Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
// Whatsapp helper module supports config behavior.
export {
evaluateSessionFreshness,
loadSessionStore,
resolveSessionKey,
resolveSessionResetPolicy,
resolveSessionResetType,
resolveStorePath,
resolveThreadFlag,
resolveChannelResetConfig,
updateLastRoute,
} from "openclaw/plugin-sdk/session-store-runtime";
export {
getRuntimeConfig,
getRuntimeConfigSourceSnapshot,
} from "openclaw/plugin-sdk/runtime-config-snapshot";
export { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime";

View File

@@ -0,0 +1,2 @@
// Whatsapp plugin module implements constants behavior.
export const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,365 @@
// Whatsapp plugin module implements deliver reply behavior.
import {
createMessageReceiptFromOutboundResults,
type MessageReceipt,
type MessageReceiptSourceResult,
} from "openclaw/plugin-sdk/channel-outbound";
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-chunking";
import {
isReasoningReplyPayload,
sendMediaWithLeadingCaption,
} from "openclaw/plugin-sdk/reply-payload";
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { requireWhatsAppInboundAdmission } from "../inbound/admission.js";
import type { WhatsAppSendResult } from "../inbound/send-result.js";
import { listWhatsAppSendResultMessageIds } from "../inbound/send-result.js";
import type { AdmittedWebInboundMessage } from "../inbound/types.js";
import { loadWebMedia } from "../media.js";
import {
type DeliverableWhatsAppOutboundPayload,
normalizeWhatsAppOutboundPayload,
normalizeWhatsAppPayloadTextPreservingIndentation,
prepareWhatsAppOutboundMedia,
sendWhatsAppOutboundWithRetry,
} from "../outbound-media-contract.js";
import { buildQuotedMessageOptions, lookupInboundMessageMeta } from "../quoted-message.js";
import { newConnectionId } from "../reconnect.js";
import { formatError } from "../session.js";
import { convertMarkdownTables } from "../text-runtime.js";
import { markdownToWhatsApp } from "../text-runtime.js";
import { whatsappOutboundLog } from "./loggers.js";
import { elide, markWhatsAppVisibleDeliveryError } from "./util.js";
export type WhatsAppReplyDeliveryResult = {
results: WhatsAppSendResult[];
receipt: MessageReceipt;
providerAccepted: boolean;
};
function resolveWhatsAppReceiptKind(
results: readonly WhatsAppSendResult[],
): Parameters<typeof createMessageReceiptFromOutboundResults>[0]["kind"] {
if (results.length > 0 && results.every((result) => result.kind === "text")) {
return "text";
}
if (results.length > 0 && results.every((result) => result.kind === "media")) {
return "media";
}
return "unknown";
}
function createWhatsAppReplyDeliveryReceipt(
results: readonly WhatsAppSendResult[],
): MessageReceipt {
const receiptResultsById = new Map<string, MessageReceiptSourceResult>();
for (const result of results) {
if (result.receipt?.parts.length) {
for (const part of result.receipt.parts) {
receiptResultsById.set(part.platformMessageId, {
...(part.raw ?? { channel: "whatsapp", messageId: part.platformMessageId }),
meta: {
...part.raw?.meta,
kind: result.kind,
providerAccepted: result.providerAccepted,
},
});
}
continue;
}
for (const messageId of listWhatsAppSendResultMessageIds(result)) {
receiptResultsById.set(messageId, {
channel: "whatsapp",
messageId,
meta: {
kind: result.kind,
providerAccepted: result.providerAccepted,
},
});
}
}
return createMessageReceiptFromOutboundResults({
results: [...receiptResultsById.values()],
kind: resolveWhatsAppReceiptKind(results),
});
}
export async function deliverWebReply(params: {
replyResult: ReplyPayload;
normalizedReplyResult?: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
msg: AdmittedWebInboundMessage;
mediaLocalRoots?: readonly string[];
maxMediaBytes: number;
textLimit: number;
chunkMode?: ChunkMode;
replyLogger: {
info: (obj: unknown, msg: string) => void;
warn: (obj: unknown, msg: string) => void;
};
connectionId?: string;
skipLog?: boolean;
tableMode?: MarkdownTableMode;
}): Promise<WhatsAppReplyDeliveryResult> {
const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params;
const admission = requireWhatsAppInboundAdmission(msg);
const conversationId = admission.conversation.id;
const isGroupConversation = admission.conversation.kind === "group";
const replyStarted = Date.now();
const sendResults: WhatsAppSendResult[] = [];
const rememberSendResult = (result: WhatsAppSendResult | undefined) => {
if (result) {
sendResults.push(result);
}
};
const finishDelivery = (): WhatsAppReplyDeliveryResult => {
const receipt = createWhatsAppReplyDeliveryReceipt(sendResults);
return {
results: sendResults,
receipt,
providerAccepted: sendResults.some((result) => result.providerAccepted),
};
};
if (isReasoningReplyPayload(replyResult)) {
whatsappOutboundLog.debug(`Suppressed reasoning payload to ${conversationId}`);
return finishDelivery();
}
const tableMode = params.tableMode ?? "code";
const chunkMode = params.chunkMode ?? "length";
const normalizedReply =
params.normalizedReplyResult ??
normalizeWhatsAppOutboundPayload(replyResult, {
normalizeText: normalizeWhatsAppPayloadTextPreservingIndentation,
});
const convertedText = markdownToWhatsApp(
convertMarkdownTables(normalizedReply.text ?? "", tableMode),
);
const textChunks = chunkMarkdownTextWithMode(convertedText, textLimit, chunkMode);
const mediaList = normalizedReply.mediaUrls ?? [];
const getQuote = () => {
if (!replyResult.replyToId) {
return undefined;
}
// Use replyToId (not msg.event.id) so batched payloads quote the correct
// per-message target. Look up cached metadata for the specific
// message being quoted — msg.payload.body may be a combined batch body.
const cached = lookupInboundMessageMeta(
admission.accountId,
msg.platform.chatJid,
replyResult.replyToId,
);
return buildQuotedMessageOptions({
messageId: replyResult.replyToId,
remoteJid: msg.platform.chatJid,
fromMe: cached?.fromMe ?? false,
participant:
cached?.participant ?? (isGroupConversation ? msg.platform.senderJid : undefined),
messageText: cached?.body ?? "",
});
};
const sendWithRetry = async <T>(fn: () => Promise<T>, label: string, maxAttempts = 3) => {
try {
return await sendWhatsAppOutboundWithRetry({
send: fn,
maxAttempts,
onRetry: ({ attempt, maxAttempts: retryMaxAttempts, backoffMs, errorText }) => {
logVerbose(
`Retrying ${label} to ${conversationId} after failure (${attempt}/${retryMaxAttempts - 1}) in ${backoffMs}ms: ${errorText}`,
);
},
});
} catch (error: unknown) {
if (sendResults.some((result) => result.providerAccepted)) {
throw markWhatsAppVisibleDeliveryError(error);
}
throw error;
}
};
// Text-only replies
if (mediaList.length === 0 && textChunks.length) {
const totalChunks = textChunks.length;
for (const [index, chunk] of textChunks.entries()) {
const chunkStarted = Date.now();
const quote = getQuote();
rememberSendResult(await sendWithRetry(() => msg.platform.reply(chunk, quote), "text"));
if (!skipLog) {
const durationMs = Date.now() - chunkStarted;
whatsappOutboundLog.debug(
`Sent chunk ${index + 1}/${totalChunks} to ${conversationId} (${durationMs.toFixed(0)}ms)`,
);
}
}
const delivery = finishDelivery();
const logPayload = {
correlationId: msg.event.id ?? newConnectionId(),
connectionId: connectionId ?? null,
to: conversationId,
from: msg.platform.recipientJid,
text: elide(replyResult.text, 240),
mediaUrl: null,
mediaSizeBytes: null,
mediaKind: null,
durationMs: Date.now() - replyStarted,
};
if (delivery.providerAccepted) {
replyLogger.info(logPayload, "auto-reply sent (text)");
} else {
replyLogger.warn(logPayload, "auto-reply text was not accepted by WhatsApp provider");
}
return delivery;
}
const remainingText = [...textChunks];
// Media (with optional caption on first item)
const leadingCaption = remainingText.shift() || "";
await sendMediaWithLeadingCaption({
mediaUrls: mediaList,
caption: leadingCaption,
send: async ({ mediaUrl, caption }) => {
const media = await prepareWhatsAppOutboundMedia(
await loadWebMedia(mediaUrl, {
maxBytes: maxMediaBytes,
localRoots: params.mediaLocalRoots,
}),
mediaUrl,
);
if (shouldLogVerbose()) {
logVerbose(
`Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
);
logVerbose(`Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`);
}
if (media.kind === "image") {
const quote = getQuote();
rememberSendResult(
await sendWithRetry(
() =>
msg.platform.sendMedia(
{
image: media.buffer,
caption,
mimetype: media.mimetype,
},
quote,
),
"media:image",
),
);
} else if (media.kind === "audio") {
const quote = getQuote();
rememberSendResult(
await sendWithRetry(
() =>
msg.platform.sendMedia(
{
audio: media.buffer,
ptt: true,
mimetype: media.mimetype,
},
quote,
),
"media:audio",
),
);
if (caption) {
rememberSendResult(
await sendWithRetry(() => msg.platform.reply(caption, quote), "media:audio-text"),
);
}
} else if (media.kind === "video") {
const quote = getQuote();
rememberSendResult(
await sendWithRetry(
() =>
msg.platform.sendMedia(
{
video: media.buffer,
caption,
mimetype: media.mimetype,
},
quote,
),
"media:video",
),
);
} else {
const quote = getQuote();
rememberSendResult(
await sendWithRetry(
() =>
msg.platform.sendMedia(
{
document: media.buffer,
fileName: media.fileName,
caption,
mimetype: media.mimetype,
},
quote,
),
"media:document",
),
);
}
whatsappOutboundLog.info(
`Sent media reply to ${conversationId} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`,
);
replyLogger.info(
{
correlationId: msg.event.id ?? newConnectionId(),
connectionId: connectionId ?? null,
to: conversationId,
from: msg.platform.recipientJid,
text: caption ?? null,
mediaUrl,
mediaSizeBytes: media.buffer.length,
mediaKind: media.kind,
durationMs: Date.now() - replyStarted,
},
"auto-reply sent (media)",
);
},
onError: async ({ error, mediaUrl, caption, isFirst }) => {
whatsappOutboundLog.error(
`Failed sending web media to ${conversationId}: ${formatError(error)}`,
);
replyLogger.warn({ err: error, mediaUrl }, "failed to send web media reply");
if (!isFirst) {
// Non-first media failures were silently dropped before. Notify the user
// so they know a trailing attachment did not arrive.
whatsappOutboundLog.warn(`Trailing media failed; sent warning to ${conversationId}`);
rememberSendResult(
await sendWithRetry(
() => msg.platform.reply("⚠️ Media unavailable.", getQuote()),
"media:fallback-unavailable",
),
);
return;
}
const warning = "⚠️ Media failed.";
const fallbackTextParts = [caption ?? "", warning].filter(Boolean);
const fallbackText = fallbackTextParts.join("\n");
if (!fallbackText) {
return;
}
whatsappOutboundLog.warn(`Media skipped; sent text-only to ${conversationId}`);
rememberSendResult(
await sendWithRetry(
() => msg.platform.reply(fallbackText, getQuote()),
"media:fallback-text",
),
);
},
});
// Remaining text chunks after media
for (const chunk of remainingText) {
rememberSendResult(
await sendWithRetry(() => msg.platform.reply(chunk, getQuote()), "media:text"),
);
}
return finishDelivery();
}

View File

@@ -0,0 +1,7 @@
// Whatsapp plugin module implements loggers behavior.
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
export const whatsappLog = createSubsystemLogger("gateway/channels/whatsapp");
export const whatsappInboundLog = whatsappLog.child("inbound");
export const whatsappOutboundLog = whatsappLog.child("outbound");
export const whatsappHeartbeatLog = whatsappLog.child("heartbeat");

View File

@@ -0,0 +1,141 @@
// Whatsapp plugin module implements mentions behavior.
import {
buildMentionRegexes,
normalizeMentionText,
} from "openclaw/plugin-sdk/channel-mention-gating";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
getComparableIdentityValues,
getMentionIdentities,
getSelfIdentity,
identitiesOverlap,
type WhatsAppIdentity,
} from "../identity.js";
import { requireWhatsAppInboundAdmission } from "../inbound/admission.js";
import type { AdmittedWebInboundMessage } from "../inbound/types.js";
import { isSelfChatMode, normalizeE164 } from "../text-runtime.js";
export type MentionConfig = {
mentionRegexes: RegExp[];
allowFrom?: Array<string | number>;
isSelfChat?: boolean;
};
export type MentionTargets = {
normalizedMentions: WhatsAppIdentity[];
self: WhatsAppIdentity;
};
export function buildMentionConfig(
cfg: OpenClawConfig,
agentId?: string,
options?: Parameters<typeof buildMentionRegexes>[2],
): MentionConfig {
const mentionRegexes = buildMentionRegexes(cfg, agentId, options);
return { mentionRegexes, allowFrom: cfg.channels?.whatsapp?.allowFrom };
}
export function resolveMentionTargets(
msg: AdmittedWebInboundMessage,
authDir?: string,
): MentionTargets {
const normalizedMentions = getMentionIdentities(msg, authDir);
const self = getSelfIdentity(msg, authDir);
return { normalizedMentions, self };
}
export function isBotMentionedFromTargets(
msg: AdmittedWebInboundMessage,
mentionCfg: MentionConfig,
targets: MentionTargets,
): boolean {
const clean = (text: string) =>
// Remove zero-width and directionality markers WhatsApp injects around display names
normalizeMentionText(text);
const explicitSelfChatOverride = typeof mentionCfg.isSelfChat === "boolean";
// `isSelfChatMode` is a config-shaped check ("is the bot's own E.164 in
// allowFrom?"), not a conversation-shaped check, so it returns true even
// for group conversations whenever the operator put their own number in
// allowFrom — which is the common config. The original mention-skip path
// was designed to prevent owner-mentioning-self in a true 1:1 self DM
// from falsely triggering the bot, so when we derive the flag implicitly
// from `allowFrom`, confine the suppression to non-group conversations
// and let real group @mentions go through the identity-overlap check
// (#49317). Explicit `mentionCfg.isSelfChat` overrides from the caller
// are honored as-is so multi-account / precomputed paths keep working.
const admission = requireWhatsAppInboundAdmission(msg);
const isGroupConversation = admission.conversation.kind === "group";
const isSelfChat = explicitSelfChatOverride
? Boolean(mentionCfg.isSelfChat)
: isSelfChatMode(targets.self.e164, mentionCfg.allowFrom) && !isGroupConversation;
const hasMentions = targets.normalizedMentions.length > 0;
if (hasMentions && !isSelfChat) {
for (const mention of targets.normalizedMentions) {
if (identitiesOverlap(targets.self, mention)) {
return true;
}
}
// If the message explicitly mentions someone else, do not fall back to regex matches.
return false;
} else if (hasMentions && isSelfChat) {
// Self-chat mode: ignore WhatsApp @mention JIDs, otherwise @mentioning the owner in self-chat triggers the bot.
}
const bodyClean = clean(msg.payload.body);
if (mentionCfg.mentionRegexes.some((re) => re.test(bodyClean))) {
return true;
}
// Fallback: detect body containing our own number (with or without +, spacing)
if (targets.self.e164) {
const selfDigits = targets.self.e164.replace(/\D/g, "");
if (selfDigits) {
const bodyDigits = bodyClean.replace(/[^\d]/g, "");
if (bodyDigits.includes(selfDigits)) {
return true;
}
const bodyNoSpace = msg.payload.body.replace(/[\s-]/g, "");
const pattern = new RegExp(`\\+?${selfDigits}`, "i");
if (pattern.test(bodyNoSpace)) {
return true;
}
}
}
return false;
}
export function debugMention(
msg: AdmittedWebInboundMessage,
mentionCfg: MentionConfig,
authDir?: string,
): { wasMentioned: boolean; details: Record<string, unknown> } {
const mentionTargets = resolveMentionTargets(msg, authDir);
const result = isBotMentionedFromTargets(msg, mentionCfg, mentionTargets);
const admission = requireWhatsAppInboundAdmission(msg);
const details = {
from: admission.conversation.id,
body: msg.payload.body,
bodyClean: normalizeMentionText(msg.payload.body),
mentionedJids: msg.group?.mentions?.jids ?? null,
normalizedMentionedJids: mentionTargets.normalizedMentions.length
? mentionTargets.normalizedMentions.map((identity) => getComparableIdentityValues(identity))
: null,
selfJid: msg.platform.self?.jid ?? msg.platform.selfJid ?? null,
selfLid: msg.platform.self?.lid ?? msg.platform.selfLid ?? null,
selfE164: msg.platform.self?.e164 ?? msg.platform.selfE164 ?? null,
resolvedSelf: mentionTargets.self,
};
return { wasMentioned: result, details };
}
export function resolveOwnerList(mentionCfg: MentionConfig, selfE164?: string | null) {
const allowFrom = mentionCfg.allowFrom;
const raw =
Array.isArray(allowFrom) && allowFrom.length > 0 ? allowFrom : selfE164 ? [selfE164] : [];
return raw
.filter((entry): entry is string => Boolean(entry && entry !== "*"))
.map((entry) => normalizeE164(entry))
.filter((entry): entry is string => Boolean(entry));
}

View File

@@ -0,0 +1,136 @@
// Whatsapp tests cover monitor state plugin behavior.
import { describe, expect, it } from "vitest";
import { createWebChannelStatusController } from "./monitor-state.js";
describe("createWebChannelStatusController", () => {
it("sets lastTransportActivityAt on noteConnected", () => {
const patches: Record<string, unknown>[] = [];
const controller = createWebChannelStatusController((s) => patches.push({ ...s }));
controller.noteConnected(1000);
const last = patches.at(-1)!;
expect(last.connected).toBe(true);
expect(last.lastTransportActivityAt).toBe(1000);
});
it("updates lastTransportActivityAt on noteInbound", () => {
const patches: Record<string, unknown>[] = [];
const controller = createWebChannelStatusController((s) => patches.push({ ...s }));
controller.noteConnected(1000);
controller.noteInbound(2000);
const last = patches.at(-1)!;
expect(last.lastTransportActivityAt).toBe(2000);
});
it("updates lastTransportActivityAt from explicit transport activity", () => {
const patches: Record<string, unknown>[] = [];
const controller = createWebChannelStatusController((s) => patches.push({ ...s }));
controller.noteConnected(1000);
controller.noteTransportActivity(3000);
const last = patches.at(-1)!;
expect(last.lastTransportActivityAt).toBe(3000);
});
it("publishes busy state for pending inbound work", () => {
const patches: Record<string, unknown>[] = [];
const controller = createWebChannelStatusController((s) => patches.push({ ...s }));
controller.noteConnected(1000);
controller.noteBusy(true, 2000);
controller.noteBusy(false, 3000);
const busy = patches.at(-2)!;
expect(busy.busy).toBe(true);
expect(busy.lastRunActivityAt).toBe(2000);
expect(busy.healthState).toBe("healthy");
const idle = patches.at(-1)!;
expect(idle.busy).toBe(false);
expect(idle.lastRunActivityAt).toBe(3000);
});
it("does not set lastTransportActivityAt on noteWatchdogStale", () => {
const patches: Record<string, unknown>[] = [];
const controller = createWebChannelStatusController((s) => patches.push({ ...s }));
controller.noteConnected(1000);
controller.noteWatchdogStale(5000);
const last = patches.at(-1)!;
// Watchdog staleness should not refresh transport activity — it means
// the check loop is running but the socket itself is idle/stale.
expect(last.lastTransportActivityAt).toBe(1000);
});
it("produces snapshots that enable stale-socket health detection", () => {
const patches: Record<string, unknown>[] = [];
const controller = createWebChannelStatusController((s) => patches.push({ ...s }));
controller.noteConnected(1000);
const last = patches.at(-1)!;
// The gateway health policy checks `connected === true && lastTransportActivityAt != null`
// to decide whether to run stale-socket detection. Both must be present.
expect(last.connected).toBe(true);
expect(last.lastTransportActivityAt).toBe(1000);
});
it("clears watchdog recovery history once the socket is healthy again", () => {
const patches: Record<string, unknown>[] = [];
const controller = createWebChannelStatusController((s) => patches.push({ ...s }));
controller.noteConnected(1000);
controller.noteClose({
at: 2000,
statusCode: 499,
error: "status=499",
reconnectAttempts: 1,
healthState: "reconnecting",
watchdogRecovery: true,
});
expect(patches.at(-1)!.lastDisconnect).toEqual({
at: 2000,
status: 499,
error: "status=499",
loggedOut: false,
});
controller.noteConnected(3000);
const last = patches.at(-1)!;
expect(last.connected).toBe(true);
expect(last.healthState).toBe("healthy");
expect(last.reconnectAttempts).toBe(0);
expect(last.lastDisconnect).toBeNull();
});
it("keeps non-watchdog reconnect history after the socket reconnects", () => {
const patches: Record<string, unknown>[] = [];
const controller = createWebChannelStatusController((s) => patches.push({ ...s }));
controller.noteConnected(1000);
controller.noteClose({
at: 2000,
statusCode: 408,
error: "status=408",
reconnectAttempts: 1,
healthState: "reconnecting",
});
controller.noteConnected(3000);
const last = patches.at(-1)!;
expect(last.connected).toBe(true);
expect(last.healthState).toBe("healthy");
expect(last.reconnectAttempts).toBe(1);
expect(last.lastDisconnect).toEqual({
at: 2000,
status: 408,
error: "status=408",
loggedOut: false,
});
});
});

View File

@@ -0,0 +1,128 @@
// Whatsapp plugin module implements monitor state behavior.
import {
createConnectedChannelStatusPatch,
createTransportActivityStatusPatch,
} from "openclaw/plugin-sdk/gateway-runtime";
import type { WebChannelHealthState, WebChannelStatus } from "./types.js";
function cloneStatus(status: WebChannelStatus): WebChannelStatus {
return {
...status,
lastDisconnect: status.lastDisconnect ? { ...status.lastDisconnect } : null,
};
}
function isTerminalHealthState(healthState: WebChannelHealthState | undefined): boolean {
return healthState === "conflict" || healthState === "logged-out" || healthState === "stopped";
}
export function createWebChannelStatusController(statusSink?: (status: WebChannelStatus) => void) {
let lastDisconnectWasWatchdogRecovery = false;
const status: WebChannelStatus = {
running: true,
connected: false,
reconnectAttempts: 0,
lastConnectedAt: null,
lastDisconnect: null,
lastInboundAt: null,
lastMessageAt: null,
lastEventAt: null,
lastError: null,
busy: false,
lastRunActivityAt: null,
healthState: "starting",
};
const emit = () => {
statusSink?.(cloneStatus(status));
};
return {
emit,
snapshot: () => status,
noteConnected(at = Date.now()) {
Object.assign(status, createConnectedChannelStatusPatch(at));
Object.assign(status, createTransportActivityStatusPatch(at));
if (lastDisconnectWasWatchdogRecovery) {
status.lastDisconnect = null;
status.reconnectAttempts = 0;
lastDisconnectWasWatchdogRecovery = false;
}
status.lastError = null;
status.healthState = "healthy";
emit();
},
noteInbound(at = Date.now()) {
status.lastInboundAt = at;
status.lastMessageAt = at;
status.lastEventAt = at;
Object.assign(status, createTransportActivityStatusPatch(at));
if (status.connected) {
status.healthState = "healthy";
}
emit();
},
noteTransportActivity(at = Date.now()) {
if (status.lastTransportActivityAt === at) {
return;
}
Object.assign(status, createTransportActivityStatusPatch(at));
emit();
},
noteBusy(busy: boolean, at = Date.now()) {
if (status.busy === busy && status.lastRunActivityAt === at) {
return;
}
status.busy = busy;
status.lastRunActivityAt = at;
if (status.connected && busy) {
status.healthState = "healthy";
}
emit();
},
noteWatchdogStale(at = Date.now()) {
status.lastEventAt = at;
if (status.connected) {
status.healthState = "stale";
}
emit();
},
noteReconnectAttempts(reconnectAttempts: number) {
status.reconnectAttempts = reconnectAttempts;
emit();
},
noteClose(params: {
at?: number;
statusCode?: number;
loggedOut?: boolean;
error?: string;
reconnectAttempts: number;
healthState: WebChannelHealthState;
watchdogRecovery?: boolean;
}) {
const at = params.at ?? Date.now();
lastDisconnectWasWatchdogRecovery = params.watchdogRecovery === true;
status.connected = false;
status.lastEventAt = at;
status.lastDisconnect = {
at,
status: params.statusCode,
error: params.error,
loggedOut: Boolean(params.loggedOut),
};
status.lastError = params.error ?? null;
status.reconnectAttempts = params.reconnectAttempts;
status.healthState = params.healthState;
emit();
},
markStopped(at = Date.now()) {
status.running = false;
status.connected = false;
status.lastEventAt = at;
if (!isTerminalHealthState(status.healthState)) {
status.healthState = "stopped";
}
emit();
},
};
}

View File

@@ -0,0 +1,714 @@
// Whatsapp plugin module implements monitor behavior.
import type { WAMessageKey } from "baileys";
import { resolveAccountEntry } from "openclaw/plugin-sdk/account-core";
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-runtime";
import { resolveInboundDebounceMs } from "openclaw/plugin-sdk/channel-inbound-debounce";
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime";
import { isControlCommandMessage } from "openclaw/plugin-sdk/command-detection";
import { drainPendingDeliveries } from "openclaw/plugin-sdk/delivery-queue-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime-env";
import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
import {
defaultRuntime,
formatDurationPrecise,
warn,
type RuntimeEnv,
} from "openclaw/plugin-sdk/runtime-env";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime";
import { resolveWhatsAppAccount, resolveWhatsAppMediaMaxBytes } from "../accounts.js";
import { WHATSAPP_AUTH_UNSTABLE_CODE, WhatsAppAuthUnstableError } from "../auth-store.js";
import {
WhatsAppConnectionController,
WHATSAPP_WATCHDOG_TIMEOUT_ERROR,
type ManagedWhatsAppListener,
} from "../connection-controller.js";
import { resolveWhatsAppInboundPolicy } from "../inbound-policy.js";
import { normalizeWebInboundMessage } from "../inbound/message-aliases.js";
import {
attachWebInboxToSocket,
readWhatsAppBaileysCacheEntry,
type WhatsAppBaileysGroupMetadataCache,
type WhatsAppBaileysMessageCache,
type WhatsAppGroupMetadataCache,
} from "../inbound/monitor.js";
import type { WebInboundMessageInput } from "../inbound/types.js";
import {
newConnectionId,
resolveHeartbeatSeconds,
resolveReconnectPolicy,
sleepWithAbort,
} from "../reconnect.js";
import { formatError, getWebAuthAgeMs, readWebSelfId } from "../session.js";
import { resolveWhatsAppSocketTiming } from "../socket-timing.js";
import { getRuntimeConfig, getRuntimeConfigSourceSnapshot } from "./config.runtime.js";
import { whatsappHeartbeatLog, whatsappLog } from "./loggers.js";
import { buildMentionConfig } from "./mentions.js";
import { createWebChannelStatusController } from "./monitor-state.js";
import { createEchoTracker } from "./monitor/echo.js";
import { formatWhatsAppInboundListeningLog } from "./monitor/listener-log.js";
import { createWebOnMessageHandler } from "./monitor/on-message.js";
import type { WebMonitorTuning } from "./types.js";
import { isLikelyWhatsAppCryptoError } from "./util.js";
function isNonRetryableWebCloseStatus(statusCode: unknown): boolean {
// WhatsApp 440 = session conflict ("Unknown Stream Errored (conflict)").
// This is persistent until the operator resolves the conflicting session.
// Baileys 428 = DisconnectReason.connectionClosed, a generic WebSocket close
// that is often transient and must stay on the reconnect path.
return statusCode === 440;
}
type ReplyResolver = typeof import("./reply-resolver.runtime.js").getReplyFromConfig;
type WhatsAppRuntimeConfig = ReturnType<typeof getRuntimeConfig>;
const loadReplyResolverRuntime = createLazyRuntimeModule(
() => import("./reply-resolver.runtime.js"),
);
function resolveWebMonitorConfigSnapshot(params: {
cfg: WhatsAppRuntimeConfig;
accountId?: string | null;
}): {
cfg: WhatsAppRuntimeConfig;
account: ReturnType<typeof resolveWhatsAppAccount>;
} {
const account = resolveWhatsAppAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const cfg = {
...params.cfg,
channels: {
...params.cfg.channels,
whatsapp: {
...params.cfg.channels?.whatsapp,
ackReaction: account.ackReaction,
messagePrefix: account.messagePrefix,
allowFrom: account.allowFrom,
groupAllowFrom: account.groupAllowFrom,
groupPolicy: account.groupPolicy,
textChunkLimit: account.textChunkLimit,
chunkMode: account.chunkMode,
mediaMaxMb: account.mediaMaxMb,
blockStreaming: account.blockStreaming,
groups: account.groups,
},
},
} satisfies WhatsAppRuntimeConfig;
return { cfg, account };
}
function normalizeReconnectAccountId(accountId?: string | null): string {
return (accountId ?? "").trim() || "default";
}
function isNoListenerReconnectError(lastError?: string): boolean {
return typeof lastError === "string" && /No active WhatsApp Web listener/i.test(lastError);
}
function resolveExplicitWhatsAppDebounceOverride(params: {
cfg: ReturnType<typeof getRuntimeConfig>;
sourceCfg?: ReturnType<typeof getRuntimeConfig> | null;
accountId: string;
}): number | undefined {
const channel = params.sourceCfg?.channels?.whatsapp;
if (!channel) {
return undefined;
}
const accountId = normalizeReconnectAccountId(params.accountId);
const accountDebounce = resolveAccountEntry(channel.accounts, accountId)?.debounceMs;
if (accountDebounce !== undefined) {
return accountDebounce;
}
if (accountId !== "default") {
const defaultAccountDebounce = resolveAccountEntry(channel.accounts, "default")?.debounceMs;
if (defaultAccountDebounce !== undefined) {
return defaultAccountDebounce;
}
}
return channel.debounceMs;
}
function isRetryableAuthUnstableError(error: unknown): error is WhatsAppAuthUnstableError {
return (
error instanceof WhatsAppAuthUnstableError ||
(typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code?: unknown }).code === WHATSAPP_AUTH_UNSTABLE_CODE)
);
}
const DEFAULT_TRANSPORT_TIMEOUT_MS = 5 * 60 * 1000;
export async function monitorWebChannel(
verbose: boolean,
listenerFactory: typeof attachWebInboxToSocket | undefined = attachWebInboxToSocket,
keepAlive = true,
replyResolver?: ReplyResolver,
runtime: RuntimeEnv = defaultRuntime,
abortSignal?: AbortSignal,
tuning: WebMonitorTuning = {},
) {
const activeReplyResolver =
replyResolver ?? (await loadReplyResolverRuntime()).getReplyFromConfig;
const runId = newConnectionId();
const replyLogger = getChildLogger({ module: "web-auto-reply", runId });
const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId });
const reconnectLogger = getChildLogger({ module: "web-reconnect", runId });
const baseCfg = getRuntimeConfig();
const sourceCfg = getRuntimeConfigSourceSnapshot();
const { cfg, account } = resolveWebMonitorConfigSnapshot({
cfg: baseCfg,
accountId: tuning.accountId,
});
const loadCurrentMonitorConfig = () =>
resolveWebMonitorConfigSnapshot({
cfg: getRuntimeConfig(),
accountId: account.accountId,
}).cfg;
const maxMediaBytes = resolveWhatsAppMediaMaxBytes(account);
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, tuning.heartbeatSeconds);
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
const socketTiming = resolveWhatsAppSocketTiming(cfg, tuning.socketTiming);
const baseMentionConfig = buildMentionConfig(cfg);
const groupHistoryLimit =
account.historyLimit ??
cfg.channels?.whatsapp?.historyLimit ??
cfg.messages?.groupChat?.historyLimit ??
DEFAULT_GROUP_HISTORY_LIMIT;
const groupHistories = new Map<
string,
Array<{
sender: string;
body: string;
timestamp?: number;
id?: string;
senderJid?: string;
}>
>();
const groupMemberNames = new Map<string, Map<string, string>>();
const groupMetadataCache: WhatsAppGroupMetadataCache = new Map();
const recentMessageKeys: WhatsAppBaileysMessageCache = new Map();
const baileysGroupMetaCache: WhatsAppBaileysGroupMetadataCache = new Map();
const echoTracker = createEchoTracker({ maxItems: 100, logVerbose });
const sleep =
tuning.sleep ??
((ms: number, signal?: AbortSignal) => sleepWithAbort(ms, signal ?? abortSignal));
const stopRequested = () => abortSignal?.aborted === true;
// Avoid noisy MaxListenersExceeded warnings in test environments where
// multiple gateway instances may be constructed.
const currentMaxListeners = process.getMaxListeners?.() ?? 10;
if (process.setMaxListeners && currentMaxListeners < 50) {
process.setMaxListeners(50);
}
let sigintStop = false;
const handleSigint = () => {
sigintStop = true;
};
process.once("SIGINT", handleSigint);
const transportTimeoutMs = tuning.transportTimeoutMs ?? DEFAULT_TRANSPORT_TIMEOUT_MS;
const messageTimeoutMs = tuning.messageTimeoutMs ?? 30 * 60 * 1000;
const watchdogCheckMs = tuning.watchdogCheckMs ?? 60 * 1000;
const controller = new WhatsAppConnectionController({
accountId: account.accountId,
authDir: account.authDir,
verbose,
keepAlive,
heartbeatSeconds,
transportTimeoutMs,
messageTimeoutMs,
watchdogCheckMs,
reconnectPolicy,
socketTiming,
abortSignal,
sleep,
isNonRetryableStatus: isNonRetryableWebCloseStatus,
});
const statusController = createWebChannelStatusController(tuning.statusSink);
statusController.emit();
try {
while (true) {
if (stopRequested()) {
break;
}
const connectionId = newConnectionId();
const inboundDebounceMs = resolveInboundDebounceMs({
cfg,
channel: "whatsapp",
overrideMs: resolveExplicitWhatsAppDebounceOverride({
cfg,
sourceCfg,
accountId: account.accountId,
}),
});
const shouldDebounce = (msg: WebInboundMessageInput) => {
const normalized = normalizeWebInboundMessage(msg);
if (normalized.payload.media?.path || normalized.payload.media?.type) {
return false;
}
if (normalized.payload.location) {
return false;
}
if (normalized.quote?.id || normalized.quote?.body) {
return false;
}
return !isControlCommandMessage(
normalized.payload.commandBody ?? normalized.payload.body,
cfg,
);
};
let connection;
try {
connection = await controller.openConnection({
connectionId,
getMessage: async (key: WAMessageKey) =>
key.id && key.remoteJid
? readWhatsAppBaileysCacheEntry(recentMessageKeys, `${key.remoteJid}:${key.id}`)
: undefined,
cachedGroupMetadata: async (jid: string) => {
const meta = readWhatsAppBaileysCacheEntry(baileysGroupMetaCache, jid);
return meta?.participants?.length ? meta : undefined;
},
createListener: async ({ sock, connection: connectionLocal }) => {
const onMessage = createWebOnMessageHandler({
cfg,
loadConfig: loadCurrentMonitorConfig,
verbose,
connectionId,
maxMediaBytes,
groupHistoryLimit,
groupHistories,
groupMemberNames,
echoTracker,
backgroundTasks: connectionLocal.backgroundTasks,
replyResolver: activeReplyResolver,
replyLogger,
baseMentionConfig,
account,
});
return (await (listenerFactory ?? attachWebInboxToSocket)({
cfg,
loadConfig: loadCurrentMonitorConfig,
verbose,
accountId: account.accountId,
authDir: account.authDir,
mediaMaxMb: account.mediaMaxMb,
selfChatMode: account.selfChatMode,
sendReadReceipts: account.sendReadReceipts,
socketTiming,
debounceMs: inboundDebounceMs,
shouldDebounce,
socketRef: controller.socketRef,
shouldRetryDisconnect: () => !sigintStop && controller.shouldRetryDisconnect(),
disconnectRetryPolicy: reconnectPolicy,
disconnectRetryAbortSignal: controller.getDisconnectRetryAbortSignal(),
groupMetadataCache,
recentMessageKeys,
baileysGroupMetaCache,
onMessage: async (msg: WebInboundMessageInput) => {
const normalized = normalizeWebInboundMessage(msg);
const inboundAt = Date.now();
controller.noteInbound(inboundAt);
statusController.noteInbound(inboundAt);
await onMessage(normalized);
},
onPendingWorkChanged: (pendingWorkCount, at) => {
statusController.noteBusy(pendingWorkCount > 0, at);
},
sock,
})) as ManagedWhatsAppListener;
},
onHeartbeat: (snapshot) => {
const authAgeMs = getWebAuthAgeMs(account.authDir);
const minutesSinceLastMessage = snapshot.lastInboundAt
? Math.floor((Date.now() - snapshot.lastInboundAt) / 60000)
: null;
const logData = {
connectionId: snapshot.connectionId,
reconnectAttempts: snapshot.reconnectAttempts,
messagesHandled: snapshot.handledMessages,
lastInboundAt: snapshot.lastInboundAt,
lastTransportActivityAt: snapshot.lastTransportActivityAt,
authAgeMs,
uptimeMs: snapshot.uptimeMs,
...(minutesSinceLastMessage !== null && minutesSinceLastMessage > 30
? { minutesSinceLastMessage }
: {}),
};
statusController.noteTransportActivity(snapshot.lastTransportActivityAt);
if (minutesSinceLastMessage && minutesSinceLastMessage > 30) {
heartbeatLogger.warn(
logData,
"⚠️ web gateway heartbeat - no messages in 30+ minutes",
);
} else {
heartbeatLogger.info(logData, "web gateway heartbeat");
}
},
onWatchdogTimeout: (snapshot) => {
const now = Date.now();
const transportSilentMs = now - snapshot.lastTransportActivityAt;
const appBaselineAt = snapshot.lastInboundAt ?? snapshot.startedAt;
const minutesSinceTransportActivity = Math.floor(transportSilentMs / 60000);
const minutesSinceAppActivity = Math.floor((now - appBaselineAt) / 60000);
const watchdogReason =
transportSilentMs > transportTimeoutMs ? "transport-inactive" : "app-silent";
statusController.noteWatchdogStale();
heartbeatLogger.warn(
{
connectionId: snapshot.connectionId,
watchdogReason,
minutesSinceTransportActivity,
minutesSinceAppActivity,
lastInboundAt: snapshot.lastInboundAt ? new Date(snapshot.lastInboundAt) : null,
lastTransportActivityAt: new Date(snapshot.lastTransportActivityAt),
messagesHandled: snapshot.handledMessages,
},
"WhatsApp watchdog timeout detected - forcing reconnect",
);
whatsappHeartbeatLog.warn(
`WhatsApp watchdog timeout (${watchdogReason}) - restarting connection`,
);
},
});
} catch (error) {
const setupDecision = controller.resolveSetupErrorDecision(error);
if (setupDecision === "aborted") {
await controller.shutdown();
break;
}
if (setupDecision) {
statusController.noteReconnectAttempts(setupDecision.reconnectAttempts);
statusController.noteClose({
statusCode: setupDecision.normalized.statusCode,
error: formatError(error),
reconnectAttempts: setupDecision.reconnectAttempts,
healthState: setupDecision.healthState,
});
if (setupDecision.action === "stop") {
reconnectLogger.warn(
{
connectionId,
status: setupDecision.normalized.statusLabel,
reconnectAttempts: setupDecision.reconnectAttempts,
maxAttempts: reconnectPolicy.maxAttempts,
},
"web reconnect: setup status error; max attempts reached",
);
if (setupDecision.healthState === "logged-out") {
runtime.error(
`WhatsApp session logged out during setup. Run \`${formatCliCommand("openclaw channels login --channel whatsapp")}\` to relink.`,
);
} else if (setupDecision.healthState === "conflict") {
runtime.error(
`WhatsApp Web connection closed during setup (status ${setupDecision.normalized.statusLabel}: session conflict). Resolve conflicting WhatsApp Web sessions, then restart the channel. To force a fresh QR, run \`${formatCliCommand("openclaw channels logout --channel whatsapp")}\` before \`${formatCliCommand("openclaw channels login --channel whatsapp")}\`. Stopping web monitoring.`,
);
} else {
runtime.error(
`WhatsApp Web connection closed during setup (status ${setupDecision.normalized.statusLabel}) after ${setupDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts} attempts. Relink with \`${formatCliCommand("openclaw channels login --channel whatsapp")}\` if the issue persists.`,
);
}
await controller.shutdown();
break;
}
reconnectLogger.info(
{
connectionId,
status: setupDecision.normalized.statusLabel,
reconnectAttempts: setupDecision.reconnectAttempts,
delayMs: setupDecision.delayMs,
},
"web reconnect: setup status error; retrying",
);
runtime.error(
`WhatsApp Web connection closed during setup (status ${setupDecision.normalized.statusLabel}). Retry ${setupDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationPrecise(setupDecision.delayMs ?? 0)}.`,
);
try {
await controller.waitBeforeRetry(setupDecision.delayMs ?? 0);
} catch {
break;
}
continue;
}
if (!isRetryableAuthUnstableError(error)) {
throw error;
}
const retryDecision = controller.consumeReconnectAttempt();
statusController.noteReconnectAttempts(retryDecision.reconnectAttempts);
statusController.noteClose({
error: error.message,
reconnectAttempts: retryDecision.reconnectAttempts,
healthState: retryDecision.healthState,
});
if (retryDecision.action === "stop") {
reconnectLogger.warn(
{
connectionId,
reconnectAttempts: retryDecision.reconnectAttempts,
maxAttempts: reconnectPolicy.maxAttempts,
},
"web reconnect: auth state stayed unstable; max attempts reached",
);
runtime.error(
`WhatsApp auth state is still stabilizing after ${retryDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts} attempts. Stopping web monitoring.`,
);
await controller.shutdown();
break;
}
reconnectLogger.info(
{
connectionId,
reconnectAttempts: retryDecision.reconnectAttempts,
delayMs: retryDecision.delayMs,
},
"web reconnect: auth state still stabilizing during inbox attach; retrying",
);
runtime.error(
`WhatsApp auth state is still stabilizing. Retry ${retryDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} for inbox attach in ${formatDurationPrecise(retryDecision.delayMs ?? 0)}.`,
);
try {
await controller.waitBeforeRetry(retryDecision.delayMs ?? 0);
} catch {
break;
}
continue;
}
statusController.noteConnected();
const approvalContextLease = registerChannelRuntimeContext({
channelRuntime: tuning.channelRuntime,
channelId: "whatsapp",
accountId: account.accountId,
capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
context: { accountId: account.accountId },
abortSignal,
});
controller.setUnhandledRejectionCleanup(
registerUnhandledRejectionHandler((reason) => {
if (!isLikelyWhatsAppCryptoError(reason)) {
return false;
}
const errorStr = formatError(reason);
reconnectLogger.warn(
{ connectionId: connection.connectionId, error: errorStr },
"web reconnect: unhandled rejection from WhatsApp socket; forcing reconnect",
);
controller.forceClose({
status: 499,
isLoggedOut: false,
error: reason,
});
return true;
}),
);
const { e164: selfE164 } = readWebSelfId(account.authDir);
const connectRoute = resolveAgentRoute({
cfg,
channel: "whatsapp",
accountId: account.accountId,
});
enqueueSystemEvent(`WhatsApp gateway connected${selfE164 ? ` as ${selfE164}` : ""}.`, {
sessionKey: connectRoute.sessionKey,
});
const normalizedAccountId = normalizeReconnectAccountId(account.accountId);
void drainPendingDeliveries({
drainKey: `whatsapp:${normalizedAccountId}`,
logLabel: "WhatsApp reconnect drain",
cfg,
log: reconnectLogger,
selectEntry: (entry) => ({
match:
entry.channel === "whatsapp" &&
normalizeReconnectAccountId(entry.accountId) === normalizedAccountId,
bypassBackoff: isNoListenerReconnectError(entry.lastError),
}),
}).catch((err: unknown) => {
reconnectLogger.warn(
{ connectionId: connection.connectionId, error: String(err) },
"reconnect drain failed",
);
});
const periodicDrainInterval = setInterval(() => {
void drainPendingDeliveries({
drainKey: `whatsapp:${normalizedAccountId}`,
logLabel: "WhatsApp periodic drain",
cfg,
log: reconnectLogger,
selectEntry: (entry) => ({
match:
entry.channel === "whatsapp" &&
normalizeReconnectAccountId(entry.accountId) === normalizedAccountId,
bypassBackoff: false,
}),
}).catch((err: unknown) => {
reconnectLogger.warn(
{ connectionId: connection.connectionId, error: String(err) },
"periodic drain failed",
);
});
}, 30_000);
const inboundPolicy = resolveWhatsAppInboundPolicy({
cfg,
accountId: account.accountId,
selfE164: selfE164 ?? null,
});
whatsappLog.info(
formatWhatsAppInboundListeningLog({
groups: inboundPolicy.account.groups,
groupPolicy: inboundPolicy.groupPolicy,
hasGroupAllowFrom: inboundPolicy.groupAllowFrom.length > 0,
}),
);
if (process.stdout.isTTY || process.stderr.isTTY) {
whatsappLog.raw("Ctrl+C to stop.");
}
if (!keepAlive) {
clearInterval(periodicDrainInterval);
approvalContextLease?.dispose();
await controller.shutdown();
return;
}
const reason = await controller.waitForClose().finally(() => {
clearInterval(periodicDrainInterval);
approvalContextLease?.dispose();
});
if (stopRequested() || sigintStop || reason === "aborted") {
await controller.shutdown();
break;
}
const decision = controller.resolveCloseDecision(reason);
if (decision === "aborted") {
await controller.shutdown();
break;
}
statusController.noteReconnectAttempts(controller.getReconnectAttempts());
reconnectLogger.info(
{
connectionId: connection.connectionId,
status: decision.normalized.statusLabel,
loggedOut: decision.normalized.isLoggedOut,
reconnectAttempts: decision.reconnectAttempts,
error: decision.normalized.errorText,
},
"web reconnect: connection closed",
);
enqueueSystemEvent(
`WhatsApp gateway disconnected (status ${decision.normalized.statusLabel})`,
{
sessionKey: connectRoute.sessionKey,
},
);
if (decision.action === "stop") {
await controller.closeCurrentConnection();
statusController.noteClose({
statusCode: decision.normalized.statusCode,
loggedOut: decision.normalized.isLoggedOut,
error: decision.normalized.errorText,
reconnectAttempts: decision.reconnectAttempts,
healthState: decision.healthState,
});
if (decision.healthState === "logged-out") {
runtime.error(
`WhatsApp session logged out. Run \`${formatCliCommand("openclaw channels login --channel whatsapp")}\` to relink.`,
);
} else if (decision.healthState === "conflict") {
reconnectLogger.warn(
{
connectionId: connection.connectionId,
status: decision.normalized.statusLabel,
error: decision.normalized.errorText,
},
"web reconnect: non-retryable close status; stopping monitor",
);
runtime.error(
`WhatsApp Web connection closed (status ${decision.normalized.statusLabel}: session conflict). Resolve conflicting WhatsApp Web sessions, then restart the channel. To force a fresh QR, run \`${formatCliCommand("openclaw channels logout --channel whatsapp")}\` before \`${formatCliCommand("openclaw channels login --channel whatsapp")}\`. Stopping web monitoring.`,
);
} else {
reconnectLogger.warn(
{
connectionId: connection.connectionId,
status: decision.normalized.statusLabel,
reconnectAttempts: decision.reconnectAttempts,
maxAttempts: reconnectPolicy.maxAttempts,
},
"web reconnect: max attempts reached; continuing in degraded mode",
);
runtime.error(
`WhatsApp Web reconnect: max attempts reached (${decision.reconnectAttempts}/${reconnectPolicy.maxAttempts}). Stopping web monitoring.`,
);
}
await controller.shutdown();
break;
}
const isWatchdogRecoveryReconnect =
decision.normalized.error === WHATSAPP_WATCHDOG_TIMEOUT_ERROR;
statusController.noteClose({
statusCode: decision.normalized.statusCode,
error: decision.normalized.errorText,
reconnectAttempts: decision.reconnectAttempts,
healthState: decision.healthState,
watchdogRecovery: isWatchdogRecoveryReconnect,
});
reconnectLogger.info(
{
connectionId: connection.connectionId,
status: decision.normalized.statusLabel,
reconnectAttempts: decision.reconnectAttempts,
maxAttempts: reconnectPolicy.maxAttempts || "unlimited",
delayMs: decision.delayMs,
},
"web reconnect: scheduling retry",
);
const reconnectMessage = isWatchdogRecoveryReconnect
? `WhatsApp Web watchdog is recovering a stale connection (status ${decision.normalized.statusLabel}). Retry ${decision.reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationPrecise(decision.delayMs ?? 0)}.`
: `WhatsApp Web connection closed (status ${decision.normalized.statusLabel}). Retry ${decision.reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationPrecise(decision.delayMs ?? 0)}… (${decision.normalized.errorText})`;
if (isWatchdogRecoveryReconnect) {
runtime.log(warn(reconnectMessage));
} else {
runtime.error(reconnectMessage);
}
await controller.closeCurrentConnection();
try {
await controller.waitBeforeRetry(decision.delayMs ?? 0);
} catch {
break;
}
}
} finally {
statusController.markStopped();
process.removeListener("SIGINT", handleSigint);
await controller.shutdown();
}
}

