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
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:
17
extensions/whatsapp/src/auto-reply/config.runtime.ts
Normal file
17
extensions/whatsapp/src/auto-reply/config.runtime.ts
Normal 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";
|
||||
2
extensions/whatsapp/src/auto-reply/constants.ts
Normal file
2
extensions/whatsapp/src/auto-reply/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Whatsapp plugin module implements constants behavior.
|
||||
export const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024;
|
||||
1014
extensions/whatsapp/src/auto-reply/deliver-reply.test.ts
Normal file
1014
extensions/whatsapp/src/auto-reply/deliver-reply.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
365
extensions/whatsapp/src/auto-reply/deliver-reply.ts
Normal file
365
extensions/whatsapp/src/auto-reply/deliver-reply.ts
Normal 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();
|
||||
}
|
||||
7
extensions/whatsapp/src/auto-reply/loggers.ts
Normal file
7
extensions/whatsapp/src/auto-reply/loggers.ts
Normal 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");
|
||||
141
extensions/whatsapp/src/auto-reply/mentions.ts
Normal file
141
extensions/whatsapp/src/auto-reply/mentions.ts
Normal 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));
|
||||
}
|
||||
136
extensions/whatsapp/src/auto-reply/monitor-state.test.ts
Normal file
136
extensions/whatsapp/src/auto-reply/monitor-state.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
128
extensions/whatsapp/src/auto-reply/monitor-state.ts
Normal file
128
extensions/whatsapp/src/auto-reply/monitor-state.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
714
extensions/whatsapp/src/auto-reply/monitor.ts
Normal file
714
extensions/whatsapp/src/auto-reply/monitor.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
110
extensions/whatsapp/src/auto-reply/monitor/ack-emoji.test.ts
Normal file
110
extensions/whatsapp/src/auto-reply/monitor/ack-emoji.test.ts
Normal 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("👀");
|
||||
});
|
||||
});
|
||||
28
extensions/whatsapp/src/auto-reply/monitor/ack-emoji.ts
Normal file
28
extensions/whatsapp/src/auto-reply/monitor/ack-emoji.ts
Normal 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;
|
||||
}
|
||||
205
extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts
Normal file
205
extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
115
extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts
Normal file
115
extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts
Normal 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)}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
156
extensions/whatsapp/src/auto-reply/monitor/broadcast.ts
Normal file
156
extensions/whatsapp/src/auto-reply/monitor/broadcast.ts
Normal 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;
|
||||
}
|
||||
20
extensions/whatsapp/src/auto-reply/monitor/commands.ts
Normal file
20
extensions/whatsapp/src/auto-reply/monitor/commands.ts
Normal 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();
|
||||
}
|
||||
65
extensions/whatsapp/src/auto-reply/monitor/echo.ts
Normal file
65
extensions/whatsapp/src/auto-reply/monitor/echo.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Whatsapp plugin module implements group activation behavior.
|
||||
export { normalizeGroupActivation } from "openclaw/plugin-sdk/group-activation";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
277
extensions/whatsapp/src/auto-reply/monitor/group-gating.ts
Normal file
277
extensions/whatsapp/src/auto-reply/monitor/group-gating.ts
Normal 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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
66
extensions/whatsapp/src/auto-reply/monitor/group-members.ts
Normal file
66
extensions/whatsapp/src/auto-reply/monitor/group-members.ts
Normal 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(", ");
|
||||
}
|
||||
@@ -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)",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
102
extensions/whatsapp/src/auto-reply/monitor/inbound-context.ts
Normal file
102
extensions/whatsapp/src/auto-reply/monitor/inbound-context.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
1630
extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts
Normal file
1630
extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
906
extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts
Normal file
906
extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts
Normal 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();
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
53
extensions/whatsapp/src/auto-reply/monitor/last-route.ts
Normal file
53
extensions/whatsapp/src/auto-reply/monitor/last-route.ts
Normal 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);
|
||||
}
|
||||
29
extensions/whatsapp/src/auto-reply/monitor/listener-log.ts
Normal file
29
extensions/whatsapp/src/auto-reply/monitor/listener-log.ts
Normal 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}).`;
|
||||
}
|
||||
@@ -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]";
|
||||
}
|
||||
71
extensions/whatsapp/src/auto-reply/monitor/message-line.ts
Normal file
71
extensions/whatsapp/src/auto-reply/monitor/message-line.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
450
extensions/whatsapp/src/auto-reply/monitor/on-message.ts
Normal file
450
extensions/whatsapp/src/auto-reply/monitor/on-message.ts
Normal 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 } : {}),
|
||||
});
|
||||
};
|
||||
}
|
||||
21
extensions/whatsapp/src/auto-reply/monitor/peer.ts
Normal file
21
extensions/whatsapp/src/auto-reply/monitor/peer.ts
Normal 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;
|
||||
}
|
||||
@@ -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>",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
622
extensions/whatsapp/src/auto-reply/monitor/process-message.ts
Normal file
622
extensions/whatsapp/src/auto-reply/monitor/process-message.ts
Normal 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;
|
||||
}
|
||||
37
extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts
Normal file
37
extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts
Normal 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";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
115
extensions/whatsapp/src/auto-reply/monitor/status-reaction.ts
Normal file
115
extensions/whatsapp/src/auto-reply/monitor/status-reaction.ts
Normal 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)}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Whatsapp plugin module implements reply resolver behavior.
|
||||
export { getReplyFromConfig } from "openclaw/plugin-sdk/reply-runtime";
|
||||
54
extensions/whatsapp/src/auto-reply/types.ts
Normal file
54
extensions/whatsapp/src/auto-reply/types.ts
Normal 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;
|
||||
};
|
||||
79
extensions/whatsapp/src/auto-reply/util.ts
Normal file
79
extensions/whatsapp/src/auto-reply/util.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
453
extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts
Normal file
453
extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user