View File

@@ -0,0 +1,110 @@
// Whatsapp tests cover ack emoji plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { resolveWhatsAppAckEmoji } from "./ack-emoji.js";
function createConfig(
ackReaction?: NonNullable<
NonNullable<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>["ackReaction"]
>,
): OpenClawConfig {
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "agent", identity: { emoji: "🔥" } }],
},
channels: {
whatsapp: {},
},
} as OpenClawConfig;
if (ackReaction !== undefined) {
cfg.channels!.whatsapp!.ackReaction = ackReaction;
}
return cfg;
}
describe("resolveWhatsAppAckEmoji", () => {
it("keeps missing ackReaction config disabled", () => {
expect(
resolveWhatsAppAckEmoji({
cfg: createConfig(),
agentId: "agent",
ackConfig: undefined,
}),
).toBe("");
});
it("uses the configured WhatsApp emoji when present", () => {
const cfg = createConfig({ emoji: " 👀 ", direct: true, group: "mentions" });
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("👀");
});
it("keeps an explicit empty emoji disabled", () => {
const cfg = createConfig({ emoji: " ", direct: true, group: "mentions" });
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("");
});
it("falls back to the routed agent identity emoji when the ack object has no emoji", () => {
const cfg = createConfig({ direct: true, group: "mentions" });
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("🔥");
});
it("uses normalized agent ids for the identity fallback", () => {
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "Agent", identity: { emoji: "🔥" } }],
},
channels: {
whatsapp: {
ackReaction: { direct: true, group: "mentions" },
},
},
} as OpenClawConfig;
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("🔥");
});
it("uses the default ack emoji when configured without an emoji or agent identity", () => {
const cfg: OpenClawConfig = {
channels: {
whatsapp: {
ackReaction: { direct: true, group: "mentions" },
},
},
} as OpenClawConfig;
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("👀");
});
});

View File

@@ -0,0 +1,28 @@
// Whatsapp plugin module implements ack emoji behavior.
import { resolveAgentIdentity } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
const DEFAULT_WHATSAPP_ACK_REACTION = "👀";
type WhatsAppAckReactionConfig = NonNullable<
NonNullable<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>["ackReaction"]
>;
export function resolveWhatsAppAckEmoji(params: {
cfg: OpenClawConfig;
agentId: string;
ackConfig: WhatsAppAckReactionConfig | undefined;
}): string {
if (!params.ackConfig) {
return "";
}
if (params.ackConfig.emoji !== undefined) {
return params.ackConfig.emoji.trim();
}
return resolveAgentIdentityEmoji(params.cfg, params.agentId) ?? DEFAULT_WHATSAPP_ACK_REACTION;
}
function resolveAgentIdentityEmoji(cfg: OpenClawConfig, agentId: string): string | undefined {
const emoji = resolveAgentIdentity(cfg, agentId)?.emoji?.trim();
return emoji || undefined;
}

View File

@@ -0,0 +1,205 @@
// Whatsapp tests cover ack reaction plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import { maybeSendAckReaction } from "./ack-reaction.js";
const hoisted = vi.hoisted(() => ({
sendReactionWhatsApp: vi.fn(async () => undefined),
}));
vi.mock("../../send.js", () => ({
sendReactionWhatsApp: hoisted.sendReactionWhatsApp,
}));
type TestMsgOverrides = NonNullable<Parameters<typeof createTestWebInboundMessage>[0]>;
function createMessage(overrides: TestMsgOverrides = {}): AdmittedWebInboundMessage {
return createTestWebInboundMessage({
event: { id: "msg-1" },
platform: {
chatJid: "15551234567@s.whatsapp.net",
recipientJid: "15559876543",
},
admission: {
accountId: "default",
conversation: {
kind: "direct",
id: "15551234567",
},
sender: {
id: "15551234567",
},
},
...overrides,
});
}
function createConfig(
reactionLevel: "off" | "ack" | "minimal" | "extensive",
extras?: Partial<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>,
): OpenClawConfig {
return {
channels: {
whatsapp: {
reactionLevel,
ackReaction: {
emoji: "👀",
direct: true,
group: "mentions",
},
...extras,
},
},
} as OpenClawConfig;
}
type AckReactionParams = Parameters<typeof maybeSendAckReaction>[0];
const runAckReaction = (overrides: Partial<AckReactionParams> = {}) =>
maybeSendAckReaction({
cfg: createConfig("ack"),
msg: createMessage(),
agentId: "agent",
sessionKey: "whatsapp:default:15551234567",
verbose: false,
info: vi.fn(),
warn: vi.fn(),
...overrides,
});
const expectAckReactionSent = (accountId: string, cfg: OpenClawConfig = createConfig("ack")) => {
expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith(
"15551234567@s.whatsapp.net",
"msg-1",
"👀",
{
verbose: false,
fromMe: false,
accountId,
cfg,
},
);
};
describe("maybeSendAckReaction", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each(["ack", "minimal", "extensive"] as const)(
"sends ack reactions when reactionLevel is %s",
async (reactionLevel) => {
const cfg = createConfig(reactionLevel);
const ackReaction = await runAckReaction({
cfg,
});
expect(ackReaction?.ackReactionValue).toBe("👀");
await expect(ackReaction?.ackReactionPromise).resolves.toBe(true);
expectAckReactionSent("default", cfg);
},
);
it("suppresses ack reactions when reactionLevel is off", async () => {
const ackReaction = await runAckReaction({
cfg: createConfig("off"),
});
expect(ackReaction).toBeNull();
expect(hoisted.sendReactionWhatsApp).not.toHaveBeenCalled();
});
it("uses the active account reactionLevel override for ack gating", async () => {
const cfg = createConfig("off", {
accounts: {
work: {
reactionLevel: "ack",
},
},
});
const ackReaction = await runAckReaction({
cfg,
msg: createMessage({
admission: {
accountId: "work",
},
}),
sessionKey: "whatsapp:work:15551234567",
});
expect(ackReaction?.ackReactionValue).toBe("👀");
expectAckReactionSent("work", cfg);
});
it("uses the agent identity emoji when WhatsApp ackReaction has no emoji", async () => {
const cfg = {
agents: {
list: [{ id: "agent", identity: { emoji: "🔥" } }],
},
channels: {
whatsapp: {
reactionLevel: "ack",
ackReaction: {
direct: true,
group: "mentions",
},
},
},
} as OpenClawConfig;
const ackReaction = await runAckReaction({ cfg });
expect(ackReaction?.ackReactionValue).toBe("🔥");
await expect(ackReaction?.ackReactionPromise).resolves.toBe(true);
expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith(
"15551234567@s.whatsapp.net",
"msg-1",
"🔥",
{
verbose: false,
fromMe: false,
accountId: "default",
cfg,
},
);
});
it("returns a handle that removes the ack with an empty reaction", async () => {
const cfg = createConfig("ack");
const ackReaction = await runAckReaction({ cfg });
await ackReaction?.remove();
expect(hoisted.sendReactionWhatsApp).toHaveBeenLastCalledWith(
"15551234567@s.whatsapp.net",
"msg-1",
"",
{
verbose: false,
fromMe: false,
accountId: "default",
cfg,
},
);
});
it("records ack send failures on the handle", async () => {
const cfg = createConfig("ack");
const warn = vi.fn();
hoisted.sendReactionWhatsApp.mockRejectedValueOnce(new Error("session down"));
const ackReaction = await runAckReaction({ cfg, warn });
await expect(ackReaction?.ackReactionPromise).resolves.toBe(false);
expect(warn).toHaveBeenCalledWith(
{
error: "session down",
chatId: "15551234567@s.whatsapp.net",
messageId: "msg-1",
},
"failed to send ack reaction",
);
});
});

View File

@@ -0,0 +1,115 @@
// Whatsapp plugin module implements ack reaction behavior.
import {
createAckReactionHandle,
shouldAckReactionForWhatsApp,
type AckReactionHandle,
} from "openclaw/plugin-sdk/channel-feedback";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { getSenderIdentity } from "../../identity.js";
import { requireWhatsAppInboundAdmission } from "../../inbound/admission.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import { resolveWhatsAppReactionLevel } from "../../reaction-level.js";
import { sendReactionWhatsApp } from "../../send.js";
import { formatError } from "../../session.js";
import { resolveWhatsAppAckEmoji } from "./ack-emoji.js";
import { resolveGroupActivationFor } from "./group-activation.js";
export async function maybeSendAckReaction(params: {
cfg: OpenClawConfig;
msg: AdmittedWebInboundMessage;
agentId: string;
sessionKey: string;
verbose: boolean;
info: (obj: unknown, msg: string) => void;
warn: (obj: unknown, msg: string) => void;
}): Promise<AckReactionHandle | null> {
if (!params.msg.event.id) {
return null;
}
const admission = requireWhatsAppInboundAdmission(params.msg);
const accountId = admission.accountId;
// Keep ackReaction as the emoji/scope control, while letting reactionLevel
// suppress all automatic reactions when it is explicitly set to "off".
const reactionLevel = resolveWhatsAppReactionLevel({
cfg: params.cfg,
accountId,
});
if (reactionLevel.level === "off") {
return null;
}
const ackConfig = params.cfg.channels?.whatsapp?.ackReaction;
const emoji = resolveWhatsAppAckEmoji({
cfg: params.cfg,
agentId: params.agentId,
ackConfig,
});
const directEnabled = ackConfig?.direct ?? true;
const groupMode = ackConfig?.group ?? "mentions";
const isGroup = admission.conversation.kind === "group";
const conversationIdForCheck = admission.conversation.id;
const activation = isGroup
? await resolveGroupActivationFor({
cfg: params.cfg,
accountId,
agentId: params.agentId,
sessionKey: params.sessionKey,
conversationId: conversationIdForCheck,
})
: null;
const shouldSendReaction = () =>
shouldAckReactionForWhatsApp({
emoji,
isDirect: admission.conversation.kind === "direct",
isGroup,
directEnabled,
groupMode,
wasMentioned: (params.msg.groupMention?.wasMentioned ?? params.msg.wasMentioned) === true,
groupActivated: activation === "always",
});
if (!shouldSendReaction()) {
return null;
}
params.info(
{ chatId: params.msg.platform.chatJid, messageId: params.msg.event.id, emoji },
"sending ack reaction",
);
const sender = getSenderIdentity(params.msg);
const reactionOptions = {
verbose: params.verbose,
fromMe: false,
...(sender.jid ? { participant: sender.jid } : {}),
accountId,
cfg: params.cfg,
};
return createAckReactionHandle({
ackReactionValue: emoji,
send: () =>
sendReactionWhatsApp(
params.msg.platform.chatJid,
params.msg.event.id!,
emoji,
reactionOptions,
),
remove: () =>
sendReactionWhatsApp(params.msg.platform.chatJid, params.msg.event.id!, "", reactionOptions),
onSendError: (err) => {
params.warn(
{
error: formatError(err),
chatId: params.msg.platform.chatJid,
messageId: params.msg.event.id,
},
"failed to send ack reaction",
);
logVerbose(
`WhatsApp ack reaction failed for chat ${params.msg.platform.chatJid}: ${formatError(err)}`,
);
},
});
}

View File

@@ -0,0 +1,10 @@
// Whatsapp plugin module implements audio preflight behavior.
import { transcribeFirstAudio as transcribeFirstAudioImpl } from "openclaw/plugin-sdk/media-runtime";
type TranscribeFirstAudio = typeof import("openclaw/plugin-sdk/media-runtime").transcribeFirstAudio;
export async function transcribeFirstAudio(
...args: Parameters<TranscribeFirstAudio>
): ReturnType<TranscribeFirstAudio> {
return await transcribeFirstAudioImpl(...args);
}

View File

@@ -0,0 +1,156 @@
// Whatsapp plugin module implements broadcast behavior.
import type { AckReactionHandle } from "openclaw/plugin-sdk/channel-feedback";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { buildAgentSessionKey, deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing";
import {
buildAgentMainSessionKey,
DEFAULT_MAIN_KEY,
normalizeAgentId,
} from "openclaw/plugin-sdk/routing";
import { resolveWhatsAppGroupSessionRoute } from "../../group-session-key.js";
import { requireWhatsAppInboundAdmission } from "../../inbound/admission.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import { formatError } from "../../session.js";
import { whatsappInboundLog } from "../loggers.js";
import type { GroupHistoryEntry } from "./inbound-context.js";
function buildBroadcastRouteKeys(params: {
cfg: OpenClawConfig;
msg: AdmittedWebInboundMessage;
route: ReturnType<typeof resolveAgentRoute>;
peerId: string;
agentId: string;
}) {
const admission = requireWhatsAppInboundAdmission(params.msg);
const sessionKey = buildAgentSessionKey({
agentId: params.agentId,
channel: "whatsapp",
accountId: params.route.accountId,
peer: {
kind: admission.conversation.kind,
id: params.peerId,
},
dmScope: params.cfg.session?.dmScope,
identityLinks: params.cfg.session?.identityLinks,
});
const mainSessionKey = buildAgentMainSessionKey({
agentId: params.agentId,
mainKey: DEFAULT_MAIN_KEY,
});
return {
sessionKey,
mainSessionKey,
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey,
mainSessionKey,
}),
};
}
export async function maybeBroadcastMessage(params: {
cfg: OpenClawConfig;
msg: AdmittedWebInboundMessage;
peerId: string;
route: ReturnType<typeof resolveAgentRoute>;
groupHistoryKey: string;
groupHistories: Map<string, GroupHistoryEntry[]>;
processMessage: (
msg: AdmittedWebInboundMessage,
route: ReturnType<typeof resolveAgentRoute>,
groupHistoryKey: string,
opts?: {
groupHistory?: GroupHistoryEntry[];
suppressGroupHistoryClear?: boolean;
preflightAudioTranscript?: string | null;
ackAlreadySent?: boolean;
ackReaction?: AckReactionHandle | null;
},
) => Promise<boolean>;
preflightAudioTranscript?: string | null;
ackAlreadySent?: boolean;
ackReaction?: AckReactionHandle | null;
}) {
const broadcastAgents = params.cfg.broadcast?.[params.peerId];
if (!broadcastAgents || !Array.isArray(broadcastAgents)) {
return false;
}
if (broadcastAgents.length === 0) {
return false;
}
const strategy = params.cfg.broadcast?.strategy || "parallel";
whatsappInboundLog.info(`Broadcasting message to ${broadcastAgents.length} agents (${strategy})`);
const agentIds = params.cfg.agents?.list?.map((agent) => normalizeAgentId(agent.id));
const hasKnownAgents = (agentIds?.length ?? 0) > 0;
const admission = requireWhatsAppInboundAdmission(params.msg);
const isGroupConversation = admission.conversation.kind === "group";
const groupHistorySnapshot = isGroupConversation
? (params.groupHistories.get(params.groupHistoryKey) ?? [])
: undefined;
const processForAgent = async (agentId: string): Promise<boolean> => {
const normalizedAgentId = normalizeAgentId(agentId);
if (hasKnownAgents && !agentIds?.includes(normalizedAgentId)) {
whatsappInboundLog.warn(`Broadcast agent ${agentId} not found in agents.list; skipping`);
return false;
}
const routeKeys = buildBroadcastRouteKeys({
cfg: params.cfg,
msg: params.msg,
route: params.route,
peerId: params.peerId,
agentId: normalizedAgentId,
});
const baseAgentRoute = {
...params.route,
agentId: normalizedAgentId,
...routeKeys,
};
const agentRoute = isGroupConversation
? resolveWhatsAppGroupSessionRoute(baseAgentRoute)
: baseAgentRoute;
try {
const opts: {
groupHistory?: GroupHistoryEntry[];
suppressGroupHistoryClear: true;
preflightAudioTranscript?: string | null;
ackAlreadySent?: boolean;
ackReaction?: AckReactionHandle | null;
} = {
groupHistory: groupHistorySnapshot,
suppressGroupHistoryClear: true,
};
if (params.preflightAudioTranscript !== undefined) {
opts.preflightAudioTranscript = params.preflightAudioTranscript;
}
if (params.ackAlreadySent === true) {
opts.ackAlreadySent = true;
}
if (params.ackReaction !== undefined) {
opts.ackReaction = params.ackReaction;
}
return await params.processMessage(params.msg, agentRoute, params.groupHistoryKey, opts);
} catch (err) {
whatsappInboundLog.error(`Broadcast agent ${agentId} failed: ${formatError(err)}`);
return false;
}
};
if (strategy === "sequential") {
for (const agentId of broadcastAgents) {
await processForAgent(agentId);
}
} else {
await Promise.allSettled(broadcastAgents.map(processForAgent));
}
if (isGroupConversation) {
params.groupHistories.set(params.groupHistoryKey, []);
}
return true;
}

View File

@@ -0,0 +1,20 @@
// Whatsapp plugin module implements commands behavior.
export function stripMentionsForCommand(
text: string,
mentionRegexes: RegExp[],
selfE164?: string | null,
) {
let result = text;
for (const re of mentionRegexes) {
result = result.replace(re, " ");
}
if (selfE164) {
// `selfE164` is usually like "+1234"; strip down to digits so we can match "+?1234" safely.
const digits = selfE164.replace(/\D/g, "");
if (digits) {
const pattern = new RegExp(`\\+?${digits}`, "g");
result = result.replace(pattern, " ");
}
}
return result.replace(/\s+/g, " ").trim();
}

View File

@@ -0,0 +1,65 @@
// Whatsapp plugin module implements echo behavior.
export type EchoTracker = {
rememberText: (
text: string | undefined,
opts: {
combinedBody?: string;
combinedBodySessionKey?: string;
logVerboseMessage?: boolean;
},
) => void;
has: (key: string) => boolean;
forget: (key: string) => void;
buildCombinedKey: (params: { sessionKey: string; combinedBody: string }) => string;
};
export function createEchoTracker(params: {
maxItems?: number;
logVerbose?: (msg: string) => void;
}): EchoTracker {
const recentlySent = new Set<string>();
const maxItems = Math.max(1, params.maxItems ?? 100);
const buildCombinedKey = (p: { sessionKey: string; combinedBody: string }) =>
`combined:${p.sessionKey}:${p.combinedBody}`;
const trim = () => {
while (recentlySent.size > maxItems) {
const firstKey = recentlySent.values().next().value;
if (!firstKey) {
break;
}
recentlySent.delete(firstKey);
}
};
const rememberText: EchoTracker["rememberText"] = (text, opts) => {
if (!text) {
return;
}
recentlySent.add(text);
if (opts.combinedBody && opts.combinedBodySessionKey) {
recentlySent.add(
buildCombinedKey({
sessionKey: opts.combinedBodySessionKey,
combinedBody: opts.combinedBody,
}),
);
}
if (opts.logVerboseMessage) {
params.logVerbose?.(
`Added to echo detection set (size now: ${recentlySent.size}): ${text.slice(0, 50)}...`,
);
}
trim();
};
return {
rememberText,
has: (key) => recentlySent.has(key),
forget: (key) => {
recentlySent.delete(key);
},
buildCombinedKey,
};
}

View File

@@ -0,0 +1,2 @@
// Whatsapp plugin module implements group activation behavior.
export { normalizeGroupActivation } from "openclaw/plugin-sdk/group-activation";

View File

@@ -0,0 +1,193 @@
// Whatsapp tests cover group activation plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { saveSessionStore, type SessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadSessionStore } from "../config.runtime.js";
import { resolveGroupActivationFor } from "./group-activation.js";
const GROUP_CONVERSATION_ID = "123@g.us";
const LEGACY_GROUP_SESSION_KEY = "agent:main:whatsapp:group:123@g.us";
const WORK_GROUP_SESSION_KEY = "agent:main:whatsapp:group:123@g.us:thread:whatsapp-account-work";
type SessionStoreEntry = {
groupActivation?: unknown;
sessionId?: unknown;
updatedAt?: unknown;
};
async function makeSessionStore(
entries: Record<string, unknown> = {},
): Promise<{ storePath: string; cleanup: () => Promise<void> }> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-"));
const storePath = path.join(dir, "sessions.json");
await saveSessionStore(storePath, entries as Record<string, SessionEntry>, {
skipMaintenance: true,
});
return {
storePath,
cleanup: async () => {
await fs.rm(dir, { recursive: true, force: true });
},
};
}
const resolveWorkGroupActivation = (storePath: string) =>
resolveGroupActivationFor({
cfg: {
channels: {
whatsapp: {
accounts: {
work: {},
},
},
},
session: { store: storePath },
} as never,
accountId: "work",
agentId: "main",
sessionKey: WORK_GROUP_SESSION_KEY,
conversationId: GROUP_CONVERSATION_ID,
});
const expectWorkGroupActivationEntry = async (
storePath: string,
assertEntry?: (entry: SessionStoreEntry | undefined) => void,
) => {
await vi.waitFor(() => {
const scopedEntry = loadSessionStore(storePath, { skipCache: true })[WORK_GROUP_SESSION_KEY];
expect(scopedEntry?.groupActivation).toBe("always");
assertEntry?.(scopedEntry);
});
};
const expectResolvedWorkGroupActivation = async (
storePath: string,
assertEntry?: (entry: SessionStoreEntry | undefined) => void,
) => {
const activation = await resolveWorkGroupActivation(storePath);
expect(activation).toBe("always");
await expectWorkGroupActivationEntry(storePath, assertEntry);
};
describe("resolveGroupActivationFor", () => {
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
while (cleanups.length > 0) {
await cleanups.pop()?.();
}
});
it("reads legacy named-account group activation and backfills the scoped key", async () => {
const { storePath, cleanup } = await makeSessionStore({
[LEGACY_GROUP_SESSION_KEY]: {
groupActivation: "always",
sessionId: "legacy-session",
updatedAt: 123,
},
});
cleanups.push(cleanup);
await expectResolvedWorkGroupActivation(storePath, (scopedEntry) => {
expect(scopedEntry?.sessionId).toBeUndefined();
expect(scopedEntry?.updatedAt).toBeUndefined();
});
});
it("preserves legacy group activation when the scoped entry already exists without activation", async () => {
const { storePath, cleanup } = await makeSessionStore({
[LEGACY_GROUP_SESSION_KEY]: {
groupActivation: "always",
},
[WORK_GROUP_SESSION_KEY]: {
sessionId: "scoped-session",
},
});
cleanups.push(cleanup);
await expectResolvedWorkGroupActivation(storePath, (scopedEntry) => {
expect(scopedEntry?.sessionId).toBe("scoped-session");
});
});
it("does not wake the default account from an activation-only legacy group entry in multi-account setups", async () => {
const { storePath, cleanup } = await makeSessionStore({
[LEGACY_GROUP_SESSION_KEY]: {
groupActivation: "always",
},
});
cleanups.push(cleanup);
const cfg = {
channels: {
whatsapp: {
groups: {
"*": {
requireMention: true,
},
},
accounts: {
work: {},
},
},
},
session: { store: storePath },
} as never;
const workActivation = await resolveGroupActivationFor({
cfg,
accountId: "work",
agentId: "main",
sessionKey: WORK_GROUP_SESSION_KEY,
conversationId: GROUP_CONVERSATION_ID,
});
expect(workActivation).toBe("always");
const defaultActivation = await resolveGroupActivationFor({
cfg,
accountId: "default",
agentId: "main",
sessionKey: LEGACY_GROUP_SESSION_KEY,
conversationId: GROUP_CONVERSATION_ID,
});
expect(defaultActivation).toBe("mention");
await expectWorkGroupActivationEntry(storePath);
});
it("does not treat mixed-case default account keys as named accounts", async () => {
const { storePath, cleanup } = await makeSessionStore({
[LEGACY_GROUP_SESSION_KEY]: {
groupActivation: "always",
},
});
cleanups.push(cleanup);
const activation = await resolveGroupActivationFor({
cfg: {
channels: {
whatsapp: {
groups: {
"*": {
requireMention: true,
},
},
accounts: {
Default: {},
},
},
},
session: { store: storePath },
} as never,
accountId: "default",
agentId: "main",
sessionKey: LEGACY_GROUP_SESSION_KEY,
conversationId: GROUP_CONVERSATION_ID,
});
expect(activation).toBe("always");
});
});

View File

@@ -0,0 +1,88 @@
// Whatsapp plugin module implements group activation behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
import {
getSessionEntry,
patchSessionEntry,
resolveStorePath,
type SessionEntry,
} from "openclaw/plugin-sdk/session-store-runtime";
import { resolveWhatsAppLegacyGroupSessionKey } from "../../group-session-key.js";
import { resolveWhatsAppInboundPolicy } from "../../inbound-policy.js";
import { normalizeGroupActivation } from "./group-activation.runtime.js";
function hasNamedWhatsAppAccounts(cfg: OpenClawConfig) {
const accountIds = Object.keys(cfg.channels?.whatsapp?.accounts ?? {});
return accountIds.some((accountId) => normalizeAccountId(accountId) !== DEFAULT_ACCOUNT_ID);
}
function isActivationOnlyEntry(
entry:
| {
groupActivation?: unknown;
sessionId?: unknown;
updatedAt?: unknown;
}
| undefined,
) {
return (
entry?.groupActivation !== undefined &&
typeof entry?.sessionId !== "string" &&
typeof entry?.updatedAt !== "number"
);
}
/** Resolves group activation for a WhatsApp conversation and backfills scoped session metadata. */
export async function resolveGroupActivationFor(params: {
cfg: OpenClawConfig;
accountId?: string | null;
agentId: string;
sessionKey: string;
conversationId: string;
}) {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.agentId,
});
const sessionScope = { storePath, agentId: params.agentId };
const legacySessionKey = resolveWhatsAppLegacyGroupSessionKey({
sessionKey: params.sessionKey,
accountId: params.accountId,
});
const legacyEntry = legacySessionKey
? getSessionEntry({ ...sessionScope, sessionKey: legacySessionKey })
: undefined;
const scopedEntry = getSessionEntry({ ...sessionScope, sessionKey: params.sessionKey });
const normalizedAccountId = normalizeAccountId(params.accountId);
const ignoreScopedActivation =
normalizedAccountId === DEFAULT_ACCOUNT_ID &&
hasNamedWhatsAppAccounts(params.cfg) &&
isActivationOnlyEntry(scopedEntry);
const activation =
(ignoreScopedActivation ? undefined : scopedEntry?.groupActivation) ??
legacyEntry?.groupActivation;
if (activation !== undefined && scopedEntry?.groupActivation === undefined) {
// Activation-only backfills must not synthesize session ids or activity.
// replaceEntry preserves existing scoped metadata while keeping fallback writes sparse.
await patchSessionEntry({
...sessionScope,
sessionKey: params.sessionKey,
fallbackEntry: {} as SessionEntry,
replaceEntry: true,
update: (entry) => {
if (entry.groupActivation !== undefined) {
return null;
}
return {
...entry,
groupActivation: activation,
};
},
});
}
const requireMention = resolveWhatsAppInboundPolicy({
cfg: params.cfg,
accountId: params.accountId,
}).resolveConversationRequireMention(params.conversationId);
const defaultActivation = !requireMention ? "always" : "mention";
return normalizeGroupActivation(activation) ?? defaultActivation;
}

View File

@@ -0,0 +1,228 @@
// Whatsapp tests cover group gating.allowlist warn plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./group-activation.js", () => ({
resolveGroupActivationFor: vi.fn(async () => "mention"),
}));
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import type { MentionConfig } from "../mentions.js";
import {
resetGroupDropWarningsForTests,
applyGroupGating,
type GroupHistoryEntry,
} from "./group-gating.js";
function makeUnregisteredGroupMsg(
conversationId: string,
accountId = "default",
): AdmittedWebInboundMessage {
return createTestWebInboundMessage({
event: {
id: `msg-${conversationId}`,
timestamp: 1700000000,
},
payload: {
body: "@openclaw hello",
},
platform: {
chatJid: conversationId,
recipientJid: "+15550000001",
sender: { e164: "+15550000002", name: "Alice" },
},
admission: {
accountId,
conversation: {
kind: "group",
id: conversationId,
},
sender: {
id: "+15550000002",
},
senderAccess: {
reasonCode: "group_policy_allowed",
},
},
});
}
type WarnLogger = (obj: unknown, msg: string) => void;
type ApplyGroupGatingParams = Parameters<typeof applyGroupGating>[0];
function makeParams(
msg: AdmittedWebInboundMessage,
warn: WarnLogger,
cfg: ApplyGroupGatingParams["cfg"] = {
channels: {
whatsapp: {
groupPolicy: "allowlist",
groups: {
"registered@g.us": {},
},
accounts: {
work: {
groupPolicy: "allowlist",
groups: {
"registered@g.us": {},
},
},
},
},
},
messages: {
groupChat: {
mentionPatterns: ["\\bopenclaw\\b"],
},
},
} as never,
) {
const admission = msg.admission;
if (!admission) {
throw new Error("Expected admitted WhatsApp test message");
}
return {
cfg,
msg,
groupHistoryKey: `whatsapp:group:${admission.conversation.id}`,
agentId: "main",
sessionKey: `agent:main:whatsapp:group:${admission.conversation.id}`,
baseMentionConfig: { mentionRegexes: [/\bopenclaw\b/i] } satisfies MentionConfig,
groupHistories: new Map<string, GroupHistoryEntry[]>(),
groupHistoryLimit: 20,
groupMemberNames: new Map<string, Map<string, string>>(),
logVerbose: vi.fn(),
replyLogger: { debug: vi.fn(), warn },
};
}
describe("applyGroupGating allowlist drop warning", () => {
beforeEach(() => {
resetGroupDropWarningsForTests();
});
it("emits a warn log naming the root groups path for the default account", async () => {
const warn = vi.fn<WarnLogger>();
const msg = makeUnregisteredGroupMsg("unregistered@g.us");
const params = makeParams(msg, warn);
const result = await applyGroupGating(params);
expect(result).toEqual({ shouldProcess: false });
expect(warn).toHaveBeenCalledTimes(1);
expect(params.logVerbose).toHaveBeenCalledWith(
'Dropping message from unregistered WhatsApp group unregistered@g.us. Add the group JID to channels.whatsapp.groups, or add "*" there to admit all groups. Sender authorization still applies.',
);
const [context, message] = warn.mock.calls[0] ?? [];
expect(context).toMatchObject({
conversationId: "unregistered@g.us",
accountId: "default",
groupsPath: "channels.whatsapp.groups",
});
expect(message).toContain("unregistered@g.us");
expect(message).toContain("channels.whatsapp.groups");
});
it("names the account-scoped groups path for non-default accounts", async () => {
const warn = vi.fn<WarnLogger>();
const msg = makeUnregisteredGroupMsg("unregistered@g.us", "work");
await applyGroupGating(makeParams(msg, warn));
expect(warn).toHaveBeenCalledTimes(1);
const [context, message] = warn.mock.calls[0] ?? [];
expect(context).toMatchObject({
conversationId: "unregistered@g.us",
accountId: "work",
groupsPath: "channels.whatsapp.accounts.work.groups",
});
expect(message).toContain("channels.whatsapp.accounts.work.groups");
});
it("names the root groups path for non-default accounts inheriting root groups", async () => {
const warn = vi.fn<WarnLogger>();
const msg = makeUnregisteredGroupMsg("unregistered@g.us", "work");
const cfg = {
channels: {
whatsapp: {
groupPolicy: "allowlist",
groups: {
"registered@g.us": {},
},
accounts: {
work: {
groupPolicy: "allowlist",
},
},
},
},
messages: {
groupChat: {
mentionPatterns: ["\\bopenclaw\\b"],
},
},
} as ApplyGroupGatingParams["cfg"];
await applyGroupGating(makeParams(msg, warn, cfg));
expect(warn).toHaveBeenCalledTimes(1);
const [context, message] = warn.mock.calls[0] ?? [];
expect(context).toMatchObject({
conversationId: "unregistered@g.us",
accountId: "work",
groupsPath: "channels.whatsapp.groups",
});
expect(message).toContain("channels.whatsapp.groups");
});
it("warns once but keeps verbose diagnostics per dropped message", async () => {
const warn = vi.fn<WarnLogger>();
const first = makeParams(makeUnregisteredGroupMsg("loud@g.us"), warn);
const second = makeParams(makeUnregisteredGroupMsg("loud@g.us"), warn);
const third = makeParams(makeUnregisteredGroupMsg("loud@g.us"), warn);
await applyGroupGating(first);
await applyGroupGating(second);
await applyGroupGating(third);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0]?.[1]).toContain("loud@g.us");
expect(first.logVerbose).toHaveBeenCalledTimes(1);
expect(second.logVerbose).toHaveBeenCalledTimes(1);
expect(third.logVerbose).toHaveBeenCalledTimes(1);
});
it("warns separately for distinct conversations", async () => {
const warn = vi.fn<WarnLogger>();
await applyGroupGating(makeParams(makeUnregisteredGroupMsg("a@g.us"), warn));
await applyGroupGating(makeParams(makeUnregisteredGroupMsg("b@g.us"), warn));
expect(warn).toHaveBeenCalledTimes(2);
expect(warn.mock.calls[0]?.[1]).toContain("a@g.us");
expect(warn.mock.calls[1]?.[1]).toContain("b@g.us");
});
it("evicts old warning keys instead of growing without bound", async () => {
const warn = vi.fn<WarnLogger>();
await applyGroupGating(makeParams(makeUnregisteredGroupMsg("evicted@g.us"), warn));
for (let index = 0; index < 100; index += 1) {
await applyGroupGating(makeParams(makeUnregisteredGroupMsg(`overflow-${index}@g.us`), warn));
}
await applyGroupGating(makeParams(makeUnregisteredGroupMsg("evicted@g.us"), warn));
expect(warn).toHaveBeenCalledTimes(102);
expect(warn.mock.calls[0]?.[1]).toContain("evicted@g.us");
expect(warn.mock.calls[101]?.[1]).toContain("evicted@g.us");
});
it("does not warn when the group is registered", async () => {
const warn = vi.fn<WarnLogger>();
const msg = makeUnregisteredGroupMsg("registered@g.us");
await applyGroupGating(makeParams(msg, warn));
expect(warn).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,127 @@
// Whatsapp tests cover group gating.audio preflight plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./group-activation.js", () => ({
resolveGroupActivationFor: vi.fn(async () => "mention"),
}));
import { createTestWebAudioInboundMessage } from "../../inbound/test-message.test-helper.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import type { MentionConfig } from "../mentions.js";
import { resolveGroupActivationFor } from "./group-activation.js";
import { applyGroupGating, type GroupHistoryEntry } from "./group-gating.js";
function makeGroupAudioMsg(): AdmittedWebInboundMessage {
return createTestWebAudioInboundMessage({
platform: {
chatJid: "1203630@g.us",
sender: { e164: "+15550000002", name: "Alice" },
},
admission: {
conversation: {
kind: "group",
id: "1203630@g.us",
},
sender: {
id: "+15550000002",
},
senderAccess: {
reasonCode: "group_policy_allowed",
},
},
wasMentioned: false,
});
}
function makeParams(
msg: AdmittedWebInboundMessage,
groupHistories: Map<string, GroupHistoryEntry[]>,
) {
return {
cfg: {
channels: {
whatsapp: {
groupPolicy: "open",
},
},
messages: {
groupChat: {
mentionPatterns: ["\\bopenclaw\\b"],
},
},
} as never,
msg,
groupHistoryKey: "whatsapp:group:1203630",
agentId: "main",
sessionKey: "agent:main:whatsapp:group:1203630",
baseMentionConfig: { mentionRegexes: [/\bopenclaw\b/i] } satisfies MentionConfig,
groupHistories,
groupHistoryLimit: 20,
groupMemberNames: new Map<string, Map<string, string>>(),
logVerbose: vi.fn(),
replyLogger: { debug: vi.fn(), warn: vi.fn() },
};
}
describe("applyGroupGating audio preflight mention text", () => {
let groupHistories: Map<string, GroupHistoryEntry[]>;
beforeEach(() => {
groupHistories = new Map();
});
it("defers a missing mention without storing placeholder history", async () => {
const msg = makeGroupAudioMsg();
const result = await applyGroupGating({
...makeParams(msg, groupHistories),
deferMissingMention: true,
});
expect(result).toEqual({ shouldProcess: false, needsMentionText: true });
expect(groupHistories.get("whatsapp:group:1203630")).toBeUndefined();
});
it("accepts voice transcript text that satisfies mention gating", async () => {
const msg = makeGroupAudioMsg();
const result = await applyGroupGating({
...makeParams(msg, groupHistories),
mentionText: "openclaw please summarize the thread",
});
expect(result).toEqual({ shouldProcess: true });
expect(msg.groupMention).toEqual({ wasMentioned: true, requireMention: true });
expect(groupHistories.get("whatsapp:group:1203630")).toBeUndefined();
});
it("carries always-on activation into dispatch", async () => {
vi.mocked(resolveGroupActivationFor).mockResolvedValueOnce("always");
const msg = makeGroupAudioMsg();
const result = await applyGroupGating(makeParams(msg, groupHistories));
expect(result).toEqual({ shouldProcess: true });
expect(msg.groupMention).toEqual({ wasMentioned: false, requireMention: false });
});
it("stores transcript text instead of the audio placeholder when mention is still missing", async () => {
const msg = makeGroupAudioMsg();
const result = await applyGroupGating({
...makeParams(msg, groupHistories),
mentionText: "please summarize the thread",
});
expect(result).toEqual({ shouldProcess: false });
expect(groupHistories.get("whatsapp:group:1203630")).toEqual([
{
sender: "Alice (+15550000002)",
body: "please summarize the thread",
timestamp: 1700000000,
id: "msg-1",
senderJid: undefined,
},
]);
});
});

View File

@@ -0,0 +1,9 @@
// Whatsapp plugin module implements group gating behavior.
export {
implicitMentionKindWhen,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-mention-gating";
export { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
export { createChannelHistoryWindow } from "openclaw/plugin-sdk/reply-history";
export { parseActivationCommand } from "openclaw/plugin-sdk/group-activation";
export { normalizeE164 } from "../../text-runtime.js";

View File

@@ -0,0 +1,277 @@
// Whatsapp plugin module implements group gating behavior.
import type { BuildMentionRegexesOptions } from "openclaw/plugin-sdk/channel-mention-gating";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveWhatsAppGroupsConfigPath } from "../../group-config-path.js";
import {
getPrimaryIdentityId,
getReplyContext,
getSelfIdentity,
getSenderIdentity,
identitiesOverlap,
} from "../../identity.js";
import { resolveWhatsAppInboundPolicy } from "../../inbound-policy.js";
import { requireWhatsAppInboundAdmission } from "../../inbound/admission.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import type { MentionConfig } from "../mentions.js";
import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js";
import { stripMentionsForCommand } from "./commands.js";
import { resolveGroupActivationFor } from "./group-activation.js";
import {
hasControlCommand,
implicitMentionKindWhen,
normalizeE164,
parseActivationCommand,
createChannelHistoryWindow,
resolveInboundMentionDecision,
} from "./group-gating.runtime.js";
import { noteGroupMember } from "./group-members.js";
export type GroupHistoryEntry = {
sender: string;
body: string;
timestamp?: number;
id?: string;
senderJid?: string;
};
type ApplyGroupGatingParams = {
cfg: OpenClawConfig;
msg: AdmittedWebInboundMessage;
mentionText?: string;
deferMissingMention?: boolean;
groupHistoryKey: string;
agentId: string;
sessionKey: string;
baseMentionConfig: MentionConfig;
providerMentionPatterns?: BuildMentionRegexesOptions["providerPolicy"];
authDir?: string;
groupHistories: Map<string, GroupHistoryEntry[]>;
groupHistoryLimit: number;
groupMemberNames: Map<string, Map<string, string>>;
selfChatMode?: boolean;
logVerbose: (msg: string) => void;
replyLogger: {
debug: (obj: unknown, msg: string) => void;
warn: (obj: unknown, msg: string) => void;
};
};
const MAX_GROUP_DROP_WARNINGS = 100;
const groupDropWarned = new Set<string>();
export function resetGroupDropWarningsForTests() {
groupDropWarned.clear();
}
function shouldWarnForGroupDrop(warnKey: string): boolean {
if (groupDropWarned.has(warnKey)) {
return false;
}
groupDropWarned.add(warnKey);
while (groupDropWarned.size > MAX_GROUP_DROP_WARNINGS) {
const oldest = groupDropWarned.values().next().value;
if (!oldest) {
break;
}
groupDropWarned.delete(oldest);
}
return true;
}
function isOwnerSender(
baseMentionConfig: MentionConfig,
msg: AdmittedWebInboundMessage,
authDir?: string,
) {
const sender = normalizeE164(getSenderIdentity(msg, authDir).e164 ?? "");
if (!sender) {
return false;
}
const owners = resolveOwnerList(
baseMentionConfig,
getSelfIdentity(msg, authDir).e164 ?? undefined,
);
return owners.includes(sender);
}
function recordPendingGroupHistoryEntry(params: {
msg: AdmittedWebInboundMessage;
body?: string;
groupHistories: Map<string, GroupHistoryEntry[]>;
groupHistoryKey: string;
groupHistoryLimit: number;
}) {
const senderIdentity = getSenderIdentity(params.msg);
const sender =
senderIdentity.name && senderIdentity.e164
? `${senderIdentity.name} (${senderIdentity.e164})`
: (senderIdentity.name ??
senderIdentity.e164 ??
getPrimaryIdentityId(senderIdentity) ??
"Unknown");
createChannelHistoryWindow({ historyMap: params.groupHistories }).record({
historyKey: params.groupHistoryKey,
limit: params.groupHistoryLimit,
entry: {
sender,
body: params.body ?? params.msg.payload.body,
timestamp: params.msg.event.timestamp,
id: params.msg.event.id,
senderJid: senderIdentity.jid ?? params.msg.platform.senderJid,
},
});
}
function skipGroupMessageAndStoreHistory(
params: ApplyGroupGatingParams,
verboseMessage: string,
body?: string,
) {
params.logVerbose(verboseMessage);
recordPendingGroupHistoryEntry({
msg: params.msg,
body,
groupHistories: params.groupHistories,
groupHistoryKey: params.groupHistoryKey,
groupHistoryLimit: params.groupHistoryLimit,
});
return { shouldProcess: false } as const;
}
export async function applyGroupGating(params: ApplyGroupGatingParams) {
const sender = getSenderIdentity(params.msg);
const self = getSelfIdentity(params.msg, params.authDir);
const admission = requireWhatsAppInboundAdmission(params.msg);
const conversationId = admission.conversation.id;
const inboundPolicy = resolveWhatsAppInboundPolicy({
cfg: params.cfg,
accountId: admission.accountId,
selfE164: self.e164 ?? null,
});
const conversationGroupPolicy = inboundPolicy.resolveConversationGroupPolicy(conversationId);
if (conversationGroupPolicy.allowlistEnabled && !conversationGroupPolicy.allowed) {
const accountId = inboundPolicy.account.accountId;
const warnKey = `${accountId}:${conversationId}`;
if (shouldWarnForGroupDrop(warnKey)) {
const groupsPath = resolveWhatsAppGroupsConfigPath({ cfg: params.cfg, accountId });
params.replyLogger.warn(
{ conversationId, accountId, groupsPath },
`WhatsApp group ${conversationId} not in ${groupsPath} — inbound dropped. Add the group JID to ${groupsPath} (or add "*" there to admit all groups). Sender authorization still applies.`,
);
}
params.logVerbose(
`Dropping message from unregistered WhatsApp group ${conversationId}. Add the group JID to channels.whatsapp.groups, or add "*" there to admit all groups. Sender authorization still applies.`,
);
return { shouldProcess: false };
}
noteGroupMember(
params.groupMemberNames,
params.groupHistoryKey,
sender.e164 ?? undefined,
sender.name ?? undefined,
);
const baseMentionConfig = {
...params.baseMentionConfig,
allowFrom: inboundPolicy.configuredAllowFrom,
};
const mentionConfig = {
...buildMentionConfig(params.cfg, params.agentId, {
provider: "whatsapp",
conversationId,
providerPolicy: params.providerMentionPatterns,
}),
allowFrom: inboundPolicy.configuredAllowFrom,
};
const mentionMsg: AdmittedWebInboundMessage =
params.mentionText !== undefined
? { ...params.msg, payload: { ...params.msg.payload, body: params.mentionText } }
: {
...params.msg,
payload: {
...params.msg.payload,
body: params.msg.payload.commandBody ?? params.msg.payload.body,
},
};
const commandBody = stripMentionsForCommand(
mentionMsg.payload.body,
mentionConfig.mentionRegexes,
self.e164,
);
const activationCommand = parseActivationCommand(commandBody);
const owner = isOwnerSender(baseMentionConfig, params.msg, params.authDir);
const shouldBypassMention = owner && hasControlCommand(commandBody, params.cfg);
if (activationCommand.hasCommand && !owner) {
return skipGroupMessageAndStoreHistory(
params,
`Ignoring /activation from non-owner in group ${conversationId}`,
);
}
const mentionDebug = debugMention(mentionMsg, mentionConfig, params.authDir);
params.replyLogger.debug(
{
conversationId,
wasMentioned: mentionDebug.wasMentioned,
...mentionDebug.details,
},
"group mention debug",
);
const wasMentioned = mentionDebug.wasMentioned;
const activation = await resolveGroupActivationFor({
cfg: params.cfg,
accountId: inboundPolicy.account.accountId,
agentId: params.agentId,
sessionKey: params.sessionKey,
conversationId,
});
const requireMention = activation !== "always";
const replyContext = getReplyContext(params.msg, params.authDir);
const sharedNumberSelfChat = params.selfChatMode === true;
// Detect reply-to-bot: compare JIDs, LIDs, and E.164 numbers.
// WhatsApp may report the quoted message sender as either a phone JID
// (xxxxx@s.whatsapp.net) or a LID (xxxxx@lid), so we compare both.
// But in shared-number/selfChatMode setups, replies from the same self number
// should not count as implicit bot mentions unless the message explicitly
// mentioned the bot in text.
const implicitReplyToSelf = sharedNumberSelfChat && identitiesOverlap(self, sender);
const implicitMentionKinds = implicitMentionKindWhen(
"quoted_bot",
!implicitReplyToSelf && identitiesOverlap(self, replyContext?.sender),
);
const mentionDecision = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned,
implicitMentionKinds,
},
policy: {
isGroup: true,
requireMention,
allowTextCommands: false,
hasControlCommand: false,
commandAuthorized: false,
},
});
const effectiveWasMentioned = mentionDecision.effectiveWasMentioned || shouldBypassMention;
// Carry the session activation and mention result together. Dispatch needs
// both facts to distinguish an always-on group from a blocked unmentioned turn.
params.msg.groupMention = { wasMentioned: effectiveWasMentioned, requireMention };
if (!shouldBypassMention && requireMention && mentionDecision.shouldSkip) {
if (params.deferMissingMention === true) {
params.logVerbose(
`Deferring group mention skip until audio preflight completes in ${conversationId}`,
);
return { shouldProcess: false, needsMentionText: true } as const;
}
return skipGroupMessageAndStoreHistory(
params,
`Group message stored for context (no mention detected) in ${conversationId}: ${mentionMsg.payload.body}`,
params.mentionText,
);
}
return { shouldProcess: true };
}

View File

@@ -0,0 +1,57 @@
// Whatsapp tests cover group members plugin behavior.
import { describe, expect, it } from "vitest";
import { formatGroupMembers, noteGroupMember } from "./group-members.js";
describe("noteGroupMember", () => {
it("normalizes member phone numbers before storing", () => {
const groupMemberNames = new Map<string, Map<string, string>>();
noteGroupMember(groupMemberNames, "g1", "+1 (555) 123-4567", "Alice");
expect(groupMemberNames.get("g1")?.get("+15551234567")).toBe("Alice");
});
it("ignores incomplete member values", () => {
const groupMemberNames = new Map<string, Map<string, string>>();
noteGroupMember(groupMemberNames, "g1", undefined, "Alice");
noteGroupMember(groupMemberNames, "g1", "+15551234567", undefined);
expect(groupMemberNames.get("g1")).toBeUndefined();
});
});
describe("formatGroupMembers", () => {
it("deduplicates participants and appends named roster members", () => {
const roster = new Map<string, string>([
["+16660000000", "Bob"],
["+17770000000", "Carol"],
]);
const formatted = formatGroupMembers({
participants: ["+1 (555) 000-0000", "+15550000000", "+16660000000"],
roster,
});
expect(formatted).toBe("+15550000000, Bob (+16660000000), Carol (+17770000000)");
});
it("falls back to sender when no participants or roster are available", () => {
const formatted = formatGroupMembers({
participants: [],
roster: undefined,
fallbackE164: "+1 (555) 222-3333",
});
expect(formatted).toBe("+15552223333");
});
it("returns undefined when no members can be resolved", () => {
expect(
formatGroupMembers({
participants: [],
roster: undefined,
}),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,66 @@
// Whatsapp plugin module implements group members behavior.
import { normalizeE164 } from "../../text-runtime.js";
function appendNormalizedUnique(entries: Iterable<string>, seen: Set<string>, ordered: string[]) {
for (const entry of entries) {
const normalized = normalizeE164(entry) ?? entry;
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
ordered.push(normalized);
}
}
export function noteGroupMember(
groupMemberNames: Map<string, Map<string, string>>,
conversationId: string,
e164?: string,
name?: string,
) {
if (!e164 || !name) {
return;
}
const normalized = normalizeE164(e164);
const key = normalized ?? e164;
if (!key) {
return;
}
let roster = groupMemberNames.get(conversationId);
if (!roster) {
roster = new Map();
groupMemberNames.set(conversationId, roster);
}
roster.set(key, name);
}
export function formatGroupMembers(params: {
participants: string[] | undefined;
roster: Map<string, string> | undefined;
fallbackE164?: string;
}) {
const { participants, roster, fallbackE164 } = params;
const seen = new Set<string>();
const ordered: string[] = [];
if (participants?.length) {
appendNormalizedUnique(participants, seen, ordered);
}
if (roster) {
appendNormalizedUnique(roster.keys(), seen, ordered);
}
if (ordered.length === 0 && fallbackE164) {
const normalized = normalizeE164(fallbackE164) ?? fallbackE164;
if (normalized) {
ordered.push(normalized);
}
}
if (ordered.length === 0) {
return undefined;
}
return ordered
.map((entry) => {
const name = roster?.get(entry);
return name ? `${name} (${entry})` : entry;
})
.join(", ");
}

View File

@@ -0,0 +1,105 @@
// Whatsapp tests cover inbound context plugin behavior.
import { describe, expect, it } from "vitest";
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
import {
resolveVisibleWhatsAppGroupHistory,
resolveVisibleWhatsAppReplyContext,
} from "./inbound-context.js";
type ReplyContextParams = Parameters<typeof resolveVisibleWhatsAppReplyContext>[0];
const makeBlockedQuotedReplyMessage = (id: string): ReplyContextParams["msg"] =>
createTestWebInboundMessage({
event: { id },
payload: { body: "Current message" },
platform: {
chatJid: "123@g.us",
recipientJid: "+2000",
senderName: "Alice",
senderJid: "111@s.whatsapp.net",
senderE164: "+111",
selfE164: "+999",
},
admission: {
accountId: "default",
conversation: {
kind: "group",
id: "123@g.us",
},
sender: {
id: "111@s.whatsapp.net",
},
senderAccess: {
reasonCode: "group_policy_allowed",
},
},
quote: {
id: "blocked-reply",
body: "Blocked quoted text",
sender: {
displayName: "Mallory (+999)",
jid: "999@s.whatsapp.net",
},
},
});
describe("whatsapp inbound context visibility", () => {
it("filters non-allowlisted group history from supplemental context", () => {
const history = resolveVisibleWhatsAppGroupHistory({
history: [
{
sender: "Alice (+111)",
body: "Allowed context",
senderJid: "111@s.whatsapp.net",
},
{
sender: "Mallory (+999)",
body: "Blocked context",
senderJid: "999@s.whatsapp.net",
},
],
mode: "allowlist",
groupPolicy: "allowlist",
groupAllowFrom: ["+111"],
});
expect(history).toEqual([
{
sender: "Alice (+111)",
body: "Allowed context",
senderJid: "111@s.whatsapp.net",
},
]);
});
it("redacts blocked quoted replies in allowlist mode", () => {
const reply = resolveVisibleWhatsAppReplyContext({
msg: makeBlockedQuotedReplyMessage("msg-reply-1"),
mode: "allowlist",
groupPolicy: "allowlist",
groupAllowFrom: ["+111"],
});
expect(reply).toBeNull();
});
it("keeps blocked quoted replies in allowlist_quote mode", () => {
const reply = resolveVisibleWhatsAppReplyContext({
msg: makeBlockedQuotedReplyMessage("msg-reply-2"),
mode: "allowlist_quote",
groupPolicy: "allowlist",
groupAllowFrom: ["+111"],
});
expect(reply).toEqual({
id: "blocked-reply",
body: "Blocked quoted text",
sender: {
jid: "999@s.whatsapp.net",
lid: null,
e164: "+999",
label: "Mallory (+999)",
},
});
});
});

View File

@@ -0,0 +1,102 @@
// Whatsapp plugin module implements inbound context behavior.
import { filterChannelInboundQuoteContext } from "openclaw/plugin-sdk/channel-inbound";
import { filterSupplementalContextItems } from "openclaw/plugin-sdk/security-runtime";
import {
getComparableIdentityValues,
getReplyContext,
resolveComparableIdentity,
type WhatsAppIdentity,
type WhatsAppReplyContext,
} from "../../identity.js";
import { requireWhatsAppInboundAdmission } from "../../inbound/admission.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import { normalizeE164 } from "../../text-runtime.js";
export type GroupHistoryEntry = {
sender: string;
body: string;
timestamp?: number;
id?: string;
senderJid?: string;
};
type ContextVisibilityMode = "all" | "allowlist" | "allowlist_quote";
function isWhatsAppSupplementalSenderAllowed(params: {
allowFrom: string[];
authDir?: string;
sender?: WhatsAppIdentity | null;
}): boolean {
if (params.allowFrom.includes("*")) {
return true;
}
const senderValues = new Set(
getComparableIdentityValues(resolveComparableIdentity(params.sender, params.authDir)),
);
if (senderValues.size === 0) {
return false;
}
for (const entry of params.allowFrom) {
const rawEntry = entry.trim();
if (!rawEntry) {
continue;
}
const normalizedEntry = normalizeE164(rawEntry);
if ((normalizedEntry && senderValues.has(normalizedEntry)) || senderValues.has(rawEntry)) {
return true;
}
}
return false;
}
export function resolveVisibleWhatsAppGroupHistory(params: {
authDir?: string;
history: GroupHistoryEntry[];
mode: ContextVisibilityMode;
groupPolicy: "open" | "allowlist" | "disabled";
groupAllowFrom: string[];
}): GroupHistoryEntry[] {
if (params.groupPolicy !== "allowlist") {
return params.history;
}
return filterSupplementalContextItems({
items: params.history,
mode: params.mode,
kind: "history",
isSenderAllowed: (entry) =>
isWhatsAppSupplementalSenderAllowed({
allowFrom: params.groupAllowFrom,
authDir: params.authDir,
sender: entry.senderJid ? { jid: entry.senderJid } : null,
}),
}).items;
}
export function resolveVisibleWhatsAppReplyContext(params: {
msg: AdmittedWebInboundMessage;
authDir?: string;
mode: ContextVisibilityMode;
groupPolicy: "open" | "allowlist" | "disabled";
groupAllowFrom: string[];
}): WhatsAppReplyContext | null {
const replyTo = getReplyContext(params.msg, params.authDir);
if (!replyTo) {
return null;
}
const admission = requireWhatsAppInboundAdmission(params.msg);
const senderAllowed =
admission.conversation.kind !== "group" || params.groupPolicy !== "allowlist"
? true
: isWhatsAppSupplementalSenderAllowed({
allowFrom: params.groupAllowFrom,
authDir: params.authDir,
sender: replyTo.sender,
});
const visible = filterChannelInboundQuoteContext(params.mode, {
id: replyTo.id,
body: replyTo.body,
sender: replyTo.sender?.label ?? undefined,
senderAllowed,
});
return visible ? replyTo : null;
}

View File

@@ -0,0 +1,23 @@
// Whatsapp plugin module implements inbound dispatch behavior.
export {
createChannelMessageReplyPipeline,
dispatchReplyWithBufferedBlockDispatcher,
finalizeInboundContext,
getAgentScopedMediaLocalRoots,
jidToE164,
logVerbose,
resolveChannelMessageSourceReplyDeliveryMode,
resolveChunkMode,
resolveIdentityNamePrefix,
resolveInboundLastRouteSessionKey,
resolveMarkdownTableMode,
resolveSendableOutboundReplyParts,
resolveTextChunkLimit,
shouldLogVerbose,
toLocationContext,
type getChildLogger,
type getReplyFromConfig,
type LoadConfigFn,
type ReplyPayload,
type resolveAgentRoute,
} from "./runtime-api.js";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,906 @@
// Whatsapp plugin module implements inbound dispatch behavior.
import {
DEFAULT_TIMING,
type StatusReactionController,
} from "openclaw/plugin-sdk/channel-feedback";
import {
buildChannelInboundEventContext,
type CommandTurnContext,
toInboundMediaFacts,
} from "openclaw/plugin-sdk/channel-inbound";
import { hasVisibleInboundReplyDispatch } from "openclaw/plugin-sdk/channel-inbound";
import { deliverInboundReplyWithMessageSendContext } from "openclaw/plugin-sdk/channel-outbound";
import { buildInboundHistoryFromEntries } from "openclaw/plugin-sdk/reply-history";
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import { requireWhatsAppInboundAdmission } from "../../inbound/admission.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import {
type DeliverableWhatsAppOutboundPayload,
normalizeWhatsAppOutboundPayload,
normalizeWhatsAppPayloadTextPreservingIndentation,
} from "../../outbound-media-contract.js";
import type { WhatsAppReplyDeliveryResult } from "../deliver-reply.js";
import { markWhatsAppVisibleDeliveryError } from "../util.js";
import { formatGroupMembers } from "./group-members.js";
import type { GroupHistoryEntry } from "./inbound-context.js";
import {
createChannelMessageReplyPipeline,
dispatchReplyWithBufferedBlockDispatcher,
finalizeInboundContext,
getAgentScopedMediaLocalRoots,
jidToE164,
logVerbose,
resolveChannelMessageSourceReplyDeliveryMode,
resolveChunkMode,
resolveIdentityNamePrefix,
resolveInboundLastRouteSessionKey,
resolveMarkdownTableMode,
resolveSendableOutboundReplyParts,
resolveTextChunkLimit,
shouldLogVerbose,
toLocationContext,
type getChildLogger,
type getReplyFromConfig,
type LoadConfigFn,
type ReplyPayload,
type resolveAgentRoute,
} from "./inbound-dispatch.runtime.js";
type ReplyLifecycleKind = "tool" | "block" | "final";
type ChannelReplyOnModelSelected = NonNullable<
ReturnType<typeof createChannelMessageReplyPipeline>["onModelSelected"]
>;
type WhatsAppDispatchPipeline = {
responsePrefix?: string;
} & Record<string, unknown>;
type VisibleReplyTarget = {
id?: string;
body?: string;
sender?: {
label?: string | null;
} | null;
};
type ReplyThreadingContext = {
implicitCurrentMessage?: "default" | "allow" | "deny";
};
type SenderContext = {
id?: string;
name?: string;
e164?: string;
};
type ReplyDeliveryInfo = { kind: ReplyLifecycleKind };
type PendingWhatsAppMediaOnlyPayload = {
info: ReplyDeliveryInfo;
mediaUrls: Set<string>;
payload: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
};
type WhatsAppMediaOnlyFlushResult = {
delivered: number;
droppedDuplicateMedia: number;
};
function normalizeErrForLog(err: unknown): unknown {
if (err instanceof Error) {
const ownEnumerableProps = Object.fromEntries(Object.entries(err));
return { ...ownEnumerableProps, type: err.name, message: err.message, stack: err.stack };
}
return err;
}
type WhatsAppReplyDeliveryVisibility = {
visibleReplySent: boolean;
};
function whatsAppReplyDeliveryVisibility(
visibleReplySent: boolean,
): WhatsAppReplyDeliveryVisibility {
return { visibleReplySent };
}
function whatsAppReplyDeliveryVisibilityFromDurableResult(result: {
visibleReplySent?: boolean;
}): WhatsAppReplyDeliveryVisibility {
return whatsAppReplyDeliveryVisibility(result.visibleReplySent === true);
}
function readTrimmedString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function markWhatsAppReplyDeliveryErrorVisibleAfterFlush(
error: unknown,
flushResult: WhatsAppMediaOnlyFlushResult,
): unknown {
return flushResult.delivered > 0 ? markWhatsAppVisibleDeliveryError(error) : error;
}
function logWhatsAppReplyDeliveryError(params: {
err: unknown;
info: ReplyDeliveryInfo;
connectionId: string;
msg: AdmittedWebInboundMessage;
replyLogger: ReturnType<typeof getChildLogger>;
}) {
const admission = requireWhatsAppInboundAdmission(params.msg);
params.replyLogger.error(
{
err: normalizeErrForLog(params.err),
replyKind: params.info.kind,
correlationId: params.msg.event.id ?? null,
connectionId: params.connectionId,
conversationId: admission.conversation.id,
chatId: params.msg.platform.chatJid ?? null,
to: admission.conversation.id,
from: params.msg.platform.recipientJid ?? null,
},
"auto-reply delivery failed",
);
}
function resolveWhatsAppDurableReplyToId(params: {
context: Record<string, unknown>;
info: ReplyDeliveryInfo;
msg: AdmittedWebInboundMessage;
payload: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
}): string | null {
if (params.payload.replyToId === null) {
return null;
}
const explicitPayloadReplyToId = readTrimmedString(params.payload.replyToId);
if (explicitPayloadReplyToId) {
return explicitPayloadReplyToId;
}
const hasVisibleInboundReplyTarget =
Boolean(readTrimmedString(params.context.ReplyToId)) ||
Boolean(readTrimmedString(params.context.ReplyToIdFull));
const currentInboundMessageId = readTrimmedString(params.msg.event.id);
if (params.info.kind === "final" && hasVisibleInboundReplyTarget && currentInboundMessageId) {
return currentInboundMessageId;
}
return null;
}
function resolveWhatsAppDisableBlockStreaming(cfg: ReturnType<LoadConfigFn>): boolean | undefined {
if (typeof cfg.channels?.whatsapp?.blockStreaming !== "boolean") {
return undefined;
}
return !cfg.channels.whatsapp.blockStreaming;
}
function resolveWhatsAppDeliverablePayload(
payload: ReplyPayload,
info: { kind: ReplyLifecycleKind },
): ReplyPayload | null {
if (payload.isReasoning === true || payload.isCompactionNotice === true) {
return null;
}
if (payload.isError === true) {
return null;
}
if (info.kind === "tool") {
if (!resolveSendableOutboundReplyParts(payload).hasMedia) {
return null;
}
return { ...payload, text: undefined };
}
return payload;
}
function getWhatsAppPayloadMediaUrls(payload: ReplyPayload): Set<string> {
return new Set(
normalizeStringEntries([
...(Array.isArray(payload.mediaUrls) ? payload.mediaUrls : []),
...(typeof payload.mediaUrl === "string" ? [payload.mediaUrl] : []),
]),
);
}
function hasWhatsAppMediaUrlOverlap(left: Set<string>, right: Set<string>): boolean {
for (const url of left) {
if (right.has(url)) {
return true;
}
}
return false;
}
function shouldDeferWhatsAppMediaOnlyPayload(params: {
info: ReplyDeliveryInfo;
mediaUrls: Set<string>;
reply: ReturnType<typeof resolveSendableOutboundReplyParts>;
}): boolean {
return (
params.info.kind !== "final" &&
params.reply.hasMedia &&
!params.reply.text.trim() &&
params.mediaUrls.size > 0
);
}
function createWhatsAppMediaOnlyReplyCoalescer(params: {
deliver: (pending: PendingWhatsAppMediaOnlyPayload) => Promise<WhatsAppReplyDeliveryVisibility>;
}) {
const pendingMediaOnlyPayloads: PendingWhatsAppMediaOnlyPayload[] = [];
const flushExceptDuplicateMedia = async (
mediaUrls?: Set<string>,
): Promise<WhatsAppMediaOnlyFlushResult> => {
const flushResult: WhatsAppMediaOnlyFlushResult = {
delivered: 0,
droppedDuplicateMedia: 0,
};
const pending = pendingMediaOnlyPayloads.splice(0);
for (const candidate of pending) {
if (mediaUrls && hasWhatsAppMediaUrlOverlap(candidate.mediaUrls, mediaUrls)) {
flushResult.droppedDuplicateMedia += 1;
continue;
}
try {
const delivery = await params.deliver(candidate);
if (delivery.visibleReplySent) {
flushResult.delivered += 1;
}
} catch (error: unknown) {
throw markWhatsAppReplyDeliveryErrorVisibleAfterFlush(error, flushResult);
}
}
return flushResult;
};
return {
defer(pending: PendingWhatsAppMediaOnlyPayload) {
pendingMediaOnlyPayloads.push(pending);
},
flushExceptDuplicateMedia,
flushAll: () => flushExceptDuplicateMedia(),
};
}
function logWhatsAppMediaOnlyFlushResult(result: WhatsAppMediaOnlyFlushResult) {
if (!shouldLogVerbose()) {
return;
}
if (result.droppedDuplicateMedia > 0) {
logVerbose(
`Dropped ${result.droppedDuplicateMedia} deferred media-only WhatsApp reply payload(s) superseded by captioned media`,
);
}
if (result.delivered > 0) {
logVerbose(`Flushed ${result.delivered} deferred media-only WhatsApp reply payload(s)`);
}
}
export function resolveWhatsAppResponsePrefix(params: {
cfg: ReturnType<LoadConfigFn>;
agentId: string;
isSelfChat: boolean;
pipelineResponsePrefix?: string;
}): string | undefined {
const configuredResponsePrefix = params.cfg.messages?.responsePrefix;
return (
params.pipelineResponsePrefix ??
(configuredResponsePrefix === undefined && params.isSelfChat
? resolveIdentityNamePrefix(params.cfg, params.agentId)
: undefined)
);
}
export async function buildWhatsAppInboundContext(params: {
bodyForAgent?: string;
combinedBody: string;
commandBody?: string;
commandAuthorized?: boolean;
commandTurn?: CommandTurnContext;
commandSource?: "text";
groupHistory?: GroupHistoryEntry[];
groupMemberRoster?: Map<string, string>;
groupSystemPrompt?: string;
msg: AdmittedWebInboundMessage;
rawBody?: string;
route: ReturnType<typeof resolveAgentRoute>;
sender: SenderContext;
transcript?: string;
mediaTranscribedIndexes?: number[];
replyThreading?: ReplyThreadingContext;
visibleReplyTo?: VisibleReplyTarget;
suppressMessageReceivedHooks?: boolean;
}): Promise<FinalizedMsgContext> {
const admission = requireWhatsAppInboundAdmission(params.msg);
const conversationId = admission.conversation.id;
const conversationKind = admission.conversation.kind;
const wasMentioned = params.msg.groupMention?.wasMentioned ?? params.msg.wasMentioned;
const inboundHistory =
conversationKind === "group"
? buildInboundHistoryFromEntries({
entries: (params.groupHistory ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
messageId: entry.id,
})),
limit: params.groupHistory?.length ?? 1,
})
: undefined;
const media = toInboundMediaFacts(
params.msg.payload.media?.path || params.msg.payload.media?.url
? [
{
path: params.msg.payload.media?.path,
url: params.msg.payload.media?.url ?? params.msg.payload.media?.path,
contentType: params.msg.payload.media?.type,
},
]
: undefined,
{ transcribed: (_entry, index) => params.mediaTranscribedIndexes?.includes(index) === true },
);
return buildChannelInboundEventContext({
channel: "whatsapp",
finalize: finalizeInboundContext,
supplemental: {
quote: params.visibleReplyTo
? {
id: params.visibleReplyTo.id,
body: params.visibleReplyTo.body,
sender: params.visibleReplyTo.sender?.label ?? undefined,
}
: undefined,
groupSystemPrompt: params.groupSystemPrompt,
untrustedContext: params.msg.payload.untrustedStructuredContext,
},
media,
messageId: params.msg.event.id,
timestamp: params.msg.event.timestamp,
from: conversationId,
sender: {
id: params.sender.id ?? params.sender.e164,
name: params.sender.name,
},
conversation: {
kind: conversationKind,
id: conversationId,
label: conversationId,
},
route: {
agentId: params.route.agentId,
accountId: params.route.accountId,
routeSessionKey: params.route.sessionKey,
},
reply: {
to: params.msg.platform.recipientJid,
originatingTo: conversationId,
},
message: {
body: params.combinedBody,
bodyForAgent: params.bodyForAgent ?? params.msg.payload.body,
inboundHistory,
rawBody: params.rawBody ?? params.msg.payload.body,
commandBody: params.commandBody ?? params.msg.payload.body,
},
access: {
...(wasMentioned !== undefined
? {
mentions: {
canDetectMention: conversationKind === "group",
wasMentioned,
requireMention: params.msg.groupMention?.requireMention,
},
}
: {}),
commands: {
authorized: params.commandAuthorized,
},
},
commandTurn: params.commandTurn,
extra: {
Transcript: params.transcript,
GroupSubject: params.msg.group?.subject,
GroupMembers: formatGroupMembers({
participants: params.msg.group?.participants,
roster: params.groupMemberRoster,
fallbackE164: params.sender.e164,
}),
SenderE164: params.sender.e164,
CommandSource:
params.commandSource ??
(params.commandTurn?.source === "native" || params.commandTurn?.source === "text"
? params.commandTurn.source
: undefined),
ReplyThreading: params.replyThreading,
SuppressMessageReceivedHooks: params.suppressMessageReceivedHooks,
...(params.msg.payload.location ? toLocationContext(params.msg.payload.location) : {}),
},
});
}
function normalizeCommandTurnFromContext(value: unknown): CommandTurnContext | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as Partial<CommandTurnContext>;
const kind = record.kind;
const source = record.source;
if (kind === "native" && source === "native" && typeof record.authorized === "boolean") {
return {
kind: "native",
source: "native",
authorized: record.authorized,
commandName: typeof record.commandName === "string" ? record.commandName : undefined,
body: typeof record.body === "string" ? record.body : undefined,
};
}
if (kind === "text-slash" && source === "text" && typeof record.authorized === "boolean") {
return {
kind: "text-slash",
source: "text",
authorized: record.authorized,
commandName: typeof record.commandName === "string" ? record.commandName : undefined,
body: typeof record.body === "string" ? record.body : undefined,
};
}
if (kind === "normal" && source === "message") {
return {
kind: "normal",
source: "message",
authorized: false,
commandName: typeof record.commandName === "string" ? record.commandName : undefined,
body: typeof record.body === "string" ? record.body : undefined,
};
}
return undefined;
}
export function resolveWhatsAppDmRouteTarget(params: {
msg: AdmittedWebInboundMessage;
senderE164?: string;
normalizeE164: (value: string) => string | null;
}): string | undefined {
const admission = requireWhatsAppInboundAdmission(params.msg);
const conversationId = admission.conversation.id;
if (admission.conversation.kind === "group") {
return undefined;
}
if (params.senderE164) {
return params.normalizeE164(params.senderE164) ?? undefined;
}
if (conversationId.includes("@")) {
return jidToE164(conversationId) ?? undefined;
}
return params.normalizeE164(conversationId) ?? undefined;
}
export function updateWhatsAppMainLastRoute(params: {
backgroundTasks: Set<Promise<unknown>>;
cfg: ReturnType<LoadConfigFn>;
ctx: Record<string, unknown>;
dmRouteTarget?: string;
pinnedMainDmRecipient: string | null;
route: ReturnType<typeof resolveAgentRoute>;
updateLastRoute: (params: {
cfg: ReturnType<LoadConfigFn>;
backgroundTasks: Set<Promise<unknown>>;
storeAgentId: string;
sessionKey: string;
channel: "whatsapp";
to: string;
accountId?: string;
ctx: Record<string, unknown>;
warn: ReturnType<typeof getChildLogger>["warn"];
}) => void;
warn: ReturnType<typeof getChildLogger>["warn"];
}) {
const shouldUpdateMainLastRoute =
!params.pinnedMainDmRecipient || params.pinnedMainDmRecipient === params.dmRouteTarget;
const inboundLastRouteSessionKey = resolveInboundLastRouteSessionKey({
route: params.route,
sessionKey: params.route.sessionKey,
});
if (
params.dmRouteTarget &&
inboundLastRouteSessionKey === params.route.mainSessionKey &&
shouldUpdateMainLastRoute
) {
params.updateLastRoute({
cfg: params.cfg,
backgroundTasks: params.backgroundTasks,
storeAgentId: params.route.agentId,
sessionKey: params.route.mainSessionKey,
channel: "whatsapp",
to: params.dmRouteTarget,
accountId: params.route.accountId,
ctx: params.ctx,
warn: params.warn,
});
return;
}
if (
params.dmRouteTarget &&
inboundLastRouteSessionKey === params.route.mainSessionKey &&
params.pinnedMainDmRecipient
) {
logVerbose(
`Skipping main-session last route update for ${params.dmRouteTarget} (pinned owner ${params.pinnedMainDmRecipient})`,
);
}
}
export async function dispatchWhatsAppBufferedReply(params: {
cfg: ReturnType<LoadConfigFn>;
connectionId: string;
context: Record<string, unknown>;
deliverReply: (params: {
replyResult: ReplyPayload;
normalizedReplyResult?: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
msg: AdmittedWebInboundMessage;
mediaLocalRoots: readonly string[];
maxMediaBytes: number;
textLimit: number;
chunkMode?: ReturnType<typeof resolveChunkMode>;
replyLogger: ReturnType<typeof getChildLogger>;
connectionId?: string;
skipLog?: boolean;
tableMode?: ReturnType<typeof resolveMarkdownTableMode>;
}) => Promise<WhatsAppReplyDeliveryResult>;
groupHistories: Map<string, GroupHistoryEntry[]>;
groupHistoryKey: string;
maxMediaBytes: number;
maxMediaTextChunkLimit?: number;
msg: AdmittedWebInboundMessage;
onModelSelected?: ChannelReplyOnModelSelected;
rememberSentText: (
text: string | undefined,
opts: {
combinedBody?: string;
combinedBodySessionKey?: string;
logVerboseMessage?: boolean;
},
) => void;
replyLogger: ReturnType<typeof getChildLogger>;
replyPipeline: WhatsAppDispatchPipeline;
replyResolver: typeof getReplyFromConfig;
route: ReturnType<typeof resolveAgentRoute>;
shouldClearGroupHistory: boolean;
statusReactionController?: StatusReactionController | null;
}) {
const admission = requireWhatsAppInboundAdmission(params.msg);
const conversationId = admission.conversation.id;
const conversationKind = admission.conversation.kind;
const statusReactionController = params.statusReactionController ?? null;
const statusReactionTiming = {
...DEFAULT_TIMING,
...params.cfg.messages?.statusReactions?.timing,
};
const removeAckAfterReply = params.cfg.messages?.removeAckAfterReply ?? false;
const textLimit = params.maxMediaTextChunkLimit ?? resolveTextChunkLimit(params.cfg, "whatsapp");
const chunkMode = resolveChunkMode(params.cfg, "whatsapp", params.route.accountId);
const tableMode = resolveMarkdownTableMode({
cfg: params.cfg,
channel: "whatsapp",
accountId: params.route.accountId,
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.route.agentId);
const sourceReplyChatType =
typeof params.context.ChatType === "string" ? params.context.ChatType : conversationKind;
const sourceReplyCommandSource =
params.context.CommandSource === "native" || params.context.CommandSource === "text"
? params.context.CommandSource
: undefined;
const sourceReplyCommandTurn = normalizeCommandTurnFromContext(params.context.CommandTurn);
const sourceReplyCommandAuthorized =
typeof params.context.CommandAuthorized === "boolean"
? params.context.CommandAuthorized
: undefined;
const sourceReplyDeliveryMode =
sourceReplyChatType === "group" || sourceReplyChatType === "channel"
? resolveChannelMessageSourceReplyDeliveryMode({
cfg: params.cfg,
ctx: {
ChatType: sourceReplyChatType,
CommandTurn: sourceReplyCommandTurn,
CommandSource: sourceReplyCommandSource,
CommandAuthorized: sourceReplyCommandAuthorized,
},
})
: undefined;
const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only";
const disableBlockStreaming = sourceRepliesAreToolOnly
? true
: resolveWhatsAppDisableBlockStreaming(params.cfg);
let didSendReply = false;
let didLogHeartbeatStrip = false;
const deliverNormalizedPayload = async (
normalizedDeliveryPayload: DeliverableWhatsAppOutboundPayload<ReplyPayload>,
info: ReplyDeliveryInfo,
): Promise<WhatsAppReplyDeliveryVisibility> => {
const reply = resolveSendableOutboundReplyParts(normalizedDeliveryPayload);
if (!reply.hasMedia && !reply.text.trim()) {
return whatsAppReplyDeliveryVisibility(false);
}
const delivery = await params.deliverReply({
replyResult: normalizedDeliveryPayload,
normalizedReplyResult: normalizedDeliveryPayload,
msg: params.msg,
mediaLocalRoots,
maxMediaBytes: params.maxMediaBytes,
textLimit,
chunkMode,
replyLogger: params.replyLogger,
connectionId: params.connectionId,
skipLog: false,
tableMode,
});
if (!delivery.providerAccepted) {
params.replyLogger.warn(
{
correlationId: params.msg.event.id ?? null,
connectionId: params.connectionId,
conversationId,
chatId: params.msg.platform.chatJid,
to: conversationId,
from: params.msg.platform.recipientJid,
replyKind: info.kind,
},
"auto-reply was not accepted by WhatsApp provider",
);
return whatsAppReplyDeliveryVisibility(false);
}
didSendReply = true;
const shouldLog = normalizedDeliveryPayload.text ? true : undefined;
params.rememberSentText(normalizedDeliveryPayload.text, {
combinedBody: params.context.Body as string | undefined,
combinedBodySessionKey: params.route.sessionKey,
logVerboseMessage: shouldLog,
});
const fromDisplay = conversationId;
if (shouldLogVerbose()) {
const preview = normalizedDeliveryPayload.text != null ? reply.text : "<media>";
logVerbose(`Reply body: ${preview}${reply.hasMedia ? " (media)" : ""} -> ${fromDisplay}`);
}
return whatsAppReplyDeliveryVisibility(true);
};
const mediaOnlyCoalescer = createWhatsAppMediaOnlyReplyCoalescer({
deliver: async (pending) => {
return await deliverNormalizedPayload(pending.payload, pending.info);
},
});
if (statusReactionController) {
void statusReactionController.setThinking();
}
const dispatchResult = await dispatchReplyWithBufferedBlockDispatcher({
ctx: params.context,
cfg: params.cfg,
replyResolver: params.replyResolver,
dispatcherOptions: {
...params.replyPipeline,
onHeartbeatStrip: () => {
if (!didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from web reply");
}
},
deliver: async (payload: ReplyPayload, info: { kind: ReplyLifecycleKind }) => {
const deliveryPayload = resolveWhatsAppDeliverablePayload(payload, info);
if (!deliveryPayload) {
return whatsAppReplyDeliveryVisibility(false);
}
const normalizedOutboundPayload = normalizeWhatsAppOutboundPayload(deliveryPayload, {
normalizeText: normalizeWhatsAppPayloadTextPreservingIndentation,
});
const normalizedDeliveryPayload =
deliveryPayload.text === undefined
? { ...normalizedOutboundPayload, text: undefined }
: normalizedOutboundPayload;
const reply = resolveSendableOutboundReplyParts(normalizedDeliveryPayload);
if (!reply.hasMedia && !reply.text.trim()) {
return whatsAppReplyDeliveryVisibility(false);
}
if (!reply.hasMedia) {
const flushResult = await mediaOnlyCoalescer.flushAll();
logWhatsAppMediaOnlyFlushResult(flushResult);
try {
const durable = await deliverInboundReplyWithMessageSendContext({
cfg: params.cfg,
channel: "whatsapp",
accountId: params.route.accountId,
agentId: params.route.agentId,
ctxPayload: params.context as FinalizedMsgContext,
payload: normalizedDeliveryPayload,
info,
to: conversationId,
replyToId: resolveWhatsAppDurableReplyToId({
context: params.context,
info,
msg: params.msg,
payload: normalizedDeliveryPayload,
}),
formatting: {
textLimit,
tableMode,
chunkMode,
},
});
if (durable.status === "failed") {
if (durable.sentBeforeError === true) {
throw markWhatsAppVisibleDeliveryError(durable.error);
}
throw durable.error;
}
if (durable.status === "handled_visible") {
didSendReply = true;
const shouldLog = normalizedDeliveryPayload.text ? true : undefined;
params.rememberSentText(normalizedDeliveryPayload.text, {
combinedBody: params.context.Body as string | undefined,
combinedBodySessionKey: params.route.sessionKey,
logVerboseMessage: shouldLog,
});
return whatsAppReplyDeliveryVisibilityFromDurableResult(durable.delivery);
}
if (durable.status === "handled_no_send") {
return flushResult.delivered > 0
? whatsAppReplyDeliveryVisibility(true)
: whatsAppReplyDeliveryVisibilityFromDurableResult(durable.delivery);
}
const delivery = await deliverNormalizedPayload(normalizedDeliveryPayload, info);
return flushResult.delivered > 0 && !delivery.visibleReplySent
? whatsAppReplyDeliveryVisibility(true)
: delivery;
} catch (error: unknown) {
throw markWhatsAppReplyDeliveryErrorVisibleAfterFlush(error, flushResult);
}
}
const mediaUrls = getWhatsAppPayloadMediaUrls(normalizedDeliveryPayload);
if (shouldDeferWhatsAppMediaOnlyPayload({ info, mediaUrls, reply })) {
mediaOnlyCoalescer.defer({
info,
mediaUrls,
payload: normalizedDeliveryPayload,
});
return whatsAppReplyDeliveryVisibility(false);
}
const flushResult = await mediaOnlyCoalescer.flushExceptDuplicateMedia(mediaUrls);
logWhatsAppMediaOnlyFlushResult(flushResult);
try {
const delivery = await deliverNormalizedPayload(normalizedDeliveryPayload, info);
return flushResult.delivered > 0 && !delivery.visibleReplySent
? whatsAppReplyDeliveryVisibility(true)
: delivery;
} catch (error: unknown) {
throw markWhatsAppReplyDeliveryErrorVisibleAfterFlush(error, flushResult);
}
},
onSettled: async () => {
const flushResult = await mediaOnlyCoalescer.flushAll();
logWhatsAppMediaOnlyFlushResult(flushResult);
return whatsAppReplyDeliveryVisibility(flushResult.delivered > 0);
},
onReplyStart: params.msg.platform.sendComposing,
...(statusReactionController
? {
onCompactionStart: async () => {
await statusReactionController.setCompacting();
},
onCompactionEnd: async () => {
statusReactionController.cancelPending();
await statusReactionController.setThinking();
},
}
: {}),
onError: (err, info) => {
logWhatsAppReplyDeliveryError({
err,
info,
connectionId: params.connectionId,
msg: params.msg,
replyLogger: params.replyLogger,
});
},
},
replyOptions: {
// Message-tool-only unmentioned group turns have no automatic visible reply.
// Suppress composing there so silent background runs do not leak presence.
suppressTyping:
sourceRepliesAreToolOnly &&
conversationKind === "group" &&
!(params.msg.groupMention?.wasMentioned ?? params.msg.wasMentioned),
disableBlockStreaming,
...(sourceReplyDeliveryMode ? { sourceReplyDeliveryMode } : {}),
onModelSelected: params.onModelSelected,
...(statusReactionController
? {
onToolStart: async (payload: { name?: string }) => {
const toolName = payload.name?.trim();
if (toolName) {
await statusReactionController.setTool(toolName);
}
},
}
: {}),
},
});
const didQueueVisibleReply = hasVisibleInboundReplyDispatch(dispatchResult);
const didDeliverVisibleReply = didSendReply || dispatchResult.observedReplyDelivery === true;
if (!didQueueVisibleReply) {
if (statusReactionController) {
void finalizeWhatsAppStatusReaction({
controller: statusReactionController,
outcome: "error",
hasFinalResponse: false,
removeAckAfterReply,
timing: statusReactionTiming,
});
}
if (params.shouldClearGroupHistory) {
params.groupHistories.set(params.groupHistoryKey, []);
}
logVerbose("Skipping auto-reply: silent token or no text/media returned from resolver");
return false;
}
if (statusReactionController) {
void finalizeWhatsAppStatusReaction({
controller: statusReactionController,
outcome: didDeliverVisibleReply ? "done" : "error",
hasFinalResponse: didDeliverVisibleReply,
removeAckAfterReply,
timing: statusReactionTiming,
});
}
if (params.shouldClearGroupHistory) {
params.groupHistories.set(params.groupHistoryKey, []);
}
return didDeliverVisibleReply;
}
async function finalizeWhatsAppStatusReaction(params: {
controller: StatusReactionController;
outcome: "done" | "error";
hasFinalResponse: boolean;
removeAckAfterReply: boolean;
timing: typeof DEFAULT_TIMING;
}): Promise<void> {
if (params.outcome === "done") {
await params.controller.setDone();
if (params.removeAckAfterReply) {
await new Promise<void>((resolve) => {
setTimeout(resolve, params.timing.doneHoldMs);
});
await params.controller.clear();
} else {
await params.controller.restoreInitial();
}
return;
}
await params.controller.setError();
if (params.hasFinalResponse) {
if (params.removeAckAfterReply) {
await new Promise<void>((resolve) => {
setTimeout(resolve, params.timing.errorHoldMs);
});
await params.controller.clear();
} else {
await params.controller.restoreInitial();
}
return;
}
if (params.removeAckAfterReply) {
await new Promise<void>((resolve) => {
setTimeout(resolve, params.timing.errorHoldMs);
});
}
await params.controller.restoreInitial();
}

View File

@@ -0,0 +1,43 @@
// Whatsapp tests cover last route plugin behavior.
import { afterEach, describe, expect, it } from "vitest";
import { trackBackgroundTask } from "./last-route.js";
const waitForTaskCleanup = async (task: Promise<unknown>) => {
await Promise.allSettled([task]);
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
};
describe("trackBackgroundTask", () => {
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => {
unhandledRejections.push(reason);
};
afterEach(() => {
process.off("unhandledRejection", onUnhandledRejection);
unhandledRejections.length = 0;
});
it("does not leak unhandled rejections when a tracked task fails", async () => {
process.on("unhandledRejection", onUnhandledRejection);
const backgroundTasks = new Set<Promise<unknown>>();
let rejectTask: ((reason?: unknown) => void) | undefined;
const task = new Promise<void>((_resolve, reject) => {
rejectTask = reject;
});
trackBackgroundTask(backgroundTasks, task);
expect(backgroundTasks.size).toBe(1);
if (!rejectTask) {
throw new Error("Expected tracked task reject callback to be initialized");
}
rejectTask(new Error("boom"));
await waitForTaskCleanup(task);
expect(backgroundTasks.size).toBe(0);
expect(unhandledRejections).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,53 @@
// Whatsapp plugin module implements last route behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { formatError } from "../../session.js";
import { resolveStorePath, updateLastRoute } from "../config.runtime.js";
export function trackBackgroundTask(
backgroundTasks: Set<Promise<unknown>>,
task: Promise<unknown>,
) {
backgroundTasks.add(task);
const cleanup = () => {
backgroundTasks.delete(task);
};
task.then(cleanup, cleanup);
}
export function updateLastRouteInBackground(params: {
cfg: OpenClawConfig;
backgroundTasks: Set<Promise<unknown>>;
storeAgentId: string;
sessionKey: string;
channel: "whatsapp";
to: string;
accountId?: string;
ctx?: MsgContext;
warn: (obj: unknown, msg: string) => void;
}) {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.storeAgentId,
});
const task = updateLastRoute({
storePath,
sessionKey: params.sessionKey,
deliveryContext: {
channel: params.channel,
to: params.to,
accountId: params.accountId,
},
ctx: params.ctx,
}).catch((err: unknown) => {
params.warn(
{
error: formatError(err),
storePath,
sessionKey: params.sessionKey,
to: params.to,
},
"failed updating last route",
);
});
trackBackgroundTask(params.backgroundTasks, task);
}

View File

@@ -0,0 +1,29 @@
// Whatsapp plugin module implements listener log behavior.
export function formatWhatsAppInboundListeningLog(account: {
groups?: Record<string, unknown>;
groupPolicy: "open" | "allowlist" | "disabled";
hasGroupAllowFrom: boolean;
}): string {
if (account.groupPolicy === "disabled") {
return "Listening for WhatsApp inbound messages (DM + groups disabled by groupPolicy).";
}
if (account.groupPolicy === "allowlist" && !account.hasGroupAllowFrom) {
return "Listening for WhatsApp inbound messages (DM + group inbound blocked by empty groupPolicy allowlist).";
}
const groups = account.groups ?? {};
if (Object.keys(groups).length === 0) {
const suffix =
account.groupPolicy === "allowlist"
? "sender allowlist configured"
: "no group allowlist configured";
return `Listening for WhatsApp inbound messages (DM + all groups; ${suffix}).`;
}
if (Object.hasOwn(groups, "*")) {
return "Listening for WhatsApp inbound messages (DM + all groups; wildcard configured).";
}
const explicitGroupCount = Object.keys(groups).length;
const groupLabel = explicitGroupCount === 1 ? "group" : "groups";
return `Listening for WhatsApp inbound messages (DM + ${explicitGroupCount} configured ${groupLabel}).`;
}

View File

@@ -0,0 +1,39 @@
// Whatsapp plugin module implements message line behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
export {
formatInboundEnvelope,
type EnvelopeFormatOptions,
} from "openclaw/plugin-sdk/channel-inbound";
type WhatsAppMessagePrefixConfig = OpenClawConfig;
function normalizeAgentId(agentId: string): string {
return agentId.trim().toLowerCase() || "main";
}
function resolveIdentityNamePrefix(
cfg: WhatsAppMessagePrefixConfig,
agentId: string,
): string | undefined {
const normalizedAgentId = normalizeAgentId(agentId);
const identityName = cfg.agents?.list
?.find((agent) => normalizeAgentId(agent.id ?? "") === normalizedAgentId)
?.identity?.name?.trim();
return identityName ? `[${identityName}]` : undefined;
}
export function resolveMessagePrefix(
cfg: WhatsAppMessagePrefixConfig,
agentId: string,
opts?: { configured?: string; hasAllowFrom?: boolean; fallback?: string },
): string {
const configured = opts?.configured ?? cfg.messages?.messagePrefix;
if (configured !== undefined) {
return configured;
}
if (opts?.hasAllowFrom === true) {
return "";
}
return resolveIdentityNamePrefix(cfg, agentId) ?? opts?.fallback ?? "[openclaw]";
}

View File

@@ -0,0 +1,71 @@
// Whatsapp plugin module implements message line behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
getPrimaryIdentityId,
getReplyContext,
getSenderIdentity,
type WhatsAppReplyContext,
} from "../../identity.js";
import { requireWhatsAppInboundAdmission } from "../../inbound/admission.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import {
formatInboundEnvelope,
resolveMessagePrefix,
type EnvelopeFormatOptions,
} from "./message-line.runtime.js";
function formatReplyTarget(replyTo: WhatsAppReplyContext | null) {
if (!replyTo?.body) {
return null;
}
const sender = replyTo.sender?.label ?? replyTo.sender?.e164 ?? "unknown sender";
const idPart = replyTo.id ? ` id:${replyTo.id}` : "";
return `[Replying to ${sender}${idPart}]\n${replyTo.body}\n[/Replying]`;
}
export function formatReplyContext(msg: AdmittedWebInboundMessage) {
return formatReplyTarget(getReplyContext(msg));
}
export function buildInboundLine(params: {
cfg: OpenClawConfig;
msg: AdmittedWebInboundMessage;
agentId: string;
previousTimestamp?: number;
envelope?: EnvelopeFormatOptions;
visibleReplyTo?: WhatsAppReplyContext | null;
}) {
const { cfg, msg, agentId, previousTimestamp, envelope } = params;
// WhatsApp inbound prefix: channels.whatsapp.messagePrefix > legacy messages.messagePrefix > identity/defaults
const messagePrefix = resolveMessagePrefix(cfg, agentId, {
configured: cfg.channels?.whatsapp?.messagePrefix,
hasAllowFrom: (cfg.channels?.whatsapp?.allowFrom?.length ?? 0) > 0,
});
const admission = requireWhatsAppInboundAdmission(msg);
const conversationId = admission.conversation.id;
const conversationKind = admission.conversation.kind;
const prefixStr = messagePrefix ? `${messagePrefix} ` : "";
const replyContext =
params.visibleReplyTo === undefined
? formatReplyContext(msg)
: formatReplyTarget(params.visibleReplyTo);
const baseLine = `${prefixStr}${msg.payload.body}${replyContext ? `\n\n${replyContext}` : ""}`;
const sender = getSenderIdentity(msg);
// Wrap with standardized envelope for the agent.
return formatInboundEnvelope({
channel: "WhatsApp",
from: conversationKind === "group" ? conversationId : conversationId.replace(/^whatsapp:/, ""),
timestamp: msg.event.timestamp,
body: baseLine,
chatType: conversationKind,
sender: {
name: sender.name ?? undefined,
e164: sender.e164 ?? undefined,
id: getPrimaryIdentityId(sender) ?? undefined,
},
previousTimestamp,
envelope,
fromMe: msg.platform.fromMe,
});
}

View File

@@ -0,0 +1,661 @@
// Whatsapp tests cover inbound configured ACP binding route materialization.
import type { ConfiguredBindingRouteResult } from "openclaw/plugin-sdk/conversation-binding-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
const processMessageMock = vi.hoisted(() => vi.fn());
const maybeBroadcastMessageMock = vi.hoisted(() => vi.fn());
const resolveConfiguredBindingRouteMock = vi.hoisted(() => vi.fn());
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn());
const resolveAgentRouteMock = vi.hoisted(() => vi.fn());
const applyGroupGatingMock = vi.hoisted(() => vi.fn());
const updateLastRouteInBackgroundMock = vi.hoisted(() => vi.fn());
const transcribeFirstAudioMock = vi.hoisted(() => vi.fn());
const maybeSendAckReactionMock = vi.hoisted(() => vi.fn());
const createStatusReactionControllerMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/conversation-binding-runtime", () => ({
resolveConfiguredBindingRoute: (...args: unknown[]) => resolveConfiguredBindingRouteMock(...args),
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
ensureConfiguredBindingRouteReadyMock(...args),
}));
vi.mock("./audio-preflight.runtime.js", () => ({
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
}));
vi.mock("./ack-reaction.js", () => ({
maybeSendAckReaction: (...args: unknown[]) => maybeSendAckReactionMock(...args),
}));
vi.mock("openclaw/plugin-sdk/routing", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/routing")>();
return {
...actual,
buildGroupHistoryKey: () => "group-key",
resolveAgentRoute: (...args: unknown[]) => resolveAgentRouteMock(...args),
};
});
vi.mock("../../accounts.js", () => ({
resolveWhatsAppAccount: () => ({
accountId: "work",
authDir: "/tmp/whatsapp-auth",
mentionPatterns: [],
selfChatMode: false,
}),
}));
vi.mock("../../group-session-key.js", () => ({
resolveWhatsAppGroupSessionRoute: (route: unknown) => route,
}));
vi.mock("../../identity.js", () => ({
getPrimaryIdentityId: () => "+15551234567",
getSenderIdentity: () => ({ e164: "+15551234567", name: "Alice" }),
}));
vi.mock("./broadcast.js", () => ({
maybeBroadcastMessage: (...args: unknown[]) => maybeBroadcastMessageMock(...args),
}));
vi.mock("./group-gating.js", () => ({
applyGroupGating: (...args: unknown[]) => applyGroupGatingMock(...args),
}));
vi.mock("./last-route.js", () => ({
updateLastRouteInBackground: (...args: unknown[]) => updateLastRouteInBackgroundMock(...args),
}));
vi.mock("./process-message.js", () => ({
processMessage: (...args: unknown[]) => processMessageMock(...args),
}));
vi.mock("./status-reaction.js", () => ({
createWhatsAppStatusReactionController: (...args: unknown[]) =>
createStatusReactionControllerMock(...args),
}));
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
import { createWebOnMessageHandler } from "./on-message.js";
const baseRoute = {
agentId: "sandboxed-agent",
accountId: "work",
channel: "whatsapp",
sessionKey: "agent:sandboxed-agent:whatsapp:direct:+15551234567",
mainSessionKey: "agent:sandboxed-agent:main",
matchedBy: "binding.agent",
lastRoutePolicy: "bound",
};
const boundSessionKey = "agent:sandboxed-agent:acp:binding:whatsapp:work:abc123";
const directConversationId = "+15551234567";
const groupConversationId = "120363001234567890@g.us";
const configuredBindingRecord = {
bindingId: `config:acp:whatsapp:work:${directConversationId}`,
targetSessionKey: boundSessionKey,
targetKind: "session",
conversation: {
channel: "whatsapp",
accountId: "work",
conversationId: directConversationId,
},
status: "active",
boundAt: 0,
metadata: {
source: "config",
mode: "oneshot",
agentId: "sandboxed-agent",
},
} as const;
const configuredStatefulTarget = {
kind: "stateful",
driverId: "acp",
sessionKey: boundSessionKey,
agentId: "sandboxed-agent",
} as const;
const configuredBindingResolution = {
conversation: {
channel: "whatsapp",
accountId: "work",
conversationId: directConversationId,
},
compiledBinding: {
channel: "whatsapp",
accountPattern: "work",
binding: {
type: "acp",
agentId: "sandboxed-agent",
match: {
channel: "whatsapp",
accountId: "work",
peer: { kind: "direct", id: directConversationId },
},
},
bindingConversationId: directConversationId,
target: { conversationId: directConversationId },
agentId: "sandboxed-agent",
provider: {
compileConfiguredBinding: () => ({ conversationId: directConversationId }),
matchInboundConversation: () => ({ conversationId: directConversationId }),
},
targetFactory: {
driverId: "acp",
materialize: () => ({
record: configuredBindingRecord,
statefulTarget: configuredStatefulTarget,
}),
},
},
match: { conversationId: "+15551234567" },
record: configuredBindingRecord,
statefulTarget: configuredStatefulTarget,
} as const;
const configuredGroupBindingRecord = {
...configuredBindingRecord,
bindingId: `config:acp:whatsapp:work:${groupConversationId}`,
conversation: {
channel: "whatsapp",
accountId: "work",
conversationId: groupConversationId,
},
} as const;
const configuredGroupBindingResolution = {
...configuredBindingResolution,
conversation: {
channel: "whatsapp",
accountId: "work",
conversationId: groupConversationId,
},
compiledBinding: {
...configuredBindingResolution.compiledBinding,
binding: {
...configuredBindingResolution.compiledBinding.binding,
match: {
channel: "whatsapp",
accountId: "work",
peer: { kind: "group", id: groupConversationId },
},
},
bindingConversationId: groupConversationId,
target: { conversationId: groupConversationId },
provider: {
compileConfiguredBinding: () => ({ conversationId: groupConversationId }),
matchInboundConversation: () => ({ conversationId: groupConversationId }),
},
targetFactory: {
driverId: "acp",
materialize: () => ({
record: configuredGroupBindingRecord,
statefulTarget: configuredStatefulTarget,
}),
},
},
match: { conversationId: groupConversationId },
record: configuredGroupBindingRecord,
} as const;
type ConfiguredBindingResolution = NonNullable<ConfiguredBindingRouteResult["bindingResolution"]>;
function resolvedConfiguredRoute(
bindingResolution: ConfiguredBindingResolution = configuredBindingResolution,
) {
return ({ route }: { route: typeof baseRoute }) => ({
bindingResolution,
boundSessionKey,
boundAgentId: "sandboxed-agent",
route: {
...route,
agentId: "sandboxed-agent",
sessionKey: boundSessionKey,
matchedBy: "binding.channel",
},
});
}
function createCfg(): Record<string, unknown> {
return {
bindings: [
{
type: "acp",
agentId: "sandboxed-agent",
match: {
channel: "whatsapp",
accountId: "work",
peer: { kind: "direct", id: "+15551234567" },
},
},
],
};
}
function createGroupCfg(): Record<string, unknown> {
return {
bindings: [
{
type: "acp",
agentId: "sandboxed-agent",
match: {
channel: "whatsapp",
accountId: "work",
peer: { kind: "group", id: "120363001234567890@g.us" },
},
},
],
};
}
function createHandler(warn = vi.fn(), cfg: Record<string, unknown> = createCfg()) {
const groupHistories = new Map();
return {
warn,
groupHistories,
handler: createWebOnMessageHandler({
cfg: cfg as never,
verbose: false,
connectionId: "conn-1",
maxMediaBytes: 1024 * 1024,
groupHistoryLimit: 20,
groupHistories,
groupMemberNames: new Map(),
echoTracker: {
has: () => false,
forget: () => {},
rememberText: () => {},
buildCombinedKey: ({ combinedBody }: { combinedBody: string }) => combinedBody,
},
backgroundTasks: new Set(),
replyResolver: vi.fn() as never,
replyLogger: {
info: () => {},
warn,
debug: () => {},
error: () => {},
} as never,
baseMentionConfig: {} as never,
account: { authDir: "/tmp/whatsapp-auth", accountId: "work" },
}),
};
}
function createMessage() {
return createTestWebInboundMessage({
admission: {
accountId: "work",
conversation: {
kind: "direct",
id: "15551234567@s.whatsapp.net",
},
sender: {
id: "15551234567@s.whatsapp.net",
},
},
platform: {
chatJid: "15551234567@s.whatsapp.net",
recipientJid: "15559876543@s.whatsapp.net",
},
});
}
type TestMessageOverrides = NonNullable<Parameters<typeof createTestWebInboundMessage>[0]>;
function createGroupMessage(overrides: TestMessageOverrides = {}) {
const { admission, platform, ...message } = overrides;
return createTestWebInboundMessage({
...message,
admission: {
...admission,
accountId: "work",
conversation: {
kind: "group",
id: "120363001234567890@g.us",
...admission?.conversation,
},
senderAccess: {
reasonCode: "group_policy_allowed",
...admission?.senderAccess,
},
},
platform: {
chatJid: "120363001234567890@g.us",
recipientJid: "15559876543@s.whatsapp.net",
...platform,
},
});
}
function createGroupAudioMessage() {
return createTestWebInboundMessage({
admission: {
accountId: "work",
conversation: {
kind: "group",
id: "120363001234567890@g.us",
},
senderAccess: {
reasonCode: "group_policy_allowed",
},
},
payload: {
body: "<media:audio>",
media: {
type: "audio/ogg; codecs=opus",
path: "/tmp/voice.ogg",
},
},
platform: {
chatJid: "120363001234567890@g.us",
recipientJid: "15559876543@s.whatsapp.net",
},
});
}
describe("createWebOnMessageHandler configured ACP bindings", () => {
beforeEach(() => {
processMessageMock.mockReset();
processMessageMock.mockResolvedValue(true);
maybeBroadcastMessageMock.mockReset();
maybeBroadcastMessageMock.mockResolvedValue(false);
applyGroupGatingMock.mockReset();
applyGroupGatingMock.mockResolvedValue({ shouldProcess: true });
updateLastRouteInBackgroundMock.mockReset();
transcribeFirstAudioMock.mockReset();
transcribeFirstAudioMock.mockResolvedValue("agent please handle this");
maybeSendAckReactionMock.mockReset();
maybeSendAckReactionMock.mockResolvedValue(null);
createStatusReactionControllerMock.mockReset();
createStatusReactionControllerMock.mockResolvedValue(null);
resolveAgentRouteMock.mockReset();
resolveAgentRouteMock.mockReturnValue(baseRoute);
ensureConfiguredBindingRouteReadyMock.mockReset();
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
resolveConfiguredBindingRouteMock.mockReset();
resolveConfiguredBindingRouteMock.mockImplementation(resolvedConfiguredRoute());
});
it("rewrites matching WhatsApp inbound turns to the configured ACP session key", async () => {
const { handler } = createHandler();
await handler(createMessage());
expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "whatsapp",
accountId: "work",
conversationId: "+15551234567",
route: baseRoute,
}),
);
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledWith({
cfg: expect.any(Object),
bindingResolution: configuredBindingResolution,
});
expect(processMessageMock).toHaveBeenCalledTimes(1);
expect(processMessageMock.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
route: expect.objectContaining({
sessionKey: boundSessionKey,
matchedBy: "binding.channel",
}),
}),
);
});
it("drops the inbound turn instead of falling back when ACP binding readiness fails", async () => {
const { handler, warn } = createHandler();
ensureConfiguredBindingRouteReadyMock.mockResolvedValueOnce({
ok: false,
error: "acpx backend unavailable",
});
await handler(createMessage());
expect(processMessageMock).not.toHaveBeenCalled();
expect(maybeBroadcastMessageMock).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
"whatsapp: configured ACP binding unavailable for conversation +15551234567: acpx backend unavailable",
);
});
it("keeps the ordinary WhatsApp route when no configured ACP binding matches", async () => {
const { handler } = createHandler();
resolveConfiguredBindingRouteMock.mockImplementationOnce(({ route }) => ({
bindingResolution: null,
route,
}));
await handler(createMessage());
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
expect(processMessageMock).toHaveBeenCalledTimes(1);
expect(processMessageMock.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
route: expect.objectContaining({
sessionKey: baseRoute.sessionKey,
matchedBy: baseRoute.matchedBy,
}),
}),
);
});
it("skips broadcast fan-out for configured ACP binding routes", async () => {
const { handler } = createHandler(vi.fn(), {
...createCfg(),
broadcast: {
"+15551234567": ["ordinary-agent"],
},
});
await handler(createMessage());
expect(maybeBroadcastMessageMock).not.toHaveBeenCalled();
expect(processMessageMock).toHaveBeenCalledTimes(1);
expect(processMessageMock.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
route: expect.objectContaining({
sessionKey: boundSessionKey,
matchedBy: "binding.channel",
}),
}),
);
});
it("waits for group admission before ensuring a configured ACP binding target", async () => {
resolveConfiguredBindingRouteMock.mockImplementationOnce(
resolvedConfiguredRoute(configuredGroupBindingResolution),
);
const pendingEntry = {
sender: "Alice",
body: "ambient group message",
};
applyGroupGatingMock.mockImplementationOnce(
async (params: { groupHistories: Map<string, unknown[]>; groupHistoryKey: string }) => {
params.groupHistories.set(params.groupHistoryKey, [pendingEntry]);
return { shouldProcess: false };
},
);
const { handler, groupHistories } = createHandler(vi.fn(), createGroupCfg());
await handler(createGroupMessage());
expect(applyGroupGatingMock).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "sandboxed-agent",
sessionKey: boundSessionKey,
}),
);
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
expect(updateLastRouteInBackgroundMock).not.toHaveBeenCalled();
expect(maybeBroadcastMessageMock).not.toHaveBeenCalled();
expect(processMessageMock).not.toHaveBeenCalled();
expect(groupHistories.get("group-key")).toEqual([pendingEntry]);
});
it("does not record ordinary group routes before group admission", async () => {
resolveConfiguredBindingRouteMock.mockImplementationOnce(({ route }) => ({
bindingResolution: null,
route,
}));
applyGroupGatingMock.mockResolvedValueOnce({ shouldProcess: false });
const { handler } = createHandler(vi.fn(), { channels: { whatsapp: {} } });
await handler(createGroupMessage());
expect(applyGroupGatingMock).toHaveBeenCalledTimes(1);
expect(updateLastRouteInBackgroundMock).not.toHaveBeenCalled();
expect(maybeBroadcastMessageMock).not.toHaveBeenCalled();
expect(processMessageMock).not.toHaveBeenCalled();
});
it("does not record group routes when admission blocks dispatch", async () => {
const { handler } = createHandler(vi.fn(), { channels: { whatsapp: {} } });
await handler(
createGroupMessage({
admission: {
ingress: {
admission: "drop",
decision: "block",
reasonCode: "group_policy_not_allowlisted",
},
senderAccess: {
allowed: false,
decision: "block",
reasonCode: "group_policy_not_allowlisted",
},
activationAccess: {
allowed: false,
shouldSkip: true,
reasonCode: "group_policy_not_allowlisted",
},
},
}),
);
expect(applyGroupGatingMock).not.toHaveBeenCalled();
expect(updateLastRouteInBackgroundMock).not.toHaveBeenCalled();
expect(maybeBroadcastMessageMock).not.toHaveBeenCalled();
expect(processMessageMock).not.toHaveBeenCalled();
});
it("does not record configured ACP group routes when readiness fails", async () => {
resolveConfiguredBindingRouteMock.mockImplementationOnce(
resolvedConfiguredRoute(configuredGroupBindingResolution),
);
const { handler, warn } = createHandler(vi.fn(), createGroupCfg());
ensureConfiguredBindingRouteReadyMock.mockResolvedValueOnce({
ok: false,
error: "acpx backend unavailable",
});
await handler(createGroupMessage());
expect(applyGroupGatingMock).toHaveBeenCalledTimes(1);
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
expect(updateLastRouteInBackgroundMock).not.toHaveBeenCalled();
expect(maybeBroadcastMessageMock).not.toHaveBeenCalled();
expect(processMessageMock).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
"whatsapp: configured ACP binding unavailable for conversation 120363001234567890@g.us: acpx backend unavailable",
);
});
it("removes preflight ack when configured ACP readiness fails after group audio mention gating", async () => {
const ackReaction = {
ackReactionPromise: Promise.resolve(true),
ackReactionValue: "👀",
remove: vi.fn(async () => undefined),
};
resolveConfiguredBindingRouteMock.mockImplementationOnce(
resolvedConfiguredRoute(configuredGroupBindingResolution),
);
applyGroupGatingMock
.mockResolvedValueOnce({ shouldProcess: false, needsMentionText: true })
.mockResolvedValueOnce({ shouldProcess: true });
maybeSendAckReactionMock.mockResolvedValueOnce(ackReaction);
ensureConfiguredBindingRouteReadyMock.mockResolvedValueOnce({
ok: false,
error: "acpx backend unavailable",
});
const { handler } = createHandler(vi.fn(), createGroupCfg());
await handler(createGroupAudioMessage());
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
expect(ackReaction.remove).toHaveBeenCalledTimes(1);
expect(maybeBroadcastMessageMock).not.toHaveBeenCalled();
expect(processMessageMock).not.toHaveBeenCalled();
});
it("clears preflight status reaction when configured ACP readiness fails after group audio mention gating", async () => {
const statusReactionController = {
setQueued: vi.fn(async () => undefined),
setThinking: vi.fn(async () => undefined),
setTool: vi.fn(async () => undefined),
setCompacting: vi.fn(async () => undefined),
cancelPending: vi.fn(),
setDone: vi.fn(async () => undefined),
setError: vi.fn(async () => undefined),
clear: vi.fn(async () => undefined),
restoreInitial: vi.fn(async () => undefined),
};
resolveConfiguredBindingRouteMock.mockImplementationOnce(
resolvedConfiguredRoute(configuredGroupBindingResolution),
);
applyGroupGatingMock
.mockResolvedValueOnce({ shouldProcess: false, needsMentionText: true })
.mockResolvedValueOnce({ shouldProcess: true });
createStatusReactionControllerMock.mockResolvedValueOnce(statusReactionController);
ensureConfiguredBindingRouteReadyMock.mockResolvedValueOnce({
ok: false,
error: "acpx backend unavailable",
});
const { handler } = createHandler(vi.fn(), {
...createGroupCfg(),
messages: {
statusReactions: { enabled: true },
},
});
await handler(createGroupAudioMessage());
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
expect(statusReactionController.setQueued).toHaveBeenCalledTimes(1);
expect(statusReactionController.cancelPending).toHaveBeenCalledTimes(1);
expect(statusReactionController.clear).toHaveBeenCalledTimes(1);
expect(statusReactionController.restoreInitial).not.toHaveBeenCalled();
expect(maybeSendAckReactionMock).not.toHaveBeenCalled();
expect(maybeBroadcastMessageMock).not.toHaveBeenCalled();
expect(processMessageMock).not.toHaveBeenCalled();
});
it("records configured ACP group routes after admission and readiness", async () => {
resolveConfiguredBindingRouteMock.mockImplementationOnce(
resolvedConfiguredRoute(configuredGroupBindingResolution),
);
const { handler } = createHandler(vi.fn(), createGroupCfg());
await handler(createGroupMessage());
expect(updateLastRouteInBackgroundMock).toHaveBeenCalledTimes(1);
expect(updateLastRouteInBackgroundMock).toHaveBeenCalledWith(
expect.objectContaining({
storeAgentId: "sandboxed-agent",
sessionKey: boundSessionKey,
channel: "whatsapp",
to: "120363001234567890@g.us",
accountId: "work",
}),
);
expect(ensureConfiguredBindingRouteReadyMock.mock.invocationCallOrder[0]).toBeLessThan(
updateLastRouteInBackgroundMock.mock.invocationCallOrder[0] ?? 0,
);
expect(processMessageMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,490 @@
// Whatsapp tests cover on message.audio preflight plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
const events: string[] = [];
const transcribeFirstAudioMock = vi.fn();
const maybeSendAckReactionMock = vi.fn();
const processMessageMock = vi.fn();
const maybeBroadcastMessageMock = vi.fn();
const createStatusReactionControllerMock = vi.fn();
const updateLastRouteInBackgroundMock = vi.fn();
const statusReactionController = {
setQueued: vi.fn(async () => {
events.push("status-queued");
}),
setThinking: vi.fn(async () => undefined),
setTool: vi.fn(async () => undefined),
setCompacting: vi.fn(async () => undefined),
cancelPending: vi.fn(),
setDone: vi.fn(async () => undefined),
setError: vi.fn(async () => undefined),
clear: vi.fn(async () => undefined),
restoreInitial: vi.fn(async () => undefined),
};
const ackReactionHandle = {
ackReactionPromise: Promise.resolve(true),
ackReactionValue: "👀",
remove: vi.fn(async () => undefined),
};
const applyGroupGatingMock = vi.fn();
vi.mock("./audio-preflight.runtime.js", () => ({
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
}));
vi.mock("./ack-reaction.js", () => ({
maybeSendAckReaction: (...args: unknown[]) => maybeSendAckReactionMock(...args),
}));
vi.mock("./process-message.js", () => ({
processMessage: (...args: unknown[]) => processMessageMock(...args),
}));
vi.mock("./broadcast.js", () => ({
maybeBroadcastMessage: (...args: unknown[]) => maybeBroadcastMessageMock(...args),
}));
vi.mock("./status-reaction.js", () => ({
createWhatsAppStatusReactionController: (...args: unknown[]) =>
createStatusReactionControllerMock(...args),
}));
vi.mock("./group-gating.js", () => ({
applyGroupGating: (...args: unknown[]) => applyGroupGatingMock(...args),
}));
vi.mock("./last-route.js", () => ({
updateLastRouteInBackground: (...args: unknown[]) => updateLastRouteInBackgroundMock(...args),
}));
vi.mock("./peer.js", () => ({
resolvePeerId: (msg: { admission: { conversation: { id: string } } }) =>
msg.admission.conversation.id,
}));
vi.mock("../config.runtime.js", () => ({
getRuntimeConfig: () => ({
channels: {
whatsapp: {
ackReaction: { enabled: true },
},
},
}),
}));
vi.mock("../../group-session-key.js", () => ({
resolveWhatsAppGroupSessionRoute: (route: unknown) => route,
}));
vi.mock("../../identity.js", () => ({
getPrimaryIdentityId: () => undefined,
getSenderIdentity: () => ({ e164: "+15550000002", name: "Alice" }),
}));
vi.mock("../../text-runtime.js", () => ({
normalizeE164: (value: string) => value,
}));
vi.mock("openclaw/plugin-sdk/routing", () => ({
buildGroupHistoryKey: () => "group-key",
resolveAgentRoute: () => ({
agentId: "main",
accountId: "default",
sessionKey: "agent:main:whatsapp:+15550000002",
mainSessionKey: "agent:main:main",
}),
}));
import {
createTestLegacyFlatWebInboundMessage,
createTestWebAudioInboundMessage,
} from "../../inbound/test-message.test-helper.js";
import type { WebInboundMessage } from "../../inbound/types.js";
import { createWebOnMessageHandler } from "./on-message.js";
function makeAudioMsg(): WebInboundMessage {
return createTestWebAudioInboundMessage();
}
function makeLegacyAudioMsg() {
return createTestLegacyFlatWebInboundMessage({
body: "<media:audio>",
mediaPath: "/tmp/voice.ogg",
mediaType: "audio/ogg; codecs=opus",
});
}
function makeGroupAudioMsg(): WebInboundMessage {
return createTestWebAudioInboundMessage({
platform: { chatJid: "1203630@g.us" },
admission: {
conversation: {
kind: "group",
id: "1203630@g.us",
},
senderAccess: {
reasonCode: "group_policy_allowed",
},
},
wasMentioned: false,
});
}
function makeBlockedDirectAudioMsg(): WebInboundMessage {
return createTestWebAudioInboundMessage({
admission: {
ingress: {
admission: "drop",
decision: "block",
reasonCode: "dm_policy_not_allowlisted",
},
senderAccess: {
allowed: false,
decision: "block",
reasonCode: "dm_policy_not_allowlisted",
},
activationAccess: {
allowed: false,
shouldSkip: true,
reasonCode: "dm_policy_not_allowlisted",
},
},
});
}
function makeEchoTracker() {
return {
has: () => false,
forget: () => {},
rememberText: () => {},
buildCombinedKey: (p: { combinedBody: string }) => p.combinedBody,
};
}
function mockObjectArg(mockFn: ReturnType<typeof vi.fn>, label: string, callIndex = 0) {
const call = mockFn.mock.calls.at(callIndex);
if (!call) {
throw new Error(`Expected ${label} call ${callIndex}`);
}
const arg = call.at(0);
if (!arg || typeof arg !== "object") {
throw new Error(`Expected ${label} call ${callIndex} object argument`);
}
return arg as Record<string, unknown>;
}
function makeHandler(overrides: Partial<Parameters<typeof createWebOnMessageHandler>[0]> = {}) {
return createWebOnMessageHandler({
cfg: {
channels: {
whatsapp: {
ackReaction: { enabled: true },
},
},
} as never,
verbose: false,
connectionId: "conn-1",
maxMediaBytes: 1024 * 1024,
groupHistoryLimit: 20,
groupHistories: new Map(),
groupMemberNames: new Map(),
echoTracker: makeEchoTracker() as never,
backgroundTasks: new Set(),
replyResolver: vi.fn() as never,
replyLogger: {
info: () => {},
warn: () => {},
debug: () => {},
error: () => {},
} as never,
baseMentionConfig: {} as never,
account: { authDir: "/tmp/auth", accountId: "default" },
...overrides,
});
}
describe("createWebOnMessageHandler audio preflight", () => {
beforeEach(() => {
events.length = 0;
maybeBroadcastMessageMock.mockReset();
maybeBroadcastMessageMock.mockImplementation(async () => false);
maybeSendAckReactionMock.mockReset();
maybeSendAckReactionMock.mockImplementation(async () => {
events.push("ack");
return ackReactionHandle;
});
transcribeFirstAudioMock.mockReset();
transcribeFirstAudioMock.mockImplementation(async () => {
events.push("stt");
return "transcribed voice note";
});
processMessageMock.mockReset();
processMessageMock.mockResolvedValue(true);
createStatusReactionControllerMock.mockReset();
createStatusReactionControllerMock.mockResolvedValue(statusReactionController);
Object.values(statusReactionController).forEach((mock) => mock.mockClear());
ackReactionHandle.remove.mockClear();
updateLastRouteInBackgroundMock.mockReset();
applyGroupGatingMock.mockReset();
applyGroupGatingMock.mockResolvedValue({ shouldProcess: true });
});
it("sends ack reaction before audio preflight for voice notes", async () => {
const handler = makeHandler();
await handler(makeAudioMsg());
expect(events).toEqual(["ack", "stt"]);
expect(processMessageMock).toHaveBeenCalledTimes(1);
const processParams = mockObjectArg(processMessageMock, "processMessage");
expect(processParams.preflightAudioTranscript).toBe("transcribed voice note");
expect(processParams.ackAlreadySent).toBe(true);
expect(processParams.ackReaction).toBe(ackReactionHandle);
});
it("sends queued status reaction before audio preflight when status reactions are enabled", async () => {
const handler = makeHandler({
cfg: {
messages: { statusReactions: { enabled: true } },
channels: {
whatsapp: {
ackReaction: { enabled: true },
},
},
} as never,
});
await handler(makeAudioMsg());
expect(events).toEqual(["status-queued", "stt"]);
expect(maybeSendAckReactionMock).not.toHaveBeenCalled();
expect(createStatusReactionControllerMock).toHaveBeenCalledTimes(1);
expect(processMessageMock).toHaveBeenCalledTimes(1);
const processParams = mockObjectArg(processMessageMock, "processMessage");
expect(processParams.preflightAudioTranscript).toBe("transcribed voice note");
expect(processParams.statusReactionController).toBe(statusReactionController);
expect(processParams.ackAlreadySent).toBeUndefined();
});
it("drops blocked DM admission before ack, preflight, or dispatch", async () => {
const handler = makeHandler();
await handler(makeBlockedDirectAudioMsg());
expect(events).toStrictEqual([]);
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
expect(maybeSendAckReactionMock).not.toHaveBeenCalled();
expect(updateLastRouteInBackgroundMock).not.toHaveBeenCalled();
expect(processMessageMock).not.toHaveBeenCalled();
});
it("skips early DM ack/preflight for legacy audio without explicit access proof", async () => {
const handler = makeHandler();
await handler(makeLegacyAudioMsg());
expect(events).toStrictEqual([]);
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
expect(maybeSendAckReactionMock).not.toHaveBeenCalled();
expect(processMessageMock).toHaveBeenCalledTimes(1);
const processParams = mockObjectArg(processMessageMock, "processMessage");
expect(processParams).not.toHaveProperty("preflightAudioTranscript");
expect(processParams).not.toHaveProperty("ackAlreadySent");
expect(processParams).not.toHaveProperty("ackReaction");
});
it("shares one transcript for legacy direct broadcasts without explicit access proof", async () => {
maybeBroadcastMessageMock.mockImplementation(
async (params: { preflightAudioTranscript?: string | null }) => {
expect(params.preflightAudioTranscript).toBe("transcribed voice note");
return true;
},
);
const handler = makeHandler({
cfg: {
channels: {
whatsapp: {
ackReaction: { enabled: true },
},
},
broadcast: {
"+15551234567": ["main", "backup"],
},
} as never,
});
await handler(makeLegacyAudioMsg());
expect(events).toStrictEqual(["stt"]);
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
expect(maybeSendAckReactionMock).not.toHaveBeenCalled();
expect(processMessageMock).not.toHaveBeenCalled();
});
it("preserves per-agent ack checks for group broadcast voice notes", async () => {
maybeBroadcastMessageMock.mockImplementation(
async (params: {
ackAlreadySent?: boolean;
ackReaction?: unknown;
preflightAudioTranscript?: string | null;
}) => {
expect(params.preflightAudioTranscript).toBe("transcribed voice note");
expect(params.ackAlreadySent).toBeUndefined();
expect(params.ackReaction).toBeUndefined();
return true;
},
);
const handler = makeHandler({
cfg: {
channels: {
whatsapp: {
ackReaction: { enabled: true },
},
},
broadcast: {
"1203630@g.us": ["main", "backup"],
},
} as never,
});
await handler(makeGroupAudioMsg());
expect(events).toEqual(["ack", "stt"]);
expect(processMessageMock).not.toHaveBeenCalled();
});
it("clears preflight status reaction before group broadcast voice fan-out", async () => {
maybeBroadcastMessageMock.mockImplementation(
async (params: { preflightAudioTranscript?: string | null }) => {
expect(params.preflightAudioTranscript).toBe("transcribed voice note");
expect(statusReactionController.cancelPending).toHaveBeenCalledTimes(1);
expect(statusReactionController.clear).toHaveBeenCalledTimes(1);
return true;
},
);
const handler = makeHandler({
cfg: {
messages: { statusReactions: { enabled: true } },
broadcast: {
"1203630@g.us": ["main", "backup"],
},
channels: {
whatsapp: {
ackReaction: { enabled: true },
},
},
} as never,
});
await handler(makeGroupAudioMsg());
expect(events).toEqual(["status-queued", "stt"]);
expect(maybeSendAckReactionMock).not.toHaveBeenCalled();
expect(statusReactionController.setQueued).toHaveBeenCalledTimes(1);
expect(statusReactionController.cancelPending).toHaveBeenCalledTimes(1);
expect(statusReactionController.clear).toHaveBeenCalledTimes(1);
expect(statusReactionController.restoreInitial).not.toHaveBeenCalled();
expect(processMessageMock).not.toHaveBeenCalled();
});
it("uses group voice transcript for mention gating before dispatch", async () => {
applyGroupGatingMock
.mockResolvedValueOnce({ shouldProcess: false, needsMentionText: true })
.mockResolvedValueOnce({ shouldProcess: true });
const handler = makeHandler();
await handler(makeGroupAudioMsg());
expect(applyGroupGatingMock).toHaveBeenCalledTimes(2);
const firstGatingParams = mockObjectArg(applyGroupGatingMock, "applyGroupGating");
expect(firstGatingParams.deferMissingMention).toBe(true);
expect(firstGatingParams).not.toHaveProperty("mentionText");
expect(events).toEqual(["ack", "stt"]);
const secondGatingParams = mockObjectArg(applyGroupGatingMock, "applyGroupGating", 1);
expect(secondGatingParams.mentionText).toBe("transcribed voice note");
expect(secondGatingParams).not.toHaveProperty("deferMissingMention");
expect(processMessageMock).toHaveBeenCalledTimes(1);
const processParams = mockObjectArg(processMessageMock, "processMessage");
expect(processParams.preflightAudioTranscript).toBe("transcribed voice note");
expect(processParams.ackAlreadySent).toBe(true);
expect(processParams.ackReaction).toBe(ackReactionHandle);
});
it("removes preflight ack when group voice transcript still misses mention gating", async () => {
applyGroupGatingMock
.mockResolvedValueOnce({ shouldProcess: false, needsMentionText: true })
.mockResolvedValueOnce({ shouldProcess: false });
const handler = makeHandler();
await handler(makeGroupAudioMsg());
expect(applyGroupGatingMock).toHaveBeenCalledTimes(2);
expect(events).toEqual(["ack", "stt"]);
expect(ackReactionHandle.remove).toHaveBeenCalledTimes(1);
expect(updateLastRouteInBackgroundMock).not.toHaveBeenCalled();
expect(maybeBroadcastMessageMock).not.toHaveBeenCalled();
expect(processMessageMock).not.toHaveBeenCalled();
});
it("clears preflight status reaction when group voice transcript still misses mention gating", async () => {
applyGroupGatingMock
.mockResolvedValueOnce({ shouldProcess: false, needsMentionText: true })
.mockResolvedValueOnce({ shouldProcess: false });
const handler = makeHandler({
cfg: {
messages: { statusReactions: { enabled: true } },
channels: {
whatsapp: {
ackReaction: { enabled: true },
},
},
} as never,
});
await handler(makeGroupAudioMsg());
expect(applyGroupGatingMock).toHaveBeenCalledTimes(2);
expect(statusReactionController.setQueued).toHaveBeenCalledTimes(1);
expect(statusReactionController.cancelPending).toHaveBeenCalledTimes(1);
expect(statusReactionController.clear).toHaveBeenCalledTimes(1);
expect(statusReactionController.restoreInitial).not.toHaveBeenCalled();
expect(maybeSendAckReactionMock).not.toHaveBeenCalled();
expect(updateLastRouteInBackgroundMock).not.toHaveBeenCalled();
expect(maybeBroadcastMessageMock).not.toHaveBeenCalled();
expect(processMessageMock).not.toHaveBeenCalled();
});
it("passes routing ctx fields to transcribeFirstAudio so echoTranscript can deliver (#79778)", async () => {
let capturedCtx: unknown;
transcribeFirstAudioMock.mockImplementation(async ({ ctx }: { ctx: unknown }) => {
capturedCtx = ctx;
return "transcribed voice note";
});
const handler = makeHandler();
await handler(makeAudioMsg());
expect(capturedCtx).toEqual({
MediaPaths: ["/tmp/voice.ogg"],
MediaTypes: ["audio/ogg; codecs=opus"],
From: "+15550000002",
To: "+15550000001",
Provider: "whatsapp",
Surface: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "+15550000002",
AccountId: "default",
});
});
it("does not transcribe group voice when policy gating rejects before mention", async () => {
applyGroupGatingMock.mockResolvedValueOnce({ shouldProcess: false });
const handler = makeHandler();
await handler(makeGroupAudioMsg());
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
expect(maybeSendAckReactionMock).not.toHaveBeenCalled();
expect(processMessageMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,450 @@
// Whatsapp plugin module implements on message behavior.
import type { AckReactionHandle } from "openclaw/plugin-sdk/channel-feedback";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
ensureConfiguredBindingRouteReady,
resolveConfiguredBindingRoute,
} from "openclaw/plugin-sdk/conversation-binding-runtime";
import type { getReplyFromConfig } from "openclaw/plugin-sdk/reply-runtime";
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { buildGroupHistoryKey } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveWhatsAppAccount } from "../../accounts.js";
import { resolveWhatsAppGroupSessionRoute } from "../../group-session-key.js";
import { getPrimaryIdentityId, getSenderIdentity } from "../../identity.js";
import {
requireAdmittedWhatsAppInboundMessage,
requireWhatsAppInboundAdmission,
} from "../../inbound/admission.js";
import {
normalizeWebInboundMessage,
withDeprecatedWebInboundMessageFlatAliases,
} from "../../inbound/message-aliases.js";
import type { AdmittedWebInboundMessage, WebInboundMessageInput } from "../../inbound/types.js";
import { normalizeE164 } from "../../text-runtime.js";
import { buildMentionConfig } from "../mentions.js";
import type { MentionConfig } from "../mentions.js";
import { maybeSendAckReaction } from "./ack-reaction.js";
import { maybeBroadcastMessage } from "./broadcast.js";
import type { EchoTracker } from "./echo.js";
import type { GroupHistoryEntry } from "./group-gating.js";
import { applyGroupGating } from "./group-gating.js";
import { updateLastRouteInBackground } from "./last-route.js";
import { resolvePeerId } from "./peer.js";
import { processMessage } from "./process-message.js";
import {
createWhatsAppStatusReactionController,
type StatusReactionController,
} from "./status-reaction.js";
export function createWebOnMessageHandler(params: {
cfg: OpenClawConfig;
loadConfig?: () => OpenClawConfig;
verbose: boolean;
connectionId: string;
maxMediaBytes: number;
groupHistoryLimit: number;
groupHistories: Map<string, GroupHistoryEntry[]>;
groupMemberNames: Map<string, Map<string, string>>;
echoTracker: EchoTracker;
backgroundTasks: Set<Promise<unknown>>;
replyResolver: typeof getReplyFromConfig;
replyLogger: ReturnType<(typeof import("openclaw/plugin-sdk/runtime-env"))["getChildLogger"]>;
baseMentionConfig: MentionConfig;
account: { authDir?: string; accountId?: string; selfChatMode?: boolean };
}) {
const hasExplicitlyPassedInboundAccess = (msg: WebInboundMessageInput): boolean =>
msg.admission ? msg.admission.ingress.decision === "allow" : msg.accessControlPassed === true;
const withDirectSenderPeer = (
msg: AdmittedWebInboundMessage,
peerId: string,
): AdmittedWebInboundMessage => {
const admission = requireWhatsAppInboundAdmission(msg);
if (
admission.conversation.kind === "group" ||
msg.platform.sender?.e164 ||
msg.platform.senderE164 ||
!peerId.startsWith("+")
) {
return msg;
}
const normalized = normalizeE164(peerId);
if (!normalized) {
return msg;
}
return requireAdmittedWhatsAppInboundMessage(
withDeprecatedWebInboundMessageFlatAliases({
...msg,
platform: {
...msg.platform,
sender: { ...msg.platform.sender, e164: normalized },
senderE164: normalized,
},
}),
);
};
const processForRoute = async (
cfg: OpenClawConfig,
msg: AdmittedWebInboundMessage,
route: ReturnType<typeof resolveAgentRoute>,
groupHistoryKey: string,
opts?: {
groupHistory?: GroupHistoryEntry[];
suppressGroupHistoryClear?: boolean;
preflightAudioTranscript?: string | null;
ackAlreadySent?: boolean;
ackReaction?: AckReactionHandle | null;
statusReactionController?: StatusReactionController | null;
},
) => {
const processParams: Parameters<typeof processMessage>[0] = {
cfg,
msg,
route,
groupHistoryKey,
groupHistories: params.groupHistories,
groupMemberNames: params.groupMemberNames,
connectionId: params.connectionId,
verbose: params.verbose,
maxMediaBytes: params.maxMediaBytes,
replyResolver: params.replyResolver,
replyLogger: params.replyLogger,
backgroundTasks: params.backgroundTasks,
rememberSentText: params.echoTracker.rememberText,
echoHas: params.echoTracker.has,
echoForget: params.echoTracker.forget,
buildCombinedEchoKey: params.echoTracker.buildCombinedKey,
};
if (opts?.groupHistory !== undefined) {
processParams.groupHistory = opts.groupHistory;
}
if (opts?.suppressGroupHistoryClear !== undefined) {
processParams.suppressGroupHistoryClear = opts.suppressGroupHistoryClear;
}
if (opts?.preflightAudioTranscript !== undefined) {
processParams.preflightAudioTranscript = opts.preflightAudioTranscript;
}
if (opts?.ackAlreadySent === true) {
processParams.ackAlreadySent = true;
}
if (opts?.ackReaction !== undefined) {
processParams.ackReaction = opts.ackReaction;
}
if (opts?.statusReactionController !== undefined) {
processParams.statusReactionController = opts.statusReactionController;
}
return processMessage(processParams);
};
return async (rawMsg: WebInboundMessageInput) => {
const canRunDirectEarlyAudioPreflight = hasExplicitlyPassedInboundAccess(rawMsg);
const normalizedMsg = requireAdmittedWhatsAppInboundMessage(normalizeWebInboundMessage(rawMsg));
const cfg = params.loadConfig?.() ?? params.cfg;
const peerId = resolvePeerId(normalizedMsg);
const msg = withDirectSenderPeer(normalizedMsg, peerId);
const admission = requireWhatsAppInboundAdmission(msg);
if (admission.ingress.admission !== "dispatch" && admission.ingress.admission !== "observe") {
return;
}
const conversationId = admission.conversation.id;
const conversationKind = admission.conversation.kind;
const baseRoute = resolveAgentRoute({
cfg,
channel: "whatsapp",
accountId: admission.accountId,
peer: {
kind: conversationKind,
id: peerId,
},
});
const baseConversationRoute =
conversationKind === "group" ? resolveWhatsAppGroupSessionRoute(baseRoute) : baseRoute;
const routeAccountId = baseConversationRoute.accountId ?? admission.accountId;
const account = resolveWhatsAppAccount({
cfg,
accountId: routeAccountId,
});
const baseMentionConfig = buildMentionConfig(cfg);
// Same-phone mode logging retained
if (conversationId === msg.platform.recipientJid) {
logVerbose(`📱 Same-phone mode detected (from === to: ${conversationId})`);
}
// Skip if this is a message we just sent (echo detection)
if (params.echoTracker.has(msg.payload.body)) {
logVerbose("Skipping auto-reply: detected echo (message matches recently sent text)");
params.echoTracker.forget(msg.payload.body);
return;
}
const configuredRoute = resolveConfiguredBindingRoute({
cfg,
route: baseConversationRoute,
channel: "whatsapp",
accountId: routeAccountId,
conversationId: peerId,
});
// Bound route facts intentionally feed group activation/mention policy.
// Side-effectful ACP readiness still waits until the group turn is admitted.
const route = configuredRoute.route;
const groupHistoryKey =
conversationKind === "group"
? buildGroupHistoryKey({
channel: "whatsapp",
accountId: route.accountId,
peerKind: "group",
peerId,
})
: route.sessionKey;
// Preflight audio transcription: run once before broadcast fan-out so all
// agents share the same transcript instead of each making a separate STT call.
// For DMs, only do this on the real inbound path after access-control/pairing
// checks have already passed in inbound/monitor.ts. For groups, the first
// gating pass must approve the group/sender before STT is attempted.
// null = preflight was attempted but produced no transcript (failed / disabled / no audio);
// undefined = preflight was not attempted (non-audio message).
let preflightAudioTranscript: string | null | undefined;
const hasAudioBody =
msg.payload.media?.type?.startsWith("audio/") === true &&
msg.payload.body === "<media:audio>";
const canRunEarlyAudioPreflight =
conversationKind === "group" || canRunDirectEarlyAudioPreflight;
let ackAlreadySent = false;
let ackReaction: AckReactionHandle | null = null;
let statusReactionController: StatusReactionController | null = null;
let recordAcceptedConfiguredGroupRoute: (() => void) | null = null;
const clearPreDispatchReaction = async () => {
try {
if (statusReactionController) {
const controller = statusReactionController;
statusReactionController = null;
controller.cancelPending();
await controller.clear();
return;
}
if (ackReaction && (await ackReaction.ackReactionPromise)) {
await ackReaction.remove();
}
} catch (err) {
params.replyLogger.warn(
{ error: String(err) },
"whatsapp: failed to clear pre-dispatch reaction after pre-dispatch rejection",
);
}
};
const transcribeAudioOnce = async () => {
if (preflightAudioTranscript !== undefined || !hasAudioBody || !msg.payload.media?.path) {
return;
}
try {
const { transcribeFirstAudio } = await import("./audio-preflight.runtime.js");
// transcribeFirstAudio returns undefined on failure/disabled; store null so
// processMessage knows the attempt was already made and does not retry.
preflightAudioTranscript =
(await transcribeFirstAudio({
ctx: {
MediaPaths: [msg.payload.media?.path],
MediaTypes: msg.payload.media?.type ? [msg.payload.media?.type] : undefined,
From: conversationId,
To: msg.platform.recipientJid,
Provider: "whatsapp",
Surface: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: conversationId,
AccountId: route.accountId,
},
cfg,
})) ?? null;
} catch {
// Non-fatal: store null so per-agent retries are suppressed.
preflightAudioTranscript = null;
}
};
const runAudioPreflightOnce = async () => {
if (
preflightAudioTranscript !== undefined ||
!canRunEarlyAudioPreflight ||
!hasAudioBody ||
!msg.payload.media?.path
) {
return;
}
if (cfg.messages?.statusReactions?.enabled === true) {
statusReactionController = await createWhatsAppStatusReactionController({
cfg,
msg,
agentId: route.agentId,
sessionKey: route.sessionKey,
verbose: params.verbose,
});
if (statusReactionController) {
await statusReactionController.setQueued();
}
} else {
ackReaction = await maybeSendAckReaction({
cfg,
msg,
agentId: route.agentId,
sessionKey: route.sessionKey,
verbose: params.verbose,
info: params.replyLogger.info.bind(params.replyLogger),
warn: params.replyLogger.warn.bind(params.replyLogger),
});
ackAlreadySent = ackReaction !== null;
}
await transcribeAudioOnce();
};
if (conversationKind === "group") {
const sender = getSenderIdentity(msg);
const metaCtx = {
From: conversationId,
To: msg.platform.recipientJid,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: conversationKind,
ConversationLabel: conversationId,
GroupSubject: msg.group?.subject,
SenderName: sender.name ?? undefined,
SenderId: getPrimaryIdentityId(sender) ?? undefined,
SenderE164: sender.e164 ?? undefined,
Provider: "whatsapp",
Surface: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: conversationId,
} satisfies MsgContext;
const recordGroupRoute = () =>
updateLastRouteInBackground({
cfg,
backgroundTasks: params.backgroundTasks,
storeAgentId: route.agentId,
sessionKey: route.sessionKey,
channel: "whatsapp",
to: conversationId,
accountId: route.accountId,
ctx: metaCtx,
warn: params.replyLogger.warn.bind(params.replyLogger),
});
// Last-route state is a dispatch side effect. Group gating must admit the
// message first; configured ACP routes also wait for backend readiness.
recordAcceptedConfiguredGroupRoute = recordGroupRoute;
let gating = await applyGroupGating({
cfg,
msg,
deferMissingMention: hasAudioBody && Boolean(msg.payload.media?.path),
groupHistoryKey,
agentId: route.agentId,
sessionKey: route.sessionKey,
baseMentionConfig,
providerMentionPatterns: account.mentionPatterns,
authDir: account.authDir,
selfChatMode: account.selfChatMode,
groupHistories: params.groupHistories,
groupHistoryLimit: params.groupHistoryLimit,
groupMemberNames: params.groupMemberNames,
logVerbose,
replyLogger: params.replyLogger,
});
if (
!gating.shouldProcess &&
"needsMentionText" in gating &&
gating.needsMentionText === true
) {
await runAudioPreflightOnce();
gating = await applyGroupGating({
cfg,
msg,
...(typeof preflightAudioTranscript === "string"
? { mentionText: preflightAudioTranscript }
: {}),
groupHistoryKey,
agentId: route.agentId,
sessionKey: route.sessionKey,
baseMentionConfig,
providerMentionPatterns: account.mentionPatterns,
authDir: account.authDir,
selfChatMode: account.selfChatMode,
groupHistories: params.groupHistories,
groupHistoryLimit: params.groupHistoryLimit,
groupMemberNames: params.groupMemberNames,
logVerbose,
replyLogger: params.replyLogger,
});
}
if (!gating.shouldProcess) {
await clearPreDispatchReaction();
return;
}
}
if (configuredRoute.bindingResolution) {
const ensured = await ensureConfiguredBindingRouteReady({
cfg,
bindingResolution: configuredRoute.bindingResolution,
});
if (!ensured.ok) {
params.replyLogger.warn(
`whatsapp: configured ACP binding unavailable for conversation ${configuredRoute.bindingResolution.record.conversation.conversationId}: ${ensured.error}`,
);
await clearPreDispatchReaction();
return;
}
}
if (recordAcceptedConfiguredGroupRoute && !configuredRoute.bindingResolution) {
recordAcceptedConfiguredGroupRoute();
recordAcceptedConfiguredGroupRoute = null;
}
await runAudioPreflightOnce();
const hasBroadcastTargets =
!configuredRoute.bindingResolution &&
Array.isArray(cfg.broadcast?.[peerId]) &&
cfg.broadcast[peerId].length > 0;
if (hasBroadcastTargets && statusReactionController) {
await clearPreDispatchReaction();
}
if (hasBroadcastTargets && !canRunEarlyAudioPreflight) {
await transcribeAudioOnce();
}
if (
!configuredRoute.bindingResolution &&
(await maybeBroadcastMessage({
cfg,
msg,
peerId,
route,
groupHistoryKey,
groupHistories: params.groupHistories,
...(preflightAudioTranscript !== undefined ? { preflightAudioTranscript } : {}),
// Group ack eligibility depends on the target agent/session, so a
// preflight ack attempt on the base route must not suppress downstream
// per-agent checks during broadcast fan-out.
...(ackAlreadySent && conversationKind !== "group" ? { ackAlreadySent: true } : {}),
...(ackReaction && conversationKind !== "group" ? { ackReaction } : {}),
...(statusReactionController && conversationKind !== "group"
? { ackAlreadySent: true }
: {}),
processMessage: (m, r, k, opts) => processForRoute(cfg, m, r, k, opts),
}))
) {
return;
}
recordAcceptedConfiguredGroupRoute?.();
await processForRoute(cfg, msg, route, groupHistoryKey, {
...(preflightAudioTranscript !== undefined ? { preflightAudioTranscript } : {}),
...(ackAlreadySent ? { ackAlreadySent: true } : {}),
...(ackReaction ? { ackReaction } : {}),
...(statusReactionController ? { statusReactionController } : {}),
});
};
}

View File

@@ -0,0 +1,21 @@
// Whatsapp plugin module implements peer behavior.
import { getSenderIdentity } from "../../identity.js";
import { requireWhatsAppInboundAdmission } from "../../inbound/admission.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import { jidToE164, normalizeE164 } from "../../text-runtime.js";
export function resolvePeerId(msg: AdmittedWebInboundMessage) {
const admission = requireWhatsAppInboundAdmission(msg);
if (admission.conversation.kind === "group") {
return admission.conversation.id;
}
const sender = getSenderIdentity(msg);
if (sender.e164) {
return normalizeE164(sender.e164) ?? sender.e164;
}
const conversationId = admission.conversation.id;
if (conversationId.includes("@")) {
return jidToE164(conversationId) ?? conversationId;
}
return normalizeE164(conversationId) ?? conversationId;
}

View File

@@ -0,0 +1,458 @@
// Whatsapp tests cover process message.audio preflight plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createTestWebAudioInboundMessage } from "../../inbound/test-message.test-helper.js";
// Mock the lazy-loaded audio preflight runtime boundary
const transcribeFirstAudioMock = vi.fn();
const maybeSendAckReactionMock = vi.fn();
vi.mock("./audio-preflight.runtime.js", () => ({
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
}));
// Controllable shouldComputeCommandAuthorized for command-sync tests
let shouldComputeCommandResult = false;
let shouldComputeCommandBodies: string[] = [];
// Minimal mocks for process-message dependencies
vi.mock("../../accounts.js", () => ({
resolveWhatsAppAccount: () => ({
accountId: "default",
dmPolicy: "pairing",
groupPolicy: "allowlist",
allowFrom: [],
}),
}));
vi.mock("../../identity.js", () => ({
getPrimaryIdentityId: () => undefined,
getSelfIdentity: () => ({ e164: "+15550000001" }),
getSenderIdentity: () => ({ e164: "+15550000002", name: "Alice" }),
}));
vi.mock("../../reconnect.js", () => ({
newConnectionId: () => "test-conn-id",
}));
vi.mock("../../session.js", () => ({
formatError: (err: unknown) => String(err),
}));
vi.mock("../deliver-reply.js", () => ({
deliverWebReply: vi.fn(async () => {}),
}));
vi.mock("../loggers.js", () => ({
whatsappInboundLog: { info: () => {}, debug: () => {} },
}));
vi.mock("./ack-reaction.js", () => ({
maybeSendAckReaction: (...args: unknown[]) => maybeSendAckReactionMock(...args),
}));
vi.mock("./inbound-context.js", () => ({
resolveVisibleWhatsAppGroupHistory: () => [],
resolveVisibleWhatsAppReplyContext: () => null,
}));
vi.mock("./last-route.js", () => ({
trackBackgroundTask: () => {},
updateLastRouteInBackground: () => {},
}));
vi.mock("./message-line.js", () => ({
buildInboundLine: (params: { msg: WebInboundMsg }) => params.msg.payload.body,
}));
vi.mock("./runtime-api.js", () => ({
buildHistoryContextFromEntries: (_p: { currentMessage: string }) => _p.currentMessage,
createChannelMessageReplyPipeline: () => ({ onModelSelected: undefined }),
formatInboundEnvelope: (p: { body: string }) => p.body,
isControlCommandMessage: () => false,
logVerbose: () => {},
normalizeE164: (v: string) => v,
readStoreAllowFromForDmPolicy: async () => [],
recordSessionMetaFromInbound: async () => {},
resolveChannelContextVisibilityMode: () => "standard",
resolveInboundSessionEnvelopeContext: () => ({
storePath: "/tmp/sessions.json",
envelopeOptions: {},
previousTimestamp: undefined,
}),
resolvePinnedMainDmOwnerFromAllowlist: () => null,
resolveDmGroupAccessWithCommandGate: () => ({ commandAuthorized: true }),
shouldComputeCommandAuthorized: (body: string) => {
shouldComputeCommandBodies.push(body);
return shouldComputeCommandResult || body.startsWith("/");
},
shouldLogVerbose: () => false,
type: undefined,
}));
vi.mock("./inbound-dispatch.js", () => ({
buildWhatsAppInboundContext: (params: {
bodyForAgent?: string;
combinedBody: string;
commandAuthorized?: boolean;
commandBody?: string;
msg: WebInboundMsg;
mediaTranscribedIndexes?: number[];
rawBody?: string;
transcript?: string;
}) => ({
Body: params.combinedBody,
BodyForAgent: params.bodyForAgent ?? params.msg.payload.body,
CommandAuthorized: params.commandAuthorized,
CommandBody: params.commandBody ?? params.msg.payload.body,
MediaPath: params.msg.payload.media?.path,
MediaType: params.msg.payload.media?.type,
MediaTranscribedIndexes: params.mediaTranscribedIndexes,
RawBody: params.rawBody ?? params.msg.payload.body,
Transcript: params.transcript,
}),
dispatchWhatsAppBufferedReply: vi.fn(async () => true),
resolveWhatsAppDmRouteTarget: () => "+15550000002",
resolveWhatsAppResponsePrefix: () => undefined,
updateWhatsAppMainLastRoute: () => {},
}));
import { dispatchWhatsAppBufferedReply } from "./inbound-dispatch.js";
import { processMessage } from "./process-message.js";
type WebInboundMsg = Parameters<typeof processMessage>[0]["msg"];
type TestRoute = Parameters<typeof processMessage>[0]["route"];
const flushMicrotasks = async () => {
await Promise.resolve();
await Promise.resolve();
};
type AudioMessageOverrides = Partial<WebInboundMsg> & {
body?: string;
mediaPath?: string;
mediaType?: string;
};
function makeAudioMsg(overrides: AudioMessageOverrides = {}): WebInboundMsg {
const { body, mediaPath, mediaType, event, payload, platform, ...messageOverrides } = overrides;
const resolvedMediaPath = Object.hasOwn(overrides, "mediaPath") ? mediaPath : "/tmp/voice.ogg";
const resolvedMediaType = Object.hasOwn(overrides, "mediaType")
? mediaType
: "audio/ogg; codecs=opus";
return createTestWebAudioInboundMessage({
event,
payload: {
body: body ?? "<media:audio>",
media: {
type: resolvedMediaType,
path: resolvedMediaPath,
...payload?.media,
},
...payload,
},
platform,
...messageOverrides,
}) as WebInboundMsg;
}
function makeRoute(overrides: Partial<TestRoute> = {}): TestRoute {
return {
agentId: "main",
sessionKey: "agent:main:main",
mainSessionKey: "agent:main:main",
accountId: "default",
...overrides,
} as TestRoute;
}
function makeParams(msgOverrides: AudioMessageOverrides = {}) {
return {
cfg: {
tools: { media: { audio: { enabled: true } } },
channels: { whatsapp: {} },
commands: { useAccessGroups: false },
} as never,
msg: makeAudioMsg(msgOverrides),
route: makeRoute(),
groupHistoryKey: "whatsapp:default:+15550000002",
groupHistories: new Map(),
groupMemberNames: new Map(),
connectionId: "conn-1",
verbose: false,
maxMediaBytes: 1024 * 1024,
replyResolver: vi.fn() as never,
replyLogger: {
info: () => {},
warn: () => {},
debug: () => {},
error: () => {},
} as never,
backgroundTasks: new Set<Promise<unknown>>(),
rememberSentText: () => {},
echoHas: () => false,
echoForget: () => {},
buildCombinedEchoKey: (p: { combinedBody: string }) => p.combinedBody,
};
}
function makeAckReactionHandle() {
return {
ackReactionPromise: Promise.resolve(true),
ackReactionValue: "👀",
remove: vi.fn(async () => undefined),
};
}
function makeRemoveAckAfterReplyParams() {
return {
...makeParams(),
cfg: {
tools: { media: { audio: { enabled: true } } },
channels: { whatsapp: {} },
commands: { useAccessGroups: false },
messages: { removeAckAfterReply: true },
} as never,
preflightAudioTranscript: "pre-computed transcript from caller",
};
}
function firstTranscriptionContext(): Record<string, unknown> {
const call = transcribeFirstAudioMock.mock.calls[0]?.[0] as
| { ctx?: Record<string, unknown> }
| undefined;
if (!call?.ctx) {
throw new Error("expected transcribeFirstAudio ctx");
}
return call.ctx;
}
function firstDispatchContext(): Record<string, unknown> {
const calls = vi.mocked(dispatchWhatsAppBufferedReply).mock.calls as unknown[][];
const dispatch = calls[0]?.[0] as { context?: Record<string, unknown> } | undefined;
if (!dispatch?.context) {
throw new Error("expected WhatsApp dispatch context");
}
return dispatch.context;
}
function expectContextFields(context: Record<string, unknown>, fields: Record<string, unknown>) {
for (const [key, value] of Object.entries(fields)) {
expect(context[key]).toEqual(value);
}
}
describe("processMessage audio preflight transcription", () => {
beforeEach(() => {
transcribeFirstAudioMock.mockReset();
maybeSendAckReactionMock.mockReset();
maybeSendAckReactionMock.mockResolvedValue(null);
shouldComputeCommandResult = false;
shouldComputeCommandBodies = [];
vi.mocked(dispatchWhatsAppBufferedReply).mockClear();
});
it("replaces <media:audio> body with transcript when transcription succeeds", async () => {
transcribeFirstAudioMock.mockResolvedValueOnce("okay let's test this voice message");
await processMessage(makeParams());
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
expectContextFields(firstTranscriptionContext(), {
AccountId: "default",
From: "+15550000002",
MediaPaths: ["/tmp/voice.ogg"],
MediaTypes: ["audio/ogg; codecs=opus"],
OriginatingChannel: "whatsapp",
OriginatingTo: "+15550000002",
Provider: "whatsapp",
Surface: "whatsapp",
To: "+15550000001",
});
const context = firstDispatchContext();
expectContextFields(context, {
Body: "okay let's test this voice message",
BodyForAgent: "okay let's test this voice message",
CommandBody: "<media:audio>",
RawBody: "<media:audio>",
Transcript: "okay let's test this voice message",
MediaTranscribedIndexes: [0],
});
// mediaPath and mediaType must be preserved so inboundAudio detection (used by
// features like messages.tts.auto: "inbound") still recognises this as audio.
expectContextFields(context, {
MediaPath: "/tmp/voice.ogg",
MediaType: "audio/ogg; codecs=opus",
});
});
it("falls back to <media:audio> placeholder when transcription fails", async () => {
transcribeFirstAudioMock.mockRejectedValueOnce(new Error("provider unavailable"));
await processMessage(makeParams());
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
expectContextFields(firstDispatchContext(), {
Body: "<media:audio>",
BodyForAgent: "<media:audio>",
});
});
it("falls back to <media:audio> placeholder when transcription returns undefined", async () => {
transcribeFirstAudioMock.mockResolvedValueOnce(undefined);
await processMessage(makeParams());
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
expectContextFields(firstDispatchContext(), {
Body: "<media:audio>",
BodyForAgent: "<media:audio>",
});
});
it("does not call transcribeFirstAudio when mediaType is not audio", async () => {
await processMessage(
makeParams({ body: "<media:image>", mediaType: "image/jpeg", mediaPath: "/tmp/img.jpg" }),
);
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
});
it("does not call transcribeFirstAudio when body is not <media:audio>", async () => {
await processMessage(makeParams({ body: "hello there", mediaType: "audio/ogg; codecs=opus" }));
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
});
it("does not call transcribeFirstAudio when mediaPath is absent", async () => {
await processMessage(makeParams({ mediaPath: undefined }));
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
});
it("does not call transcribeFirstAudio when msg.mediaType is absent", async () => {
await processMessage(
makeParams({ mediaType: undefined, body: "<media:audio>", mediaPath: "/tmp/voice.ogg" }),
);
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
// Body passes through as-is without a mediaType to confirm audio
expectContextFields(firstDispatchContext(), {
Body: "<media:audio>",
});
});
it("does not use transcript body for command detection", async () => {
transcribeFirstAudioMock.mockResolvedValueOnce("/new start a new session");
await processMessage(makeParams());
expect(shouldComputeCommandBodies).toEqual(["<media:audio>"]);
expectContextFields(firstDispatchContext(), {
Body: "/new start a new session",
BodyForAgent: "/new start a new session",
CommandBody: "<media:audio>",
RawBody: "<media:audio>",
Transcript: "/new start a new session",
MediaTranscribedIndexes: [0],
});
});
it("uses preflightAudioTranscript when provided, skipping transcribeFirstAudio", async () => {
// Simulate broadcast fan-out: caller pre-computed the transcript and passes it in.
// transcribeFirstAudio must NOT be called again inside processMessage.
await processMessage({
...makeParams(),
preflightAudioTranscript: "pre-computed transcript from fan-out caller",
});
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
expectContextFields(firstDispatchContext(), {
Body: "pre-computed transcript from fan-out caller",
BodyForAgent: "pre-computed transcript from fan-out caller",
CommandBody: "<media:audio>",
RawBody: "<media:audio>",
Transcript: "pre-computed transcript from fan-out caller",
MediaTranscribedIndexes: [0],
});
});
it("does not send a duplicate ack when caller already sent it", async () => {
await processMessage({
...makeParams(),
preflightAudioTranscript: "pre-computed transcript from caller",
ackAlreadySent: true,
ackReaction: makeAckReactionHandle(),
});
expect(maybeSendAckReactionMock).not.toHaveBeenCalled();
});
it("removes caller-provided ack after a successful visible reply", async () => {
const ackReaction = makeAckReactionHandle();
await processMessage({
...makeRemoveAckAfterReplyParams(),
ackReaction,
});
await flushMicrotasks();
expect(ackReaction.remove).toHaveBeenCalledTimes(1);
});
it("removes internally sent ack after a successful visible reply", async () => {
const ackReaction = makeAckReactionHandle();
maybeSendAckReactionMock.mockResolvedValueOnce(ackReaction);
await processMessage(makeRemoveAckAfterReplyParams());
await flushMicrotasks();
expect(maybeSendAckReactionMock).toHaveBeenCalledTimes(1);
expect(ackReaction.remove).toHaveBeenCalledTimes(1);
});
it("keeps ack when no visible reply was delivered", async () => {
const ackReaction = makeAckReactionHandle();
maybeSendAckReactionMock.mockResolvedValueOnce(ackReaction);
vi.mocked(dispatchWhatsAppBufferedReply).mockResolvedValueOnce(false);
await processMessage(makeRemoveAckAfterReplyParams());
await flushMicrotasks();
expect(ackReaction.remove).not.toHaveBeenCalled();
});
it("keeps ack when the ack send failed", async () => {
const ackReaction = {
...makeAckReactionHandle(),
ackReactionPromise: Promise.resolve(false),
};
maybeSendAckReactionMock.mockResolvedValueOnce(ackReaction);
await processMessage(makeRemoveAckAfterReplyParams());
await flushMicrotasks();
expect(ackReaction.remove).not.toHaveBeenCalled();
});
it("skips internal STT when preflightAudioTranscript is null (failed preflight sentinel)", async () => {
// null = caller already attempted preflight but got nothing (provider unavailable,
// disabled, etc.). processMessage must NOT retry to avoid 1+N attempts in broadcast.
await processMessage({
...makeParams(),
preflightAudioTranscript: null,
});
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
// Body falls back to the original <media:audio> placeholder, not retried transcript.
expectContextFields(firstDispatchContext(), {
Body: "<media:audio>",
});
});
});

View File

@@ -0,0 +1,591 @@
// Whatsapp tests cover process message plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createAcceptedWhatsAppSendResult } from "../../inbound/send-result.test-helper.js";
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
// Hoisted mocks used across tests so vi.mock factories can reference them.
const {
resolvePolicyMock,
buildContextMock,
isControlCommandMessageMock,
dispatchBufferedReplyMock,
runMessageReceivedMock,
shouldComputeCommandAuthorizedMock,
trackBackgroundTaskMock,
} = vi.hoisted(() => ({
resolvePolicyMock: vi.fn(),
buildContextMock: vi.fn(),
isControlCommandMessageMock: vi.fn(() => false),
dispatchBufferedReplyMock: vi.fn(async () => ({
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
})),
runMessageReceivedMock: vi.fn(async () => undefined),
shouldComputeCommandAuthorizedMock: vi.fn(() => false),
trackBackgroundTaskMock: vi.fn(),
}));
vi.mock("../../inbound-policy.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../inbound-policy.js")>();
return {
...actual,
resolveWhatsAppCommandAuthorized: async () => true,
resolveWhatsAppInboundPolicy: resolvePolicyMock,
};
});
vi.mock("./inbound-dispatch.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./inbound-dispatch.js")>();
return {
...actual,
buildWhatsAppInboundContext: buildContextMock,
dispatchWhatsAppBufferedReply: dispatchBufferedReplyMock,
resolveWhatsAppDmRouteTarget: () => null,
resolveWhatsAppResponsePrefix: () => undefined,
updateWhatsAppMainLastRoute: () => {},
};
});
vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({
getGlobalHookRunner: () => ({
hasHooks: (hookName: string) => hookName === "message_received",
runMessageReceived: runMessageReceivedMock,
}),
}));
vi.mock("../../identity.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../identity.js")>();
return {
...actual,
getPrimaryIdentityId: () => null,
getSelfIdentity: () => ({ e164: "+15550001111" }),
getSenderIdentity: () => ({ name: "Alice", e164: "+15550002222" }),
};
});
vi.mock("../../reconnect.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../reconnect.js")>();
return { ...actual, newConnectionId: () => "test-conn-id" };
});
vi.mock("../../session.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../session.js")>();
return { ...actual, formatError: (e: unknown) => String(e) };
});
vi.mock("../deliver-reply.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../deliver-reply.js")>();
return { ...actual, deliverWebReply: async () => {} };
});
vi.mock("../loggers.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../loggers.js")>();
return {
...actual,
whatsappInboundLog: { info: () => {}, debug: () => {} },
};
});
vi.mock("./ack-reaction.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./ack-reaction.js")>();
return { ...actual, maybeSendAckReaction: async () => {} };
});
vi.mock("./inbound-context.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./inbound-context.js")>();
return {
...actual,
resolveVisibleWhatsAppGroupHistory: (params: { history: unknown[] }) => params.history,
resolveVisibleWhatsAppReplyContext: () => null,
};
});
vi.mock("./last-route.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./last-route.js")>();
return {
...actual,
trackBackgroundTask: trackBackgroundTaskMock,
updateLastRouteInBackground: () => {},
};
});
vi.mock("./message-line.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./message-line.js")>();
return { ...actual, buildInboundLine: () => "hi" };
});
vi.mock("./runtime-api.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./runtime-api.js")>();
return {
...actual,
buildHistoryContextFromEntries: () => "hi",
createChannelMessageReplyPipeline: () => ({
onModelSelected: () => {},
responsePrefix: undefined,
}),
formatInboundEnvelope: () => "hi",
logVerbose: () => {},
normalizeE164: (v: string) => v,
recordSessionMetaFromInbound: async () => {},
resolveChannelContextVisibilityMode: () => "off",
resolveInboundSessionEnvelopeContext: () => ({
storePath: "/tmp",
envelopeOptions: {},
previousTimestamp: undefined,
}),
resolvePinnedMainDmOwnerFromAllowlist: () => null,
isControlCommandMessage: isControlCommandMessageMock,
shouldComputeCommandAuthorized: shouldComputeCommandAuthorizedMock,
shouldLogVerbose: () => false,
};
});
import { clearInternalHooks, registerInternalHook } from "openclaw/plugin-sdk/hook-runtime";
import { processMessage } from "./process-message.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeAccount(groups: Record<string, { systemPrompt?: string }> = {}): {
accountId: string;
authDir: string;
groups: Record<string, { systemPrompt?: string }>;
} {
return { accountId: "default", authDir: "/tmp/wa-test-auth", groups };
}
function makePolicy(account: ReturnType<typeof makeAccount>) {
return {
account,
dmPolicy: "pairing",
groupPolicy: "allowlist",
configuredAllowFrom: [],
dmAllowFrom: [],
groupAllowFrom: [],
isSelfChat: false,
providerMissingFallbackApplied: false,
isSamePhone: () => false,
resolveConversationGroupPolicy: () => "allowlist",
resolveConversationRequireMention: () => false,
};
}
const GROUP_JID = "123@g.us";
function makeBaseMsg(overrides: { body?: string; commandBody?: string } = {}) {
const body = overrides.body ?? "hi";
return createTestWebInboundMessage({
event: {
id: "msg1",
timestamp: 1710000000,
},
payload: {
body,
commandBody: overrides.commandBody,
},
platform: {
chatJid: GROUP_JID,
recipientJid: "+15550001111",
senderJid: "15550002222@s.whatsapp.net",
senderE164: "+15550002222",
senderName: "Alice",
sendComposing: async () => {},
reply: async () => createAcceptedWhatsAppSendResult("text", "r1"),
sendMedia: async () => createAcceptedWhatsAppSendResult("media", "m1"),
},
admission: {
accountId: "default",
conversation: {
kind: "group",
id: GROUP_JID,
},
sender: {
id: "+15550002222",
},
senderAccess: {
reasonCode: "group_policy_allowed",
},
},
group: {
subject: "Test Group",
},
});
}
const baseRoute = {
agentId: "main",
channel: "whatsapp",
accountId: "default",
sessionKey: "agent:main:whatsapp:group:123@g.us",
mainSessionKey: "agent:main:whatsapp:group:123@g.us",
lastRoutePolicy: "main",
matchedBy: "default",
};
function callProcessMessage(
overrides: {
cfg?: unknown;
groupHistories?: Map<string, unknown[]>;
msg?: unknown;
} = {},
) {
return processMessage({
cfg: (overrides.cfg ?? {}) as never,
msg: (overrides.msg ?? makeBaseMsg()) as never,
route: baseRoute as never,
groupHistoryKey: "whatsapp:default:group:123@g.us",
groupHistories: (overrides.groupHistories ?? new Map()) as never,
groupMemberNames: new Map(),
connectionId: "conn-1",
verbose: false,
maxMediaBytes: 1024,
replyResolver: (async () => undefined) as never,
replyLogger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as never,
backgroundTasks: new Set(),
rememberSentText: () => {},
echoHas: () => false,
echoForget: () => {},
buildCombinedEchoKey: ({ sessionKey }) => sessionKey,
});
}
function mockCallArg(mockFn: ReturnType<typeof vi.fn>, label: string, callIndex = 0, argIndex = 0) {
const call = mockFn.mock.calls.at(callIndex);
if (!call) {
throw new Error(`Expected ${label} call ${callIndex}`);
}
if (!(argIndex in call)) {
throw new Error(`Expected ${label} call ${callIndex} argument ${argIndex}`);
}
return call[argIndex];
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("processMessage group system prompt wiring", () => {
beforeEach(() => {
buildContextMock.mockReset();
dispatchBufferedReplyMock.mockClear();
isControlCommandMessageMock.mockReset();
isControlCommandMessageMock.mockReturnValue(false);
resolvePolicyMock.mockReset();
runMessageReceivedMock.mockClear();
shouldComputeCommandAuthorizedMock.mockReset();
shouldComputeCommandAuthorizedMock.mockReturnValue(false);
trackBackgroundTaskMock.mockClear();
clearInternalHooks();
buildContextMock.mockImplementation(
(params: { groupSystemPrompt?: string; combinedBody?: string }) => ({
GroupSystemPrompt: params.groupSystemPrompt,
Body: params.combinedBody ?? "",
}),
);
});
afterEach(() => {
clearInternalHooks();
});
it("resolves group systemPrompt from account config and passes it into buildWhatsAppInboundContext", async () => {
resolvePolicyMock.mockReturnValue(
makePolicy(makeAccount({ [GROUP_JID]: { systemPrompt: "from config" } })),
);
await callProcessMessage();
expect(
(
mockCallArg(buildContextMock, "buildWhatsAppInboundContext") as {
groupSystemPrompt?: string;
}
).groupSystemPrompt,
).toBe("from config");
});
it("marks detected WhatsApp slash messages as text command turns", async () => {
resolvePolicyMock.mockReturnValue(makePolicy(makeAccount()));
isControlCommandMessageMock.mockReturnValue(true);
shouldComputeCommandAuthorizedMock.mockReturnValue(true);
await callProcessMessage({
msg: makeBaseMsg({ body: "/status" }),
});
expect(shouldComputeCommandAuthorizedMock).toHaveBeenCalledWith("/status", {});
expect(isControlCommandMessageMock).toHaveBeenCalledWith("/status", {});
expect(buildContextMock.mock.calls[0][0]).toMatchObject({
commandBody: "/status",
commandAuthorized: true,
commandTurn: {
kind: "text-slash",
source: "text",
authorized: true,
body: "/status",
},
rawBody: "/status",
});
});
it("keeps generated media notices out of command input", async () => {
resolvePolicyMock.mockReturnValue(makePolicy(makeAccount()));
isControlCommandMessageMock.mockReturnValue(true);
shouldComputeCommandAuthorizedMock.mockReturnValue(true);
await callProcessMessage({
msg: makeBaseMsg({
body: "/reset\n\n[whatsapp attachment unavailable]",
commandBody: "/reset",
}),
});
expect(shouldComputeCommandAuthorizedMock).toHaveBeenCalledWith("/reset", {});
expect(isControlCommandMessageMock).toHaveBeenCalledWith("/reset", {});
expect(buildContextMock.mock.calls[0][0]).toMatchObject({
bodyForAgent: "/reset\n\n[whatsapp attachment unavailable]",
commandBody: "/reset",
rawBody: "/reset",
});
});
it("checks auth for inline command tokens without marking them as command-source turns", async () => {
resolvePolicyMock.mockReturnValue(makePolicy(makeAccount()));
isControlCommandMessageMock.mockReturnValue(false);
shouldComputeCommandAuthorizedMock.mockReturnValue(true);
await callProcessMessage({
msg: makeBaseMsg({ body: "please inspect `/tmp/foo`" }),
});
expect(buildContextMock.mock.calls[0][0]).toMatchObject({
commandBody: "please inspect `/tmp/foo`",
commandAuthorized: true,
commandTurn: {
kind: "normal",
source: "message",
authorized: false,
body: "please inspect `/tmp/foo`",
},
rawBody: "please inspect `/tmp/foo`",
});
expect(buildContextMock.mock.calls[0][0].commandSource).toBeUndefined();
});
it("passes pending group history from the history window into inbound context", async () => {
resolvePolicyMock.mockReturnValue(makePolicy(makeAccount()));
const groupHistories = new Map<string, unknown[]>([
[
"whatsapp:default:group:123@g.us",
[
{
sender: "Alice (+15550002222)",
body: "quiet pending context",
timestamp: 1710000000,
id: "quiet-msg-1",
senderJid: "15550002222@s.whatsapp.net",
},
],
],
]);
await callProcessMessage({ groupHistories });
expect(buildContextMock.mock.calls[0][0]).toMatchObject({
groupHistory: [
{
sender: "Alice (+15550002222)",
body: "quiet pending context",
timestamp: 1710000000,
id: "quiet-msg-1",
senderJid: "15550002222@s.whatsapp.net",
},
],
});
});
it("fires message_received hooks with canonical WhatsApp correlation fields", async () => {
const internalReceived = vi.fn();
registerInternalHook("message:received", internalReceived);
resolvePolicyMock.mockReturnValue(makePolicy(makeAccount()));
buildContextMock.mockImplementationOnce(() => ({
Body: "hi",
BodyForCommands: "hi",
RawBody: "hi",
CommandBody: "hi",
From: GROUP_JID,
To: "+15550001111",
SessionKey: baseRoute.sessionKey,
AccountId: "default",
MessageSid: "msg1",
SenderId: "+15550002222",
SenderName: "Alice",
SenderE164: "+15550002222",
Timestamp: 1710000000,
Provider: "whatsapp",
Surface: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: GROUP_JID,
GroupSubject: "Test Group",
}));
await callProcessMessage({
cfg: {
channels: {
whatsapp: {
pluginHooks: {
messageReceived: true,
},
},
},
},
});
await Promise.resolve();
await Promise.resolve();
expect(runMessageReceivedMock).toHaveBeenCalledTimes(1);
expect(runMessageReceivedMock).toHaveBeenCalledWith(
{
from: GROUP_JID,
content: "hi",
timestamp: 1710000000,
threadId: undefined,
messageId: "msg1",
senderId: "+15550002222",
sessionKey: baseRoute.sessionKey,
runId: undefined,
metadata: {
to: "+15550001111",
provider: "whatsapp",
surface: "whatsapp",
threadId: undefined,
originatingChannel: "whatsapp",
originatingTo: GROUP_JID,
messageId: "msg1",
senderId: "+15550002222",
senderName: "Alice",
senderUsername: undefined,
senderE164: "+15550002222",
guildId: undefined,
channelName: undefined,
topicName: undefined,
},
},
{
channelId: "whatsapp",
accountId: "default",
conversationId: GROUP_JID,
sessionKey: baseRoute.sessionKey,
messageId: "msg1",
senderId: "+15550002222",
},
);
expect(internalReceived).toHaveBeenCalledTimes(1);
const internalEvent = mockCallArg(internalReceived, "internal message received") as Record<
string,
unknown
>;
expect(internalEvent.timestamp).toBeInstanceOf(Date);
expect({ ...internalEvent, timestamp: undefined }).toEqual({
type: "message",
action: "received",
sessionKey: baseRoute.sessionKey,
context: {
from: GROUP_JID,
content: "hi",
timestamp: 1710000000,
channelId: "whatsapp",
accountId: "default",
conversationId: GROUP_JID,
messageId: "msg1",
metadata: {
to: "+15550001111",
provider: "whatsapp",
surface: "whatsapp",
threadId: undefined,
senderId: "+15550002222",
senderName: "Alice",
senderUsername: undefined,
senderE164: "+15550002222",
guildId: undefined,
channelName: undefined,
topicName: undefined,
},
},
timestamp: undefined,
messages: [],
});
});
it("does not fire WhatsApp message_received hooks without explicit opt-in", async () => {
const internalReceived = vi.fn();
registerInternalHook("message:received", internalReceived);
resolvePolicyMock.mockReturnValue(makePolicy(makeAccount()));
await callProcessMessage();
expect(runMessageReceivedMock).not.toHaveBeenCalled();
expect(internalReceived).not.toHaveBeenCalled();
});
it("tracks session metadata writes as connection background tasks", async () => {
resolvePolicyMock.mockReturnValue(makePolicy(makeAccount()));
buildContextMock.mockImplementationOnce(() => ({
Body: "hi",
RawBody: "hi",
CommandBody: "hi",
SessionKey: baseRoute.sessionKey,
Provider: "whatsapp",
Surface: "whatsapp",
}));
await callProcessMessage();
expect(trackBackgroundTaskMock).toHaveBeenCalledTimes(1);
expect(mockCallArg(trackBackgroundTaskMock, "trackBackgroundTask")).toBeInstanceOf(Set);
expect(mockCallArg(trackBackgroundTaskMock, "trackBackgroundTask", 0, 1)).toBeInstanceOf(
Promise,
);
});
it("drops blocked admission before session record and reply dispatch", async () => {
resolvePolicyMock.mockReturnValue(makePolicy(makeAccount()));
buildContextMock.mockImplementationOnce(() => ({
Body: "hi",
RawBody: "hi",
CommandBody: "hi",
SessionKey: baseRoute.sessionKey,
Provider: "whatsapp",
Surface: "whatsapp",
}));
const result = await callProcessMessage({
msg: createTestWebInboundMessage({
admission: {
ingress: {
admission: "drop",
decision: "block",
reasonCode: "dm_policy_not_allowlisted",
},
senderAccess: {
allowed: false,
decision: "block",
reasonCode: "dm_policy_not_allowlisted",
},
activationAccess: {
allowed: false,
shouldSkip: true,
reasonCode: "dm_policy_not_allowlisted",
},
},
}),
});
expect(result).toBe(false);
expect(buildContextMock).not.toHaveBeenCalled();
expect(trackBackgroundTaskMock).not.toHaveBeenCalled();
expect(dispatchBufferedReplyMock).not.toHaveBeenCalled();
expect(runMessageReceivedMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,622 @@
// Whatsapp plugin module implements process message behavior.
import {
logAckFailure,
removeAckReactionHandleAfterReply,
type AckReactionHandle,
} from "openclaw/plugin-sdk/channel-feedback";
import {
runChannelInboundEvent,
type CommandTurnContext,
} from "openclaw/plugin-sdk/channel-inbound";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import {
createInternalHookEvent,
deriveInboundMessageHookContext,
fireAndForgetBoundedHook,
toInternalMessageReceivedContext,
toPluginMessageContext,
toPluginMessageReceivedEvent,
triggerInternalHook,
} from "openclaw/plugin-sdk/hook-runtime";
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
import { resolveBatchedReplyThreadingPolicy } from "openclaw/plugin-sdk/reply-reference";
import { getPrimaryIdentityId, getSelfIdentity, getSenderIdentity } from "../../identity.js";
import {
resolveWhatsAppCommandAuthorized,
resolveWhatsAppInboundPolicy,
} from "../../inbound-policy.js";
import { requireWhatsAppInboundAdmission } from "../../inbound/admission.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import { newConnectionId } from "../../reconnect.js";
import { formatError } from "../../session.js";
import {
resolveWhatsAppDirectSystemPrompt,
resolveWhatsAppGroupSystemPrompt,
} from "../../system-prompt.js";
import { deliverWebReply } from "../deliver-reply.js";
import { whatsappInboundLog } from "../loggers.js";
import { elide } from "../util.js";
import { maybeSendAckReaction } from "./ack-reaction.js";
import {
resolveVisibleWhatsAppGroupHistory,
resolveVisibleWhatsAppReplyContext,
type GroupHistoryEntry,
} from "./inbound-context.js";
import {
buildWhatsAppInboundContext,
dispatchWhatsAppBufferedReply,
resolveWhatsAppDmRouteTarget,
resolveWhatsAppResponsePrefix,
updateWhatsAppMainLastRoute,
} from "./inbound-dispatch.js";
import { trackBackgroundTask, updateLastRouteInBackground } from "./last-route.js";
import { buildInboundLine } from "./message-line.js";
import {
buildHistoryContextFromEntries,
createChannelMessageReplyPipeline,
formatInboundEnvelope,
logVerbose,
normalizeE164,
resolveChannelContextVisibilityMode,
resolveInboundSessionEnvelopeContext,
resolvePinnedMainDmOwnerFromAllowlist,
isControlCommandMessage,
shouldComputeCommandAuthorized,
shouldLogVerbose,
type getChildLogger,
type getReplyFromConfig,
type HistoryEntry,
type LoadConfigFn,
type resolveAgentRoute,
} from "./runtime-api.js";
import {
createWhatsAppStatusReactionController,
type StatusReactionController,
} from "./status-reaction.js";
const WHATSAPP_MESSAGE_RECEIVED_HOOK_LIMITS = {
maxConcurrency: 8,
maxQueue: 128,
timeoutMs: 2_000,
};
type WhatsAppMessageReceivedHookConfig = {
pluginHooks?: {
messageReceived?: boolean;
};
accounts?: Record<string, unknown>;
};
function readWhatsAppMessageReceivedHookOptIn(value: unknown): boolean | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const pluginHooks = (value as WhatsAppMessageReceivedHookConfig).pluginHooks;
if (pluginHooks?.messageReceived === undefined) {
return undefined;
}
return pluginHooks.messageReceived;
}
function shouldEmitWhatsAppMessageReceivedHooks(params: {
cfg: ReturnType<LoadConfigFn>;
accountId?: string;
}): boolean {
const channelConfig = params.cfg.channels?.whatsapp as
| WhatsAppMessageReceivedHookConfig
| undefined;
const accountConfig =
params.accountId && channelConfig?.accounts
? channelConfig.accounts[params.accountId]
: undefined;
return (
readWhatsAppMessageReceivedHookOptIn(accountConfig) ??
readWhatsAppMessageReceivedHookOptIn(channelConfig) ??
false
);
}
function emitWhatsAppMessageReceivedHooks(params: {
ctx: Awaited<ReturnType<typeof buildWhatsAppInboundContext>>;
sessionKey: string;
}): void {
const canonical = deriveInboundMessageHookContext(params.ctx);
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("message_received")) {
fireAndForgetBoundedHook(
() =>
hookRunner.runMessageReceived(
toPluginMessageReceivedEvent(canonical),
toPluginMessageContext(canonical),
),
"whatsapp: message_received plugin hook failed",
undefined,
WHATSAPP_MESSAGE_RECEIVED_HOOK_LIMITS,
);
}
fireAndForgetBoundedHook(
() =>
triggerInternalHook(
createInternalHookEvent(
"message",
"received",
params.sessionKey,
toInternalMessageReceivedContext(canonical),
),
),
"whatsapp: message_received internal hook failed",
undefined,
WHATSAPP_MESSAGE_RECEIVED_HOOK_LIMITS,
);
}
function emitWhatsAppMessageReceivedHooksIfEnabled(params: {
cfg: ReturnType<LoadConfigFn>;
ctx: Awaited<ReturnType<typeof buildWhatsAppInboundContext>>;
accountId?: string;
sessionKey: string;
}): void {
if (
!shouldEmitWhatsAppMessageReceivedHooks({
cfg: params.cfg,
accountId: params.accountId,
})
) {
return;
}
emitWhatsAppMessageReceivedHooks({
ctx: params.ctx,
sessionKey: params.sessionKey,
});
}
function resolvePinnedMainDmRecipient(params: {
cfg: ReturnType<LoadConfigFn>;
allowFrom?: string[];
}): string | null {
return resolvePinnedMainDmOwnerFromAllowlist({
dmScope: params.cfg.session?.dmScope,
allowFrom: params.allowFrom,
normalizeEntry: (entry) => normalizeE164(entry),
});
}
export async function processMessage(params: {
cfg: ReturnType<LoadConfigFn>;
msg: AdmittedWebInboundMessage;
route: ReturnType<typeof resolveAgentRoute>;
groupHistoryKey: string;
groupHistories: Map<string, GroupHistoryEntry[]>;
groupMemberNames: Map<string, Map<string, string>>;
connectionId: string;
verbose: boolean;
maxMediaBytes: number;
replyResolver: typeof getReplyFromConfig;
replyLogger: ReturnType<typeof getChildLogger>;
backgroundTasks: Set<Promise<unknown>>;
rememberSentText: (
text: string | undefined,
opts: {
combinedBody?: string;
combinedBodySessionKey?: string;
logVerboseMessage?: boolean;
},
) => void;
echoHas: (key: string) => boolean;
echoForget: (key: string) => void;
buildCombinedEchoKey: (p: { sessionKey: string; combinedBody: string }) => string;
maxMediaTextChunkLimit?: number;
groupHistory?: GroupHistoryEntry[];
suppressGroupHistoryClear?: boolean;
ackAlreadySent?: boolean;
ackReaction?: AckReactionHandle | null;
statusReactionController?: StatusReactionController | null;
/** Pre-computed audio transcript from a caller-level preflight, used to avoid
* re-transcribing the same voice note once per broadcast agent.
* - string → transcript obtained; use it directly, skip internal STT
* - null → preflight was attempted but failed / returned nothing; skip internal STT
* - undefined (omitted) → caller did not attempt preflight; run internal STT as normal */
preflightAudioTranscript?: string | null;
}) {
const admission = requireWhatsAppInboundAdmission(params.msg);
if (admission.ingress.admission !== "dispatch" && admission.ingress.admission !== "observe") {
return false;
}
const conversationId = admission.conversation.id;
const conversationKind = admission.conversation.kind;
const self = getSelfIdentity(params.msg);
const inboundPolicy = resolveWhatsAppInboundPolicy({
cfg: params.cfg,
accountId: params.route.accountId ?? admission.accountId,
selfE164: self.e164 ?? null,
});
const account = inboundPolicy.account;
const contextVisibilityMode = resolveChannelContextVisibilityMode({
cfg: params.cfg,
channel: "whatsapp",
accountId: account.accountId,
});
const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
cfg: params.cfg,
agentId: params.route.agentId,
sessionKey: params.route.sessionKey,
});
// Preflight audio transcription: transcribe voice notes before building the
// inbound context so the agent receives the transcript instead of <media:audio>.
// Mirrors the preflight step added for Telegram in #61008.
// When the caller already performed transcription (e.g. on-message.ts before
// broadcast fan-out) the pre-computed result is reused to avoid N STT calls
// for N broadcast agents on the same voice note.
// preflightAudioTranscript semantics:
// string → transcript ready, use it
// null → caller attempted but got nothing; skip internal STT to avoid retry
// undefined → caller did not attempt; run internal STT
let audioTranscript: string | undefined = params.preflightAudioTranscript ?? undefined;
const hasAudioBody =
params.msg.payload.media?.type?.startsWith("audio/") === true &&
params.msg.payload.body === "<media:audio>";
if (
params.preflightAudioTranscript === undefined &&
hasAudioBody &&
params.msg.payload.media?.path
) {
try {
const { transcribeFirstAudio } = await import("./audio-preflight.runtime.js");
audioTranscript = await transcribeFirstAudio({
ctx: {
MediaPaths: [params.msg.payload.media?.path],
MediaTypes: params.msg.payload.media?.type ? [params.msg.payload.media?.type] : undefined,
From: conversationId,
To: params.msg.platform.recipientJid,
Provider: "whatsapp",
Surface: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: conversationId,
AccountId: params.route.accountId,
},
cfg: params.cfg,
});
} catch {
// Transcription failure is non-fatal: fall back to <media:audio> placeholder.
if (shouldLogVerbose()) {
logVerbose("whatsapp: audio preflight transcription failed, using placeholder");
}
}
}
// If we have a transcript, replace the agent-facing body so the agent sees the spoken text.
// mediaPath and mediaType are intentionally preserved so that inboundAudio detection
// (used by features such as messages.tts.auto: "inbound") still sees this as an
// audio message. The transcript and transcribed media index are also stored on
// context so downstream media understanding does not transcribe it again.
const msgForAgent: AdmittedWebInboundMessage =
audioTranscript !== undefined
? { ...params.msg, payload: { ...params.msg.payload, body: audioTranscript } }
: params.msg;
const visibleReplyTo = resolveVisibleWhatsAppReplyContext({
msg: params.msg,
authDir: account.authDir,
mode: contextVisibilityMode,
groupPolicy: inboundPolicy.groupPolicy,
groupAllowFrom: inboundPolicy.groupAllowFrom,
});
let combinedBody = buildInboundLine({
cfg: params.cfg,
msg: msgForAgent,
agentId: params.route.agentId,
previousTimestamp,
envelope: envelopeOptions,
visibleReplyTo,
});
let shouldClearGroupHistory = false;
const visibleGroupHistory =
conversationKind === "group"
? resolveVisibleWhatsAppGroupHistory({
history: params.groupHistory ?? params.groupHistories.get(params.groupHistoryKey) ?? [],
mode: contextVisibilityMode,
groupPolicy: inboundPolicy.groupPolicy,
groupAllowFrom: inboundPolicy.groupAllowFrom,
authDir: account.authDir,
})
: undefined;
if (conversationKind === "group") {
const history = visibleGroupHistory ?? [];
if (history.length > 0) {
const historyEntries: HistoryEntry[] = history.map((m) => ({
sender: m.sender,
body: m.body,
timestamp: m.timestamp,
}));
combinedBody = buildHistoryContextFromEntries({
entries: historyEntries,
currentMessage: combinedBody,
excludeLast: false,
formatEntry: (entry) => {
return formatInboundEnvelope({
channel: "WhatsApp",
from: conversationId,
timestamp: entry.timestamp,
body: entry.body,
chatType: "group",
senderLabel: entry.sender,
envelope: envelopeOptions,
});
},
});
}
shouldClearGroupHistory = !(params.suppressGroupHistoryClear ?? false);
}
// Echo detection uses combined body so we don't respond twice.
const combinedEchoKey = params.buildCombinedEchoKey({
sessionKey: params.route.sessionKey,
combinedBody,
});
if (params.echoHas(combinedEchoKey)) {
logVerbose("Skipping auto-reply: detected echo for combined message");
params.echoForget(combinedEchoKey);
return false;
}
// When statusReactions.enabled, a StatusReactionController takes over lifecycle
// signaling (queued → thinking → tool → done/error). The plain ackReaction is
// skipped so the same message slot isn't used for two competing systems.
const statusReactionController =
params.statusReactionController ??
(params.cfg.messages?.statusReactions?.enabled === true && !params.ackAlreadySent
? await createWhatsAppStatusReactionController({
cfg: params.cfg,
msg: params.msg,
agentId: params.route.agentId,
sessionKey: params.route.sessionKey,
verbose: params.verbose,
})
: null);
if (statusReactionController && !params.statusReactionController) {
void statusReactionController.setQueued();
}
// Send ack reaction immediately upon message receipt (post-gating). Callers
// that do preflight work before processMessage can send it first and set
// ackAlreadySent so slow STT does not delay user-visible receipt feedback.
// Skip if the status reaction controller is handling lifecycle signaling.
let ackReaction = params.ackReaction ?? null;
if (!statusReactionController && !ackReaction && params.ackAlreadySent !== true) {
ackReaction = await maybeSendAckReaction({
cfg: params.cfg,
msg: params.msg,
agentId: params.route.agentId,
sessionKey: params.route.sessionKey,
verbose: params.verbose,
info: params.replyLogger.info.bind(params.replyLogger),
warn: params.replyLogger.warn.bind(params.replyLogger),
});
}
const correlationId = params.msg.event.id ?? newConnectionId();
params.replyLogger.info(
{
connectionId: params.connectionId,
correlationId,
from: conversationId,
to: params.msg.platform.recipientJid,
body: elide(combinedBody, 240),
mediaType: params.msg.payload.media?.type ?? null,
mediaPath: params.msg.payload.media?.path ?? null,
},
"inbound web message",
);
const fromDisplay = conversationId;
const kindLabel = params.msg.payload.media?.type ? `, ${params.msg.payload.media?.type}` : "";
whatsappInboundLog.info(
`Inbound message ${fromDisplay} -> ${params.msg.platform.recipientJid} (${conversationKind}${kindLabel}, ${combinedBody.length} chars)`,
);
if (shouldLogVerbose()) {
whatsappInboundLog.debug(`Inbound body: ${elide(combinedBody, 400)}`);
}
const sender = getSenderIdentity(params.msg);
const commandBody = params.msg.payload.commandBody ?? params.msg.payload.body;
const dmRouteTarget = resolveWhatsAppDmRouteTarget({
msg: params.msg,
senderE164: sender.e164 ?? undefined,
normalizeE164,
});
const shouldCheckCommandAuth = shouldComputeCommandAuthorized(commandBody, params.cfg);
const isTextCommand = isControlCommandMessage(commandBody, params.cfg);
const commandAuthorized = shouldCheckCommandAuth
? await resolveWhatsAppCommandAuthorized({
cfg: params.cfg,
msg: params.msg,
policy: inboundPolicy,
authDir: account.authDir,
})
: undefined;
const commandTurn: CommandTurnContext = isTextCommand
? {
kind: "text-slash",
source: "text",
authorized: Boolean(commandAuthorized),
body: commandBody,
}
: {
kind: "normal",
source: "message",
authorized: false,
body: commandBody,
};
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
cfg: params.cfg,
agentId: params.route.agentId,
channel: "whatsapp",
accountId: params.route.accountId,
});
const responsePrefix = resolveWhatsAppResponsePrefix({
cfg: params.cfg,
agentId: params.route.agentId,
isSelfChat: conversationKind !== "group" && inboundPolicy.isSelfChat,
pipelineResponsePrefix: replyPipeline.responsePrefix,
});
const replyThreading = resolveBatchedReplyThreadingPolicy(
account.replyToMode ?? "off",
params.msg.event.isBatched === true,
);
// Resolve combined conversation system prompt using the group or direct surface.
const conversationSystemPrompt =
conversationKind === "group"
? resolveWhatsAppGroupSystemPrompt({
accountConfig: account,
groupId: conversationId,
})
: resolveWhatsAppDirectSystemPrompt({
accountConfig: account,
peerId: dmRouteTarget ?? conversationId,
});
const ctxPayload = await buildWhatsAppInboundContext({
bodyForAgent: msgForAgent.payload.body,
combinedBody,
commandBody,
commandAuthorized,
commandTurn,
groupHistory: visibleGroupHistory,
groupMemberRoster: params.groupMemberNames.get(params.groupHistoryKey),
groupSystemPrompt: conversationSystemPrompt,
msg: params.msg,
rawBody: commandBody,
route: params.route,
sender: {
id: getPrimaryIdentityId(sender) ?? undefined,
name: sender.name ?? undefined,
e164: sender.e164 ?? undefined,
},
...(audioTranscript !== undefined ? { transcript: audioTranscript } : {}),
...(audioTranscript !== undefined ? { mediaTranscribedIndexes: [0] } : {}),
replyThreading,
visibleReplyTo: visibleReplyTo ?? undefined,
suppressMessageReceivedHooks: true,
});
emitWhatsAppMessageReceivedHooksIfEnabled({
cfg: params.cfg,
ctx: ctxPayload,
accountId: params.route.accountId,
sessionKey: params.route.sessionKey,
});
const pinnedMainDmRecipient = resolvePinnedMainDmRecipient({
cfg: params.cfg,
allowFrom: inboundPolicy.configuredAllowFrom,
});
updateWhatsAppMainLastRoute({
backgroundTasks: params.backgroundTasks,
cfg: params.cfg,
ctx: ctxPayload,
dmRouteTarget,
pinnedMainDmRecipient,
route: params.route,
updateLastRoute: updateLastRouteInBackground,
warn: params.replyLogger.warn.bind(params.replyLogger),
});
const turnResult = await runChannelInboundEvent({
channel: "whatsapp",
accountId: params.route.accountId,
raw: params.msg,
adapter: {
ingest: () => ({
id: params.msg.event.id ?? `${conversationId}:${Date.now()}`,
timestamp: params.msg.event.timestamp,
rawText: ctxPayload.RawBody ?? "",
textForAgent: ctxPayload.BodyForAgent,
textForCommands: ctxPayload.CommandBody,
raw: params.msg,
}),
preflight: () => {
const reason = admission.ingress.reasonCode;
if (admission.ingress.admission === "dispatch") {
return { admission: { kind: "dispatch", reason } };
}
if (admission.ingress.admission === "observe") {
return { admission: { kind: "observeOnly", reason } };
}
if (admission.ingress.admission === "skip") {
return { admission: { kind: "handled", reason } };
}
return {
admission: {
kind: "drop",
reason,
recordHistory: false,
},
};
},
resolveTurn: () => ({
channel: "whatsapp",
accountId: params.route.accountId,
routeSessionKey: params.route.sessionKey,
storePath,
ctxPayload,
recordInboundSession,
record: {
onRecordError: (err) => {
params.replyLogger.warn(
{
error: formatError(err),
storePath,
sessionKey: params.route.sessionKey,
},
"failed updating session meta",
);
},
trackSessionMetaTask: (task) => {
trackBackgroundTask(params.backgroundTasks, task);
},
},
runDispatch: () =>
dispatchWhatsAppBufferedReply({
cfg: params.cfg,
connectionId: params.connectionId,
context: ctxPayload,
deliverReply: deliverWebReply,
groupHistories: params.groupHistories,
groupHistoryKey: params.groupHistoryKey,
maxMediaBytes: params.maxMediaBytes,
maxMediaTextChunkLimit: params.maxMediaTextChunkLimit,
msg: params.msg,
onModelSelected,
rememberSentText: params.rememberSentText,
replyLogger: params.replyLogger,
replyPipeline: {
...replyPipeline,
responsePrefix,
},
replyResolver: params.replyResolver,
route: params.route,
shouldClearGroupHistory,
statusReactionController,
}),
}),
},
});
const didSendReply = turnResult.dispatched ? turnResult.dispatchResult : false;
removeAckReactionHandleAfterReply({
removeAfterReply: Boolean(params.cfg.messages?.removeAckAfterReply && didSendReply),
ackReaction,
onError: (err) => {
logAckFailure({
log: logVerbose,
channel: "whatsapp",
target: `${params.msg.platform.chatJid ?? conversationId}/${params.msg.event.id ?? "unknown"}`,
error: err,
});
},
});
return didSendReply;
}

View File

@@ -0,0 +1,37 @@
// Whatsapp API module exposes the plugin public contract.
export { resolveIdentityNamePrefix } from "openclaw/plugin-sdk/agent-runtime";
export { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound";
export { resolveInboundSessionEnvelopeContext } from "openclaw/plugin-sdk/channel-inbound";
export { toLocationContext } from "openclaw/plugin-sdk/channel-inbound";
export {
createChannelMessageReplyPipeline,
resolveChannelMessageSourceReplyDeliveryMode,
} from "openclaw/plugin-sdk/channel-outbound";
export {
isControlCommandMessage,
shouldComputeCommandAuthorized,
} from "openclaw/plugin-sdk/command-detection";
export { resolveChannelContextVisibilityMode } from "../config.runtime.js";
export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
export type LoadConfigFn = typeof import("../config.runtime.js").getRuntimeConfig;
export {
buildHistoryContextFromEntries,
type HistoryEntry,
} from "openclaw/plugin-sdk/reply-history";
export { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
export {
dispatchReplyWithBufferedBlockDispatcher,
finalizeInboundContext,
resolveChunkMode,
resolveTextChunkLimit,
type getReplyFromConfig,
type ReplyPayload,
} from "openclaw/plugin-sdk/reply-runtime";
export {
resolveInboundLastRouteSessionKey,
type resolveAgentRoute,
} from "openclaw/plugin-sdk/routing";
export { logVerbose, shouldLogVerbose, type getChildLogger } from "openclaw/plugin-sdk/runtime-env";
export { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
export { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
export { jidToE164, normalizeE164 } from "../../text-runtime.js";

View File

@@ -0,0 +1,156 @@
// Whatsapp tests cover status reaction plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import { createWhatsAppStatusReactionController } from "./status-reaction.js";
const hoisted = vi.hoisted(() => ({
sendReactionWhatsApp: vi.fn(async () => undefined),
}));
vi.mock("../../send.js", () => ({
sendReactionWhatsApp: hoisted.sendReactionWhatsApp,
}));
type TestMsgOverrides = NonNullable<Parameters<typeof createTestWebInboundMessage>[0]>;
function createMessage(overrides: TestMsgOverrides = {}): AdmittedWebInboundMessage {
return createTestWebInboundMessage({
event: { id: "msg-1" },
platform: {
chatJid: "15551234567@s.whatsapp.net",
recipientJid: "15559876543",
},
admission: {
accountId: "default",
conversation: {
kind: "direct",
id: "15551234567",
},
sender: {
id: "15551234567",
},
},
...overrides,
});
}
describe("createWhatsAppStatusReactionController", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses the agent identity emoji when WhatsApp ackReaction has no emoji", async () => {
const cfg = {
agents: {
list: [{ id: "agent", identity: { emoji: "🔥" } }],
},
messages: {
statusReactions: {
enabled: true,
timing: {
debounceMs: 1_000_000,
stallSoftMs: 1_000_000,
stallHardMs: 1_000_000,
doneHoldMs: 0,
errorHoldMs: 0,
},
},
},
channels: {
whatsapp: {
reactionLevel: "ack",
ackReaction: {
direct: true,
group: "mentions",
},
},
},
} as OpenClawConfig;
const controller = await createWhatsAppStatusReactionController({
cfg,
msg: createMessage(),
agentId: "agent",
sessionKey: "whatsapp:default:15551234567",
verbose: false,
});
void controller?.setQueued();
await vi.waitFor(() => {
expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith(
"15551234567@s.whatsapp.net",
"msg-1",
"🔥",
{
verbose: false,
fromMe: false,
accountId: "default",
cfg,
},
);
});
await controller?.clear();
});
it("uses the active account reactionLevel override from admission", async () => {
const cfg = {
messages: {
statusReactions: {
enabled: true,
timing: {
debounceMs: 1_000_000,
stallSoftMs: 1_000_000,
stallHardMs: 1_000_000,
doneHoldMs: 0,
errorHoldMs: 0,
},
},
},
channels: {
whatsapp: {
reactionLevel: "off",
ackReaction: {
emoji: "👀",
direct: true,
group: "mentions",
},
accounts: {
work: {
reactionLevel: "ack",
},
},
},
},
} as OpenClawConfig;
const controller = await createWhatsAppStatusReactionController({
cfg,
msg: createMessage({
admission: {
accountId: "work",
},
}),
agentId: "agent",
sessionKey: "whatsapp:work:15551234567",
verbose: false,
});
void controller?.setQueued();
await vi.waitFor(() => {
expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith(
"15551234567@s.whatsapp.net",
"msg-1",
"👀",
{
verbose: false,
fromMe: false,
accountId: "work",
cfg,
},
);
});
await controller?.clear();
});
});

View File

@@ -0,0 +1,115 @@
// Whatsapp plugin module implements status reaction behavior.
import {
createStatusReactionController,
shouldAckReactionForWhatsApp,
type StatusReactionController,
} from "openclaw/plugin-sdk/channel-feedback";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { getSenderIdentity } from "../../identity.js";
import { requireWhatsAppInboundAdmission } from "../../inbound/admission.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import { resolveWhatsAppReactionLevel } from "../../reaction-level.js";
import { sendReactionWhatsApp } from "../../send.js";
import { resolveWhatsAppAckEmoji } from "./ack-emoji.js";
import { resolveGroupActivationFor } from "./group-activation.js";
export type { StatusReactionController };
export type WhatsAppStatusReactionParams = {
cfg: OpenClawConfig;
msg: AdmittedWebInboundMessage;
agentId: string;
sessionKey: string;
verbose: boolean;
};
export async function createWhatsAppStatusReactionController(
params: WhatsAppStatusReactionParams,
): Promise<StatusReactionController | null> {
if (!params.msg.event.id) {
return null;
}
const statusReactionsConfig = params.cfg.messages?.statusReactions;
if (statusReactionsConfig?.enabled !== true) {
return null;
}
const admission = requireWhatsAppInboundAdmission(params.msg);
const accountId = admission.accountId;
const reactionLevel = resolveWhatsAppReactionLevel({
cfg: params.cfg,
accountId,
});
if (reactionLevel.level === "off") {
return null;
}
const ackConfig = params.cfg.channels?.whatsapp?.ackReaction;
const ackEmoji = resolveWhatsAppAckEmoji({
cfg: params.cfg,
agentId: params.agentId,
ackConfig,
});
if (!ackEmoji) {
return null;
}
const directEnabled = ackConfig?.direct ?? true;
const groupMode = ackConfig?.group ?? "mentions";
const isGroup = admission.conversation.kind === "group";
const conversationIdForCheck = admission.conversation.id;
const activation = isGroup
? await resolveGroupActivationFor({
cfg: params.cfg,
accountId,
agentId: params.agentId,
sessionKey: params.sessionKey,
conversationId: conversationIdForCheck,
})
: null;
const shouldUseStatusReaction = shouldAckReactionForWhatsApp({
emoji: ackEmoji,
isDirect: admission.conversation.kind === "direct",
isGroup,
directEnabled,
groupMode,
wasMentioned: (params.msg.groupMention?.wasMentioned ?? params.msg.wasMentioned) === true,
groupActivated: activation === "always",
});
if (!shouldUseStatusReaction) {
return null;
}
const sender = getSenderIdentity(params.msg);
const reactionOptions = {
verbose: params.verbose,
fromMe: false,
...(sender.jid ? { participant: sender.jid } : {}),
accountId,
cfg: params.cfg,
};
const chatId = params.msg.platform.chatJid;
const msgId = params.msg.event.id;
return createStatusReactionController({
enabled: true,
adapter: {
setReaction: async (emoji: string) => {
await sendReactionWhatsApp(chatId, msgId, emoji, reactionOptions);
},
clearReaction: async () => {
await sendReactionWhatsApp(chatId, msgId, "", reactionOptions);
},
},
initialEmoji: ackEmoji,
emojis: statusReactionsConfig.emojis,
timing: statusReactionsConfig.timing,
onError: (err) => {
logVerbose(`WhatsApp status-reaction error for chat ${chatId}/${msgId}: ${String(err)}`);
},
});
}

View File

@@ -0,0 +1,2 @@
// Whatsapp plugin module implements reply resolver behavior.
export { getReplyFromConfig } from "openclaw/plugin-sdk/reply-runtime";

View File

@@ -0,0 +1,54 @@
// Whatsapp type declarations define plugin contracts.
import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract";
import type { WebInboundMessage } from "../inbound/types.js";
import type { ReconnectPolicy } from "../reconnect.js";
import type { WhatsAppSocketTimingOptions } from "../socket-timing.js";
export type WebChannelHealthState =
| "starting"
| "healthy"
| "stale"
| "reconnecting"
| "conflict"
| "logged-out"
| "stopped";
/** @deprecated Use `WebInboundMessage`. */
export type WebInboundMsg = WebInboundMessage;
export type WebChannelStatus = {
running: boolean;
connected: boolean;
reconnectAttempts: number;
lastConnectedAt?: number | null;
lastDisconnect?: {
at: number;
status?: number;
error?: string;
loggedOut?: boolean;
} | null;
lastInboundAt?: number | null;
lastMessageAt?: number | null;
lastEventAt?: number | null;
lastTransportActivityAt?: number | null;
busy?: boolean;
lastRunActivityAt?: number | null;
lastError?: string | null;
healthState?: WebChannelHealthState;
};
export type WebMonitorTuning = {
reconnect?: Partial<ReconnectPolicy>;
socketTiming?: WhatsAppSocketTimingOptions;
heartbeatSeconds?: number;
transportTimeoutMs?: number;
messageTimeoutMs?: number;
watchdogCheckMs?: number;
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
statusSink?: (status: WebChannelStatus) => void;
channelRuntime?: ChannelRuntimeSurface;
/** WhatsApp account id. Default: "default". */
accountId?: string;
/** Debounce window (ms) for batching rapid consecutive messages from the same sender. */
debounceMs?: number;
};

View File

@@ -0,0 +1,79 @@
// Whatsapp plugin module implements util behavior.
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
export function elide(text?: string, limit = 400) {
if (!text) {
return text;
}
if (text.length <= limit) {
return text;
}
const truncated = truncateUtf16Safe(text, limit);
return `${truncated}… (truncated ${text.length - truncated.length} chars)`;
}
export function markWhatsAppVisibleDeliveryError(error: unknown): unknown {
if (typeof error === "object" && error !== null && !Array.isArray(error)) {
try {
Object.assign(error, { sentBeforeError: true, visibleReplySent: true });
return error;
} catch {
// Fall back to a wrapper when a platform error object is non-extensible.
}
}
const visibleError = new Error("visible WhatsApp reply delivery failed", { cause: error });
Object.assign(visibleError, { sentBeforeError: true, visibleReplySent: true });
return visibleError;
}
export function isLikelyWhatsAppCryptoError(reason: unknown) {
const formatReason = (value: unknown): string => {
if (value == null) {
return "";
}
if (typeof value === "string") {
return value;
}
if (value instanceof Error) {
return `${value.message}\n${value.stack ?? ""}`;
}
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return Object.prototype.toString.call(value);
}
}
if (typeof value === "number") {
return String(value);
}
if (typeof value === "boolean") {
return String(value);
}
if (typeof value === "bigint") {
return String(value);
}
if (typeof value === "symbol") {
return value.description ?? value.toString();
}
if (typeof value === "function") {
return value.name ? `[function ${value.name}]` : "[function]";
}
return Object.prototype.toString.call(value);
};
const raw =
reason instanceof Error ? `${reason.message}\n${reason.stack ?? ""}` : formatReason(reason);
const haystack = normalizeLowercaseStringOrEmpty(raw);
const hasAuthError =
haystack.includes("unsupported state or unable to authenticate data") ||
haystack.includes("bad mac");
if (!hasAuthError) {
return false;
}
return (
haystack.includes("baileys") ||
haystack.includes("noise-handler") ||
haystack.includes("aesdecryptgcm")
);
}

View File

@@ -0,0 +1,839 @@
// Whatsapp tests cover web auto reply monitor plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestWebInboundMessage } from "../inbound/test-message.test-helper.js";
import type { AdmittedWebInboundMessage } from "../inbound/types.js";
import { buildMentionConfig } from "./mentions.js";
import { applyGroupGating, type GroupHistoryEntry } from "./monitor/group-gating.js";
import { formatWhatsAppInboundListeningLog } from "./monitor/listener-log.js";
import { buildInboundLine, formatReplyContext } from "./monitor/message-line.js";
let sessionDir: string | undefined;
let sessionStorePath: string;
beforeEach(async () => {
sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-group-gating-"));
sessionStorePath = path.join(sessionDir, "sessions.json");
await fs.writeFile(sessionStorePath, "{}");
});
afterEach(async () => {
if (sessionDir) {
await fs.rm(sessionDir, { recursive: true, force: true });
sessionDir = undefined;
}
});
const makeConfig = (overrides: Record<string, unknown>) =>
({
channels: {
whatsapp: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
session: { store: sessionStorePath },
...overrides,
}) as unknown as import("openclaw/plugin-sdk/config-contracts").OpenClawConfig;
async function runGroupGating(params: {
cfg: import("openclaw/plugin-sdk/config-contracts").OpenClawConfig;
msg: AdmittedWebInboundMessage;
agentId?: string;
selfChatMode?: boolean;
authDir?: string;
}) {
const groupHistories = new Map<string, GroupHistoryEntry[]>();
const conversationId = params.msg.admission?.conversation.id ?? "123@g.us";
const agentId = params.agentId ?? "main";
const sessionKey = `agent:${agentId}:whatsapp:group:${conversationId}`;
const baseMentionConfig = buildMentionConfig(params.cfg, undefined);
const verboseLogs: string[] = [];
const result = await applyGroupGating({
cfg: params.cfg,
msg: params.msg,
groupHistoryKey: `whatsapp:default:group:${conversationId}`,
agentId,
sessionKey,
baseMentionConfig,
authDir: params.authDir,
selfChatMode: params.selfChatMode,
groupHistories,
groupHistoryLimit: 10,
groupMemberNames: new Map(),
logVerbose: (message) => verboseLogs.push(message),
replyLogger: { debug: () => {}, warn: () => {} },
});
return { result, groupHistories, verboseLogs };
}
type TestMessageOverrides = {
admission?: NonNullable<Parameters<typeof createTestWebInboundMessage>[0]>["admission"];
body?: string;
id?: string;
mentionedJids?: string[];
replyToBody?: string;
replyToId?: string;
replyToSender?: string;
replyToSenderE164?: string;
replyToSenderJid?: string;
selfE164?: string;
selfJid?: string;
senderE164?: string;
senderJid?: string;
senderName?: string;
timestamp?: number;
to?: string;
};
function createGroupMessage(overrides: TestMessageOverrides = {}): AdmittedWebInboundMessage {
const conversationId = overrides.admission?.conversation?.id ?? "123@g.us";
return createTestWebInboundMessage({
event: {
id: overrides.id ?? "g1",
timestamp: overrides.timestamp,
},
payload: {
body: overrides.body ?? "hello group",
},
platform: {
chatJid: conversationId,
recipientJid: overrides.to ?? "+2",
senderE164: overrides.senderE164 ?? "+111",
senderJid: overrides.senderJid,
senderName: overrides.senderName ?? "Alice",
selfE164: overrides.selfE164 ?? "+999",
selfJid: overrides.selfJid,
},
admission: {
...overrides.admission,
accountId: overrides.admission?.accountId ?? "default",
conversation: {
kind: "group",
id: conversationId,
...overrides.admission?.conversation,
},
sender: {
id: overrides.senderJid ?? overrides.senderE164 ?? "+111",
...overrides.admission?.sender,
},
senderAccess: {
reasonCode: "group_policy_allowed",
...overrides.admission?.senderAccess,
},
},
quote: overrides.replyToBody
? {
id: overrides.replyToId,
body: overrides.replyToBody,
sender: {
displayName: overrides.replyToSender,
jid: overrides.replyToSenderJid,
e164: overrides.replyToSenderE164,
},
}
: undefined,
group: {
mentions: {
jids: overrides.mentionedJids,
},
},
});
}
function createDirectMessage(overrides: TestMessageOverrides = {}): AdmittedWebInboundMessage {
const conversationId = overrides.admission?.conversation?.id ?? "+1555";
return createTestWebInboundMessage({
event: {
id: overrides.id ?? "d1",
timestamp: overrides.timestamp,
},
payload: {
body: overrides.body ?? "hello direct",
},
platform: {
chatJid: conversationId,
recipientJid: overrides.to ?? "+2",
senderE164: overrides.senderE164 ?? conversationId,
senderJid: overrides.senderJid,
senderName: overrides.senderName ?? "Alice",
selfE164: overrides.selfE164 ?? "+999",
selfJid: overrides.selfJid,
},
admission: {
...overrides.admission,
accountId: overrides.admission?.accountId ?? "default",
conversation: {
kind: "direct",
id: conversationId,
...overrides.admission?.conversation,
},
sender: {
id: overrides.senderJid ?? overrides.senderE164 ?? conversationId,
...overrides.admission?.sender,
},
},
quote: overrides.replyToBody
? {
id: overrides.replyToId,
body: overrides.replyToBody,
sender: {
displayName: overrides.replyToSender,
jid: overrides.replyToSenderJid,
e164: overrides.replyToSenderE164,
},
}
: undefined,
});
}
function makeOwnerGroupConfig() {
return makeConfig({
channels: {
whatsapp: {
allowFrom: ["+111"],
groups: { "*": { requireMention: true } },
},
},
});
}
function makeInboundCfg(messagePrefix = "") {
return {
agents: { defaults: { workspace: "/tmp/openclaw" } },
channels: { whatsapp: { messagePrefix } },
} as never;
}
describe("WhatsApp listener diagnostics", () => {
it("describes WhatsApp inbound listener scope without implying DM-only routing", () => {
expect(
formatWhatsAppInboundListeningLog({
groupPolicy: "open",
hasGroupAllowFrom: false,
}),
).toBe(
"Listening for WhatsApp inbound messages (DM + all groups; no group allowlist configured).",
);
expect(
formatWhatsAppInboundListeningLog({
groupPolicy: "disabled",
hasGroupAllowFrom: true,
}),
).toBe("Listening for WhatsApp inbound messages (DM + groups disabled by groupPolicy).");
expect(
formatWhatsAppInboundListeningLog({
groupPolicy: "allowlist",
hasGroupAllowFrom: false,
}),
).toBe(
"Listening for WhatsApp inbound messages (DM + group inbound blocked by empty groupPolicy allowlist).",
);
expect(
formatWhatsAppInboundListeningLog({
groupPolicy: "allowlist",
hasGroupAllowFrom: true,
}),
).toBe(
"Listening for WhatsApp inbound messages (DM + all groups; sender allowlist configured).",
);
expect(
formatWhatsAppInboundListeningLog({
groups: { "123@g.us": {}, "*": {} },
groupPolicy: "allowlist",
hasGroupAllowFrom: true,
}),
).toBe("Listening for WhatsApp inbound messages (DM + all groups; wildcard configured).");
expect(
formatWhatsAppInboundListeningLog({
groups: { "123@g.us": {}, "456@g.us": {} },
groupPolicy: "allowlist",
hasGroupAllowFrom: true,
}),
).toBe("Listening for WhatsApp inbound messages (DM + 2 configured groups).");
});
});
describe("applyGroupGating", () => {
it("treats reply-to-bot as implicit mention", async () => {
const cfg = makeConfig({});
const { result } = await runGroupGating({
cfg,
msg: createGroupMessage({
id: "m1",
to: "+15550000",
admission: { accountId: "default" },
body: "following up",
timestamp: Date.now(),
selfJid: "15551234567@s.whatsapp.net",
selfE164: "+15551234567",
replyToId: "m0",
replyToBody: "bot said hi",
replyToSender: "+15551234567",
replyToSenderJid: "15551234567@s.whatsapp.net",
replyToSenderE164: "+15551234567",
}),
});
expect(result.shouldProcess).toBe(true);
});
it("does not treat self-number quoted replies as implicit mention in selfChatMode groups", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
selfChatMode: true,
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
});
const { result } = await runGroupGating({
cfg,
selfChatMode: true,
msg: createGroupMessage({
id: "m-self-reply",
to: "+15550000",
admission: { accountId: "default" },
body: "following up on my own message",
timestamp: Date.now(),
senderE164: "+15551234567",
senderJid: "15551234567@s.whatsapp.net",
selfJid: "15551234567@s.whatsapp.net",
selfE164: "+15551234567",
replyToId: "m0",
replyToBody: "my earlier message",
replyToSender: "+15551234567",
replyToSenderJid: "15551234567@s.whatsapp.net",
replyToSenderE164: "+15551234567",
}),
});
expect(result.shouldProcess).toBe(false);
});
it("still treats reply-to-bot as implicit mention in selfChatMode when sender is a different user", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
selfChatMode: true,
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
});
const { result } = await runGroupGating({
cfg,
selfChatMode: true,
msg: createGroupMessage({
id: "m-other-reply",
to: "+15550000",
admission: { accountId: "default" },
body: "following up on bot reply",
timestamp: Date.now(),
senderE164: "+15559999999",
senderJid: "15559999999@s.whatsapp.net",
selfJid: "15551234567@s.whatsapp.net",
selfE164: "+15551234567",
replyToId: "m0",
replyToBody: "bot earlier response",
replyToSender: "+15551234567",
replyToSenderJid: "15551234567@s.whatsapp.net",
replyToSenderE164: "+15551234567",
}),
});
expect(result.shouldProcess).toBe(true);
});
it("processes explicit group @mentions when self is in allowFrom (#49317)", async () => {
if (!sessionDir) {
throw new Error("sessionDir not initialized");
}
await fs.writeFile(
path.join(sessionDir, "lid-mapping-216372600647751_reverse.json"),
JSON.stringify("+15551234567"),
);
const cfg = makeConfig({
channels: {
whatsapp: {
allowFrom: ["+15551234567"],
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
});
const msg = createGroupMessage({
id: "g-self-lid-mention",
admission: { accountId: "default" },
body: "@216372600647751 can you see this?",
mentionedJids: ["216372600647751@lid"],
senderE164: "+15550001111",
senderName: "Alice",
selfE164: "+15551234567",
selfJid: "15551234567@s.whatsapp.net",
});
const { result, groupHistories } = await runGroupGating({
cfg,
authDir: sessionDir,
msg,
});
expect(result.shouldProcess).toBe(true);
expect(msg.groupMention).toEqual({ wasMentioned: true, requireMention: true });
expect(groupHistories.get("whatsapp:default:group:123@g.us")).toBeUndefined();
});
it("honors per-account selfChatMode overrides before suppressing implicit mentions", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
selfChatMode: true,
groupPolicy: "open",
groups: { "*": { requireMention: true } },
accounts: {
work: {
selfChatMode: false,
},
},
},
},
});
// Per-account override: work account has selfChatMode: false despite root being true
const { result } = await runGroupGating({
cfg,
selfChatMode: false,
msg: createGroupMessage({
id: "m-account-override",
to: "+15550000",
admission: { accountId: "work" },
body: "following up on bot reply",
timestamp: Date.now(),
senderE164: "+15551234567",
senderJid: "15551234567@s.whatsapp.net",
selfJid: "15551234567@s.whatsapp.net",
selfE164: "+15551234567",
replyToId: "m0",
replyToBody: "bot earlier response",
replyToSender: "+15551234567",
replyToSenderJid: "15551234567@s.whatsapp.net",
replyToSenderE164: "+15551234567",
}),
});
expect(result.shouldProcess).toBe(true);
});
it("uses account-scoped groupPolicy and groupAllowFrom for named-account group gating", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
groupPolicy: "allowlist",
accounts: {
work: {
groupPolicy: "allowlist",
groupAllowFrom: ["+111"],
},
},
},
},
});
const { result } = await runGroupGating({
cfg,
msg: createGroupMessage({
id: "g-account-policy",
admission: { accountId: "work" },
body: "following up",
senderE164: "+111",
senderJid: "111@s.whatsapp.net",
selfJid: "15551234567@s.whatsapp.net",
selfE164: "+15551234567",
replyToId: "m0",
replyToBody: "bot said hi",
replyToSender: "+15551234567",
replyToSenderJid: "15551234567@s.whatsapp.net",
replyToSenderE164: "+15551234567",
}),
});
expect(result.shouldProcess).toBe(true);
});
it("inherits group gating defaults from accounts.default for named accounts", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
groupPolicy: "allowlist",
accounts: {
default: {
groupPolicy: "open",
groups: {
"*": {
requireMention: false,
},
},
},
work: {},
},
},
},
});
const { result } = await runGroupGating({
cfg,
msg: createGroupMessage({
id: "g-default-inheritance",
admission: { accountId: "work" },
body: "plain group message",
senderE164: "+111",
senderJid: "111@s.whatsapp.net",
selfJid: "15551234567@s.whatsapp.net",
selfE164: "+15551234567",
}),
});
expect(result.shouldProcess).toBe(true);
});
it("preserves allowFrom fallback for named-account group gating when groupAllowFrom is empty", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
groupPolicy: "allowlist",
accounts: {
work: {
groupPolicy: "allowlist",
allowFrom: ["+111"],
groupAllowFrom: [],
groups: {
"*": {
requireMention: false,
},
},
},
},
},
},
});
const { result } = await runGroupGating({
cfg,
msg: createGroupMessage({
id: "g-empty-group-allow-fallback",
admission: { accountId: "work" },
body: "plain group message",
senderE164: "+111",
senderJid: "111@s.whatsapp.net",
selfJid: "15551234567@s.whatsapp.net",
selfE164: "+15551234567",
}),
});
expect(result.shouldProcess).toBe(true);
});
it("uses account-scoped allowFrom when bypassing mention gating for owner commands", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
allowFrom: ["+999"],
accounts: {
work: {
allowFrom: ["+111"],
},
},
},
},
});
const { result } = await runGroupGating({
cfg,
msg: createGroupMessage({
id: "g-account-owner",
admission: { accountId: "work" },
body: "/new",
senderE164: "+111",
senderName: "Owner",
}),
});
expect(result.shouldProcess).toBe(true);
});
it("does not treat group mention gating as self-chat under implicit self fallback", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
groups: { "*": { requireMention: true } },
},
},
messages: { groupChat: { mentionPatterns: ["@openclaw"] } },
});
const { result, groupHistories } = await runGroupGating({
cfg,
msg: createGroupMessage({
id: "g-other-mention",
body: "@openclaw please check this",
mentionedJids: ["15550000000@s.whatsapp.net"],
selfE164: "+15551234567",
selfJid: "15551234567@s.whatsapp.net",
}),
});
expect(result.shouldProcess).toBe(false);
expect(groupHistories.get("whatsapp:default:group:123@g.us")?.length).toBe(1);
});
it.each([
{ id: "g-new", command: "/new" },
{ id: "g-status", command: "/status" },
])("bypasses mention gating for owner $command in group chats", async ({ id, command }) => {
const { result } = await runGroupGating({
cfg: makeOwnerGroupConfig(),
msg: createGroupMessage({
id,
body: command,
senderE164: "+111",
senderName: "Owner",
}),
});
expect(result.shouldProcess).toBe(true);
});
it("does not bypass mention gating for non-owner /new in group chats", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
allowFrom: ["+999"],
groups: { "*": { requireMention: true } },
},
},
});
const { result, groupHistories } = await runGroupGating({
cfg,
msg: createGroupMessage({
id: "g-new-unauth",
body: "/new",
senderE164: "+111",
senderName: "NotOwner",
}),
});
expect(result.shouldProcess).toBe(false);
expect(groupHistories.get("whatsapp:default:group:123@g.us")?.length).toBe(1);
});
it("uses per-agent mention patterns for group gating (routing + mentionPatterns)", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
allowFrom: ["*"],
groups: { "*": { requireMention: true } },
},
},
messages: {
groupChat: { mentionPatterns: ["@global"] },
},
agents: {
list: [
{
id: "work",
groupChat: { mentionPatterns: ["@workbot"] },
},
],
},
bindings: [
{
agentId: "work",
match: {
provider: "whatsapp",
peer: { kind: "group", id: "123@g.us" },
},
},
],
});
const route = resolveAgentRoute({
cfg,
channel: "whatsapp",
peer: { kind: "group", id: "123@g.us" },
});
expect(route.agentId).toBe("work");
const { result: globalMention } = await runGroupGating({
cfg,
agentId: route.agentId,
msg: createGroupMessage({
id: "g1",
body: "@global ping",
senderE164: "+111",
senderName: "Alice",
}),
});
expect(globalMention.shouldProcess).toBe(false);
const { result: workMention } = await runGroupGating({
cfg,
agentId: route.agentId,
msg: createGroupMessage({
id: "g2",
body: "@workbot ping",
senderE164: "+222",
senderName: "Bob",
}),
});
expect(workMention.shouldProcess).toBe(true);
});
it("allows group messages when whatsapp groups default disables mention gating", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
allowFrom: ["*"],
groups: { "*": { requireMention: false } },
},
},
messages: { groupChat: { mentionPatterns: ["@openclaw"] } },
});
const { result } = await runGroupGating({
cfg,
msg: createGroupMessage(),
});
expect(result.shouldProcess).toBe(true);
});
it("blocks group messages when whatsapp groups is set without a wildcard", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
allowFrom: ["*"],
groups: {
"999@g.us": { requireMention: false },
},
},
},
});
const { result, verboseLogs } = await runGroupGating({
cfg,
msg: createGroupMessage({
body: "@workbot ping",
mentionedJids: ["999@s.whatsapp.net"],
selfJid: "999@s.whatsapp.net",
}),
});
expect(result.shouldProcess).toBe(false);
expect(verboseLogs).toContain(
'Dropping message from unregistered WhatsApp group 123@g.us. Add the group JID to channels.whatsapp.groups, or add "*" there to admit all groups. Sender authorization still applies.',
);
});
});
describe("buildInboundLine", () => {
it("prefixes group messages with sender", () => {
const line = buildInboundLine({
cfg: makeInboundCfg(""),
agentId: "main",
msg: createGroupMessage({
admission: { accountId: "default" },
body: "ping",
timestamp: 1700000000000,
senderJid: "111@s.whatsapp.net",
senderE164: "+15550001111",
senderName: "Bob",
}) as never,
});
expect(line).toContain("Bob (+15550001111):");
expect(line).toContain("ping");
});
it("includes reply-to context blocks when replyToBody is present", () => {
const line = buildInboundLine({
cfg: makeInboundCfg(""),
agentId: "main",
msg: createDirectMessage({
admission: {
conversation: {
id: "+1555",
},
},
body: "hello",
replyToId: "q1",
replyToBody: "original",
replyToSender: "+1999",
}),
envelope: { includeTimestamp: false },
});
expect(line).toContain("[Replying to +1999 id:q1]");
expect(line).toContain("original");
expect(line).toContain("[/Replying]");
});
it("applies the WhatsApp messagePrefix when configured", () => {
const line = buildInboundLine({
cfg: makeInboundCfg("[PFX]"),
agentId: "main",
msg: createDirectMessage({
admission: {
conversation: {
id: "+1555",
},
},
body: "ping",
to: "+2666",
}),
envelope: { includeTimestamp: false },
});
expect(line).toContain("[PFX] ping");
});
it("normalizes direct from labels by stripping whatsapp: prefix", () => {
const line = buildInboundLine({
cfg: makeInboundCfg(""),
agentId: "main",
msg: createDirectMessage({
admission: {
conversation: {
id: "whatsapp:+15550001111",
},
},
body: "ping",
to: "+2666",
}),
envelope: { includeTimestamp: false },
});
expect(line).toContain("+15550001111");
expect(line).not.toContain("whatsapp:+15550001111");
});
});
describe("formatReplyContext", () => {
it("returns null when replyToBody is missing", () => {
expect(formatReplyContext({} as never)).toBeNull();
});
it("uses unknown sender label when reply sender is absent", () => {
expect(
formatReplyContext(
createDirectMessage({
replyToBody: "original",
}),
),
).toBe("[Replying to unknown sender]\noriginal\n[/Replying]");
});
});

View File

@@ -0,0 +1,453 @@
// Whatsapp tests cover web auto reply utils plugin behavior.
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { normalizeMainKey } from "openclaw/plugin-sdk/routing";
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { withTempDir } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it, vi } from "vitest";
import { createTestWebInboundMessage } from "../inbound/test-message.test-helper.js";
import type { AdmittedWebInboundMessage } from "../inbound/types.js";
import {
evaluateSessionFreshness,
loadSessionStore,
resolveChannelResetConfig,
resolveSessionKey,
resolveSessionResetPolicy,
resolveSessionResetType,
resolveStorePath,
resolveThreadFlag,
} from "./config.runtime.js";
import {
debugMention,
isBotMentionedFromTargets,
resolveMentionTargets,
resolveOwnerList,
} from "./mentions.js";
import { elide, isLikelyWhatsAppCryptoError } from "./util.js";
type TestMessageOverrides = {
admission?: NonNullable<Parameters<typeof createTestWebInboundMessage>[0]>["admission"];
body?: string;
mentionedJids?: string[];
selfE164?: string;
selfJid?: string;
selfLid?: string;
};
const makeMsg = (overrides: TestMessageOverrides): AdmittedWebInboundMessage => {
const conversationId = overrides.admission?.conversation?.id ?? "120363401234567890@g.us";
const conversationKind = overrides.admission?.conversation?.kind ?? "group";
return createTestWebInboundMessage({
event: { id: "m1" },
payload: { body: overrides.body ?? "" },
platform: {
chatJid: conversationId,
recipientJid: "15551234567@s.whatsapp.net",
selfE164: overrides.selfE164,
selfJid: overrides.selfJid,
selfLid: overrides.selfLid,
},
admission: {
...overrides.admission,
accountId: overrides.admission?.accountId ?? "default",
conversation: {
kind: conversationKind,
id: conversationId,
...overrides.admission?.conversation,
},
sender: {
id: conversationId,
...overrides.admission?.sender,
},
senderAccess: {
reasonCode:
conversationKind === "direct" ? "dm_policy_allowlisted" : "group_policy_allowed",
...overrides.admission?.senderAccess,
},
},
group: {
mentions: {
jids: overrides.mentionedJids,
},
},
});
};
function getSessionSnapshotForTest(
cfg: OpenClawConfig,
from: string,
ctx?: {
sessionKey?: string | null;
isGroup?: boolean;
messageThreadId?: string | number | null;
threadLabel?: string | null;
threadStarterBody?: string | null;
parentSessionKey?: string | null;
},
) {
const sessionCfg = cfg.session;
const scope = sessionCfg?.scope ?? "per-sender";
const key =
ctx?.sessionKey?.trim() ??
resolveSessionKey(
scope,
{ From: from, To: "", Body: "" },
normalizeMainKey(sessionCfg?.mainKey),
);
const store = loadSessionStore(resolveStorePath(sessionCfg?.store));
const entry = store[key];
const isThread = resolveThreadFlag({
sessionKey: key,
messageThreadId: ctx?.messageThreadId ?? null,
threadLabel: ctx?.threadLabel ?? null,
threadStarterBody: ctx?.threadStarterBody ?? null,
parentSessionKey: ctx?.parentSessionKey ?? null,
});
const resetType = resolveSessionResetType({ sessionKey: key, isGroup: ctx?.isGroup, isThread });
const resetPolicy = resolveSessionResetPolicy({
sessionCfg,
resetType,
resetOverride: resolveChannelResetConfig({
sessionCfg,
channel: entry?.lastChannel ?? entry?.channel,
}),
});
const freshness = entry
? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now: Date.now(), policy: resetPolicy })
: { fresh: false };
return {
key,
entry,
fresh: freshness.fresh,
resetPolicy,
resetType,
dailyResetAt: freshness.dailyResetAt,
idleExpiresAt: freshness.idleExpiresAt,
};
}
describe("isBotMentionedFromTargets", () => {
const mentionCfg = { mentionRegexes: [/\bopenclaw\b/i] };
function expectMentioned(
msg: AdmittedWebInboundMessage,
cfg: { mentionRegexes: RegExp[]; allowFrom?: Array<string | number>; isSelfChat?: boolean },
expected: boolean,
) {
const targets = resolveMentionTargets(msg);
expect(isBotMentionedFromTargets(msg, cfg, targets)).toBe(expected);
}
it("ignores regex matches when other mentions are present", () => {
const msg = makeMsg({
body: "@OpenClaw please help",
mentionedJids: ["19998887777@s.whatsapp.net"],
selfE164: "+15551234567",
selfJid: "15551234567@s.whatsapp.net",
});
expectMentioned(msg, mentionCfg, false);
});
it("matches explicit self mentions", () => {
const msg = makeMsg({
body: "hey",
mentionedJids: ["15551234567@s.whatsapp.net"],
selfE164: "+15551234567",
selfJid: "15551234567@s.whatsapp.net",
});
expectMentioned(msg, mentionCfg, true);
});
it("falls back to regex when no mentions are present", () => {
const msg = makeMsg({
body: "openclaw can you help?",
selfE164: "+15551234567",
selfJid: "15551234567@s.whatsapp.net",
});
expectMentioned(msg, mentionCfg, true);
});
it("ignores JID mentions in a true 1:1 self-chat (not a group)", () => {
const cfg = { mentionRegexes: [/\bopenclaw\b/i], allowFrom: ["+999"] };
const msg = makeMsg({
// Direct chat with self, not a group — the original "ignore mentions
// in self-chat" suppression still applies here so that mentioning the
// owner in their own DM does not falsely trigger the bot.
admission: {
conversation: {
kind: "direct",
id: "999@s.whatsapp.net",
},
},
body: "@owner ping",
mentionedJids: ["999@s.whatsapp.net"],
selfE164: "+999",
selfJid: "999@s.whatsapp.net",
});
expectMentioned(msg, cfg, false);
const msgTextMention = makeMsg({
admission: {
conversation: {
kind: "direct",
id: "999@s.whatsapp.net",
},
},
body: "openclaw ping",
selfE164: "+999",
selfJid: "999@s.whatsapp.net",
});
expectMentioned(msgTextMention, cfg, true);
});
it("detects an explicit group @mention even when self is in allowFrom (#49317)", () => {
// Operator config commonly puts their own E.164 in allowFrom so they can
// run owner-only commands in groups; previously, that flipped the gate
// to "self-chat mode" and silently dropped mention detection in groups,
// including LID-style WhatsApp mentions that resolve to the bot's own
// E.164. After the fix, group conversations honor the identity-overlap
// check regardless of allowFrom.
const cfg = { mentionRegexes: [/\bopenclaw\b/i], allowFrom: ["+15551234567"] };
const msg = makeMsg({
// Default `from` is the @g.us group JID from `makeMsg`.
body: "@216372600647751 can you see this?",
mentionedJids: ["216372600647751@lid"],
selfE164: "+15551234567",
selfJid: "15551234567@s.whatsapp.net",
selfLid: "216372600647751@lid",
});
expectMentioned(msg, cfg, true);
});
it("honors explicit self-chat overrides without recomputing from allowFrom", () => {
const cfg = {
mentionRegexes: [/\bopenclaw\b/i],
allowFrom: ["+15551230000"],
isSelfChat: true,
};
const msg = makeMsg({
body: "@owner ping",
mentionedJids: ["999@s.whatsapp.net"],
selfE164: "+999",
selfJid: "999@s.whatsapp.net",
});
expectMentioned(msg, cfg, false);
});
it("matches fallback number mentions when regexes do not match", () => {
const msg = makeMsg({
body: "please check +1 555 123 4567",
selfE164: "+15551234567",
selfJid: "15551234567@s.whatsapp.net",
});
expectMentioned(msg, { mentionRegexes: [] }, true);
});
});
describe("resolveMentionTargets with @lid mapping", () => {
it("uses @lid reverse mapping for mentions and self identity", async () => {
await withTempDir("openclaw-lid-mapping-", async (authDir) => {
await fs.writeFile(
path.join(authDir, "lid-mapping-777_reverse.json"),
JSON.stringify("+1777"),
);
const mentionTargets = resolveMentionTargets(
makeMsg({
body: "ping",
mentionedJids: ["777@lid"],
selfE164: "+15551234567",
selfJid: "15551234567@s.whatsapp.net",
}),
authDir,
);
expect(mentionTargets.normalizedMentions).toEqual([
{
jid: null,
lid: "777@lid",
e164: "+1777",
},
]);
const selfTargets = resolveMentionTargets(
makeMsg({
body: "ping",
selfJid: "777@lid",
}),
authDir,
);
expect(selfTargets.self).toEqual({
jid: null,
lid: "777@lid",
e164: "+1777",
});
});
});
});
describe("getSessionSnapshot", () => {
it("uses channel reset overrides when configured", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
try {
await withTempDir("openclaw-snapshot-", async (root) => {
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:s1";
await upsertSessionEntry({
storePath,
sessionKey,
entry: {
sessionId: "snapshot-session",
updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(),
lastChannel: "whatsapp",
},
});
const cfg = {
session: {
store: storePath,
reset: { mode: "daily", atHour: 4, idleMinutes: 240 },
resetByChannel: {
whatsapp: { mode: "idle", idleMinutes: 360 },
},
},
} as OpenClawConfig;
const snapshot = getSessionSnapshotForTest(cfg, "whatsapp:+15550001111", {
sessionKey,
});
expect(snapshot.resetPolicy.mode).toBe("idle");
expect(snapshot.resetPolicy.idleMinutes).toBe(360);
expect(snapshot.fresh).toBe(true);
expect(snapshot.dailyResetAt).toBeUndefined();
});
} finally {
vi.useRealTimers();
}
});
});
describe("web auto-reply util", () => {
describe("mentions diagnostics", () => {
it("returns normalized debug fields and mention outcome", () => {
const msg = makeMsg({
admission: {
conversation: {
id: "777@lid",
},
},
body: "openclaw ping",
selfE164: "+15551234567",
selfJid: "15551234567@s.whatsapp.net",
});
const result = debugMention(msg, { mentionRegexes: [/\bopenclaw\b/i] });
expect(result.wasMentioned).toBe(true);
expect(result.details.bodyClean).toBe("openclaw ping");
expect(result.details.normalizedMentionedJids).toBeNull();
});
it("resolves owner list from allowFrom or falls back to self", () => {
expect(
resolveOwnerList(
{
mentionRegexes: [],
allowFrom: ["*", " +1 555 000 1111 "],
},
null,
),
).toEqual(["+15550001111"]);
expect(resolveOwnerList({ mentionRegexes: [] }, "+1 555 000 2222")).toEqual(["+15550002222"]);
});
});
describe("elide", () => {
const hasLoneSurrogate = (value: string): boolean =>
Array.from(value).some((char) => {
if (char.length !== 1) {
return false;
}
const codeUnit = char.charCodeAt(0);
return codeUnit >= 0xd800 && codeUnit <= 0xdfff;
});
it("returns undefined for undefined input", () => {
expect(elide(undefined)).toBe(undefined);
});
it("returns input when under limit", () => {
expect(elide("hi", 10)).toBe("hi");
});
it("truncates and annotates when over limit", () => {
expect(elide("abcdef", 3)).toBe("abc… (truncated 3 chars)");
});
it("does not split surrogate pairs when the limit lands inside an emoji", () => {
const output = elide("😀😀😀", 5);
expect(output).toBe("😀😀… (truncated 2 chars)");
expect(hasLoneSurrogate(output ?? "")).toBe(false);
});
it("keeps a complete astral character when it fits before the limit", () => {
const output = elide("ab😀cd", 4);
expect(output).toBe("ab😀… (truncated 2 chars)");
expect(hasLoneSurrogate(output ?? "")).toBe(false);
});
});
describe("isLikelyWhatsAppCryptoError", () => {
it("matches known Baileys crypto auth errors (Error)", () => {
const err = new Error("bad mac");
err.stack = "at something\nat baileys/noise-handler\n";
expect(isLikelyWhatsAppCryptoError(err)).toBe(true);
});
it("returns false for circular objects", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(isLikelyWhatsAppCryptoError(circular)).toBe(false);
});
const cases: Array<{ name: string; value: unknown; expected: boolean }> = [
{ name: "returns false for non-matching Error", value: new Error("boom"), expected: false },
{ name: "returns false for non-matching string", value: "boom", expected: false },
{
name: "returns false for bad-mac object without whatsapp/baileys markers",
value: { message: "bad mac" },
expected: false,
},
{
name: "matches known Baileys crypto auth errors (string, unsupported state)",
value: "baileys: unsupported state or unable to authenticate data (noise-handler)",
expected: true,
},
{
name: "matches known Baileys crypto auth errors (string, bad mac)",
value: "bad mac in aesDecryptGCM (baileys)",
expected: true,
},
{ name: "handles null reason without throwing", value: null, expected: false },
{ name: "handles number reason without throwing", value: 123, expected: false },
{ name: "handles boolean reason without throwing", value: true, expected: false },
{ name: "handles bigint reason without throwing", value: 123n, expected: false },
{ name: "handles symbol reason without throwing", value: Symbol("bad mac"), expected: false },
{
name: "handles function reason without throwing",
value: function namedFn() {},
expected: false,
},
];
for (const testCase of cases) {
it(testCase.name, () => {
expect(isLikelyWhatsAppCryptoError(testCase.value)).toBe(testCase.expected);
});
}
});
});