From eebcb100b83588994b076a58bce263fa0c29e6d7 Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Sat, 30 May 2026 13:40:16 -0300 Subject: [PATCH] refactor(whatsapp): read inbound contexts in auto reply --- .../whatsapp/src/auto-reply/deliver-reply.ts | 64 +++++++-------- .../whatsapp/src/auto-reply/mentions.ts | 24 +++--- extensions/whatsapp/src/auto-reply/monitor.ts | 20 +++-- .../src/auto-reply/monitor/ack-reaction.ts | 27 ++++--- .../src/auto-reply/monitor/broadcast.ts | 8 +- .../src/auto-reply/monitor/group-gating.ts | 24 +++--- .../src/auto-reply/monitor/inbound-context.ts | 13 ++- .../auto-reply/monitor/inbound-dispatch.ts | 75 ++++++++---------- .../src/auto-reply/monitor/message-line.ts | 30 ++++--- .../src/auto-reply/monitor/on-message.ts | 68 ++++++++++------ .../whatsapp/src/auto-reply/monitor/peer.ts | 4 +- .../src/auto-reply/monitor/process-message.ts | 77 ++++++++++-------- .../src/auto-reply/monitor/status-reaction.ts | 10 +-- extensions/whatsapp/src/auto-reply/types.ts | 1 + extensions/whatsapp/src/auto-reply/util.ts | 14 ++++ extensions/whatsapp/src/identity.ts | 79 +++++++++++-------- extensions/whatsapp/src/inbound-policy.ts | 2 +- src/auto-reply/reply/dispatch-from-config.ts | 4 +- src/auto-reply/templating.ts | 5 ++ 19 files changed, 318 insertions(+), 231 deletions(-) diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.ts index 8c3ec7bfbe5b..669ea35790f2 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.ts @@ -14,6 +14,7 @@ import { import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { WhatsAppSendResult } from "../inbound/send-result.js"; import { listWhatsAppSendResultMessageIds } from "../inbound/send-result.js"; +import type { WebInboundMessage } from "../inbound/types.js"; import { loadWebMedia } from "../media.js"; import { type DeliverableWhatsAppOutboundPayload, @@ -28,8 +29,7 @@ import { formatError } from "../session.js"; import { convertMarkdownTables } from "../text-runtime.js"; import { markdownToWhatsApp } from "../text-runtime.js"; import { whatsappOutboundLog } from "./loggers.js"; -import type { WebInboundMsg } from "./types.js"; -import { elide } from "./util.js"; +import { elide, markWhatsAppVisibleDeliveryError } from "./util.js"; export type WhatsAppReplyDeliveryResult = { results: WhatsAppSendResult[]; @@ -84,24 +84,10 @@ function createWhatsAppReplyDeliveryReceipt( }); } -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 async function deliverWebReply(params: { replyResult: ReplyPayload; normalizedReplyResult?: DeliverableWhatsAppOutboundPayload; - msg: WebInboundMsg; + msg: WebInboundMessage; mediaLocalRoots?: readonly string[]; maxMediaBytes: number; textLimit: number; @@ -151,15 +137,20 @@ export async function deliverWebReply(params: { if (!replyResult.replyToId) { return undefined; } - // Use replyToId (not msg.id) so batched payloads quote the correct + // 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.body may be a combined batch body. - const cached = lookupInboundMessageMeta(msg.accountId, msg.chatId, replyResult.replyToId); + // message being quoted — msg.payload.body may be a combined batch body. + const cached = lookupInboundMessageMeta( + msg.accountId, + msg.platform.chatJid, + replyResult.replyToId, + ); return buildQuotedMessageOptions({ messageId: replyResult.replyToId, - remoteJid: msg.chatId, + remoteJid: msg.platform.chatJid, fromMe: cached?.fromMe ?? false, - participant: cached?.participant ?? (msg.chatType === "group" ? msg.senderJid : undefined), + participant: + cached?.participant ?? (msg.chatType === "group" ? msg.platform.senderJid : undefined), messageText: cached?.body ?? "", }); }; @@ -189,7 +180,7 @@ export async function deliverWebReply(params: { for (const [index, chunk] of textChunks.entries()) { const chunkStarted = Date.now(); const quote = getQuote(); - rememberSendResult(await sendWithRetry(() => msg.reply(chunk, quote), "text")); + rememberSendResult(await sendWithRetry(() => msg.platform.reply(chunk, quote), "text")); if (!skipLog) { const durationMs = Date.now() - chunkStarted; whatsappOutboundLog.debug( @@ -199,10 +190,10 @@ export async function deliverWebReply(params: { } const delivery = finishDelivery(); const logPayload = { - correlationId: msg.id ?? newConnectionId(), + correlationId: msg.event.id ?? newConnectionId(), connectionId: connectionId ?? null, to: msg.from, - from: msg.to, + from: msg.platform.recipientJid, text: elide(replyResult.text, 240), mediaUrl: null, mediaSizeBytes: null, @@ -243,7 +234,7 @@ export async function deliverWebReply(params: { rememberSendResult( await sendWithRetry( () => - msg.sendMedia( + msg.platform.sendMedia( { image: media.buffer, caption, @@ -259,7 +250,7 @@ export async function deliverWebReply(params: { rememberSendResult( await sendWithRetry( () => - msg.sendMedia( + msg.platform.sendMedia( { audio: media.buffer, ptt: true, @@ -272,7 +263,7 @@ export async function deliverWebReply(params: { ); if (caption) { rememberSendResult( - await sendWithRetry(() => msg.reply(caption, quote), "media:audio-text"), + await sendWithRetry(() => msg.platform.reply(caption, quote), "media:audio-text"), ); } } else if (media.kind === "video") { @@ -280,7 +271,7 @@ export async function deliverWebReply(params: { rememberSendResult( await sendWithRetry( () => - msg.sendMedia( + msg.platform.sendMedia( { video: media.buffer, caption, @@ -296,7 +287,7 @@ export async function deliverWebReply(params: { rememberSendResult( await sendWithRetry( () => - msg.sendMedia( + msg.platform.sendMedia( { document: media.buffer, fileName: media.fileName, @@ -314,10 +305,10 @@ export async function deliverWebReply(params: { ); replyLogger.info( { - correlationId: msg.id ?? newConnectionId(), + correlationId: msg.event.id ?? newConnectionId(), connectionId: connectionId ?? null, to: msg.from, - from: msg.to, + from: msg.platform.recipientJid, text: caption ?? null, mediaUrl, mediaSizeBytes: media.buffer.length, @@ -341,14 +332,19 @@ export async function deliverWebReply(params: { } whatsappOutboundLog.warn(`Media skipped; sent text-only to ${msg.from}`); rememberSendResult( - await sendWithRetry(() => msg.reply(fallbackText, getQuote()), "media:fallback-text"), + await sendWithRetry( + () => msg.platform.reply(fallbackText, getQuote()), + "media:fallback-text", + ), ); }, }); // Remaining text chunks after media for (const chunk of remainingText) { - rememberSendResult(await sendWithRetry(() => msg.reply(chunk, getQuote()), "media:text")); + rememberSendResult( + await sendWithRetry(() => msg.platform.reply(chunk, getQuote()), "media:text"), + ); } return finishDelivery(); } diff --git a/extensions/whatsapp/src/auto-reply/mentions.ts b/extensions/whatsapp/src/auto-reply/mentions.ts index 26d5b234e9bf..c03058804ecb 100644 --- a/extensions/whatsapp/src/auto-reply/mentions.ts +++ b/extensions/whatsapp/src/auto-reply/mentions.ts @@ -11,9 +11,9 @@ import { identitiesOverlap, type WhatsAppIdentity, } from "../identity.js"; +import type { WebInboundMessage } from "../inbound/types.js"; import { isWhatsAppGroupJid } from "../normalize-target.js"; import { isSelfChatMode, normalizeE164 } from "../text-runtime.js"; -import type { WebInboundMsg } from "./types.js"; export type MentionConfig = { mentionRegexes: RegExp[]; @@ -35,14 +35,14 @@ export function buildMentionConfig( return { mentionRegexes, allowFrom: cfg.channels?.whatsapp?.allowFrom }; } -export function resolveMentionTargets(msg: WebInboundMsg, authDir?: string): MentionTargets { +export function resolveMentionTargets(msg: WebInboundMessage, authDir?: string): MentionTargets { const normalizedMentions = getMentionIdentities(msg, authDir); const self = getSelfIdentity(msg, authDir); return { normalizedMentions, self }; } export function isBotMentionedFromTargets( - msg: WebInboundMsg, + msg: WebInboundMessage, mentionCfg: MentionConfig, targets: MentionTargets, ): boolean { @@ -78,7 +78,7 @@ export function isBotMentionedFromTargets( } 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.body); + const bodyClean = clean(msg.payload.body); if (mentionCfg.mentionRegexes.some((re) => re.test(bodyClean))) { return true; } @@ -91,7 +91,7 @@ export function isBotMentionedFromTargets( if (bodyDigits.includes(selfDigits)) { return true; } - const bodyNoSpace = msg.body.replace(/[\s-]/g, ""); + const bodyNoSpace = msg.payload.body.replace(/[\s-]/g, ""); const pattern = new RegExp(`\\+?${selfDigits}`, "i"); if (pattern.test(bodyNoSpace)) { return true; @@ -103,7 +103,7 @@ export function isBotMentionedFromTargets( } export function debugMention( - msg: WebInboundMsg, + msg: WebInboundMessage, mentionCfg: MentionConfig, authDir?: string, ): { wasMentioned: boolean; details: Record } { @@ -111,15 +111,15 @@ export function debugMention( const result = isBotMentionedFromTargets(msg, mentionCfg, mentionTargets); const details = { from: msg.from, - body: msg.body, - bodyClean: normalizeMentionText(msg.body), - mentionedJids: msg.mentions ?? msg.mentionedJids ?? null, + 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.self?.jid ?? msg.selfJid ?? null, - selfLid: msg.self?.lid ?? msg.selfLid ?? null, - selfE164: msg.self?.e164 ?? msg.selfE164 ?? 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 }; diff --git a/extensions/whatsapp/src/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts index c04d283673a8..4f5ad9a7c4cb 100644 --- a/extensions/whatsapp/src/auto-reply/monitor.ts +++ b/extensions/whatsapp/src/auto-reply/monitor.ts @@ -26,7 +26,9 @@ import { type ManagedWhatsAppListener, } from "../connection-controller.js"; import { resolveWhatsAppInboundPolicy } from "../inbound-policy.js"; +import { normalizeWebInboundMessage } from "../inbound/message-aliases.js"; import { attachWebInboxToSocket, type WhatsAppGroupMetadataCache } from "../inbound/monitor.js"; +import type { WebInboundMessageInput } from "../inbound/types.js"; import { newConnectionId, resolveHeartbeatSeconds, @@ -42,7 +44,7 @@ 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 { WebInboundMsg, WebMonitorTuning } from "./types.js"; +import type { WebMonitorTuning } from "./types.js"; import { isLikelyWhatsAppCryptoError } from "./util.js"; function isNonRetryableWebCloseStatus(statusCode: unknown): boolean { @@ -286,17 +288,18 @@ export async function monitorWebChannel( accountId: account.accountId, }), }); - const shouldDebounce = (msg: WebInboundMsg) => { - if (msg.mediaPath || msg.mediaType) { + const shouldDebounce = (msg: WebInboundMessageInput) => { + const normalized = normalizeWebInboundMessage(msg); + if (normalized.payload.media?.path || normalized.payload.media?.type) { return false; } - if (msg.location) { + if (normalized.payload.location) { return false; } - if (msg.replyToId || msg.replyToBody) { + if (normalized.quote?.id || normalized.quote?.body) { return false; } - return !isControlCommandMessage(msg.body, cfg); + return !isControlCommandMessage(normalized.payload.body, cfg); }; let connection; @@ -337,11 +340,12 @@ export async function monitorWebChannel( disconnectRetryPolicy: reconnectPolicy, disconnectRetryAbortSignal: controller.getDisconnectRetryAbortSignal(), groupMetadataCache, - onMessage: async (msg: WebInboundMsg) => { + onMessage: async (msg: WebInboundMessageInput) => { + const normalized = normalizeWebInboundMessage(msg); const inboundAt = Date.now(); controller.noteInbound(inboundAt); statusController.noteInbound(inboundAt); - await onMessage(msg); + await onMessage(normalized); }, sock, })) as ManagedWhatsAppListener; diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts index 391a2226f326..650063576226 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts @@ -7,16 +7,16 @@ import { import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { getSenderIdentity } from "../../identity.js"; +import type { WebInboundMessage } from "../../inbound/types.js"; import { resolveWhatsAppReactionLevel } from "../../reaction-level.js"; import { sendReactionWhatsApp } from "../../send.js"; import { formatError } from "../../session.js"; -import type { WebInboundMsg } from "../types.js"; import { resolveWhatsAppAckEmoji } from "./ack-emoji.js"; import { resolveGroupActivationFor } from "./group-activation.js"; export async function maybeSendAckReaction(params: { cfg: OpenClawConfig; - msg: WebInboundMsg; + msg: WebInboundMessage; agentId: string; sessionKey: string; conversationId: string; @@ -25,7 +25,7 @@ export async function maybeSendAckReaction(params: { info: (obj: unknown, msg: string) => void; warn: (obj: unknown, msg: string) => void; }): Promise { - if (!params.msg.id) { + if (!params.msg.event.id) { return null; } @@ -75,7 +75,7 @@ export async function maybeSendAckReaction(params: { } params.info( - { chatId: params.msg.chatId, messageId: params.msg.id, emoji }, + { chatId: params.msg.platform.chatJid, messageId: params.msg.event.id, emoji }, "sending ack reaction", ); const sender = getSenderIdentity(params.msg); @@ -88,18 +88,27 @@ export async function maybeSendAckReaction(params: { }; return createAckReactionHandle({ ackReactionValue: emoji, - send: () => sendReactionWhatsApp(params.msg.chatId, params.msg.id!, emoji, reactionOptions), - remove: () => sendReactionWhatsApp(params.msg.chatId, params.msg.id!, "", reactionOptions), + 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.chatId, - messageId: params.msg.id, + chatId: params.msg.platform.chatJid, + messageId: params.msg.event.id, }, "failed to send ack reaction", ); - logVerbose(`WhatsApp ack reaction failed for chat ${params.msg.chatId}: ${formatError(err)}`); + logVerbose( + `WhatsApp ack reaction failed for chat ${params.msg.platform.chatJid}: ${formatError(err)}`, + ); }, }); } diff --git a/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts b/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts index c822540f2479..22bab964624c 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts @@ -9,14 +9,14 @@ import { normalizeAgentId, } from "openclaw/plugin-sdk/routing"; import { resolveWhatsAppGroupSessionRoute } from "../../group-session-key.js"; +import type { WebInboundMessage } from "../../inbound/types.js"; import { formatError } from "../../session.js"; import { whatsappInboundLog } from "../loggers.js"; -import type { WebInboundMsg } from "../types.js"; import type { GroupHistoryEntry } from "./inbound-context.js"; function buildBroadcastRouteKeys(params: { cfg: OpenClawConfig; - msg: WebInboundMsg; + msg: WebInboundMessage; route: ReturnType; peerId: string; agentId: string; @@ -49,13 +49,13 @@ function buildBroadcastRouteKeys(params: { export async function maybeBroadcastMessage(params: { cfg: OpenClawConfig; - msg: WebInboundMsg; + msg: WebInboundMessage; peerId: string; route: ReturnType; groupHistoryKey: string; groupHistories: Map; processMessage: ( - msg: WebInboundMsg, + msg: WebInboundMessage, route: ReturnType, groupHistoryKey: string, opts?: { diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts index 4ce9734a5328..9f167a228a14 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts @@ -10,9 +10,9 @@ import { identitiesOverlap, } from "../../identity.js"; import { resolveWhatsAppInboundPolicy } from "../../inbound-policy.js"; +import type { WebInboundMessage } from "../../inbound/types.js"; import type { MentionConfig } from "../mentions.js"; import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js"; -import type { WebInboundMsg } from "../types.js"; import { stripMentionsForCommand } from "./commands.js"; import { resolveGroupActivationFor } from "./group-activation.js"; import { @@ -35,7 +35,7 @@ export type GroupHistoryEntry = { type ApplyGroupGatingParams = { cfg: OpenClawConfig; - msg: WebInboundMsg; + msg: WebInboundMessage; mentionText?: string; deferMissingMention?: boolean; conversationId: string; @@ -78,7 +78,7 @@ function shouldWarnForGroupDrop(warnKey: string): boolean { return true; } -function isOwnerSender(baseMentionConfig: MentionConfig, msg: WebInboundMsg) { +function isOwnerSender(baseMentionConfig: MentionConfig, msg: WebInboundMessage) { const sender = normalizeE164(getSenderIdentity(msg).e164 ?? ""); if (!sender) { return false; @@ -88,7 +88,7 @@ function isOwnerSender(baseMentionConfig: MentionConfig, msg: WebInboundMsg) { } function recordPendingGroupHistoryEntry(params: { - msg: WebInboundMsg; + msg: WebInboundMessage; body?: string; groupHistories: Map; groupHistoryKey: string; @@ -107,10 +107,10 @@ function recordPendingGroupHistoryEntry(params: { limit: params.groupHistoryLimit, entry: { sender, - body: params.body ?? params.msg.body, - timestamp: params.msg.timestamp, - id: params.msg.id, - senderJid: senderIdentity.jid ?? params.msg.senderJid, + body: params.body ?? params.msg.payload.body, + timestamp: params.msg.event.timestamp, + id: params.msg.event.id, + senderJid: senderIdentity.jid ?? params.msg.platform.senderJid, }, }); } @@ -178,9 +178,11 @@ export async function applyGroupGating(params: ApplyGroupGatingParams) { allowFrom: inboundPolicy.configuredAllowFrom, }; const mentionMsg = - params.mentionText !== undefined ? { ...params.msg, body: params.mentionText } : params.msg; + params.mentionText !== undefined + ? { ...params.msg, payload: { ...params.msg.payload, body: params.mentionText } } + : params.msg; const commandBody = stripMentionsForCommand( - mentionMsg.body, + mentionMsg.payload.body, mentionConfig.mentionRegexes, self.e164, ); @@ -251,7 +253,7 @@ export async function applyGroupGating(params: ApplyGroupGatingParams) { } return skipGroupMessageAndStoreHistory( params, - `Group message stored for context (no mention detected) in ${params.conversationId}: ${mentionMsg.body}`, + `Group message stored for context (no mention detected) in ${params.conversationId}: ${mentionMsg.payload.body}`, params.mentionText, ); } diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-context.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-context.ts index 2873761865d7..e09903229713 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-context.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-context.ts @@ -4,11 +4,12 @@ import { filterSupplementalContextItems } from "openclaw/plugin-sdk/security-run import { getComparableIdentityValues, getReplyContext, + resolveComparableIdentity, type WhatsAppIdentity, type WhatsAppReplyContext, } from "../../identity.js"; +import type { WebInboundMessage } from "../../inbound/types.js"; import { normalizeE164 } from "../../text-runtime.js"; -import type { WebInboundMsg } from "../types.js"; export type GroupHistoryEntry = { sender: string; @@ -22,12 +23,15 @@ 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(params.sender)); + const senderValues = new Set( + getComparableIdentityValues(resolveComparableIdentity(params.sender, params.authDir)), + ); if (senderValues.size === 0) { return false; } @@ -45,6 +49,7 @@ function isWhatsAppSupplementalSenderAllowed(params: { } export function resolveVisibleWhatsAppGroupHistory(params: { + authDir?: string; history: GroupHistoryEntry[]; mode: ContextVisibilityMode; groupPolicy: "open" | "allowlist" | "disabled"; @@ -60,13 +65,14 @@ export function resolveVisibleWhatsAppGroupHistory(params: { isSenderAllowed: (entry) => isWhatsAppSupplementalSenderAllowed({ allowFrom: params.groupAllowFrom, + authDir: params.authDir, sender: entry.senderJid ? { jid: entry.senderJid } : null, }), }).items; } export function resolveVisibleWhatsAppReplyContext(params: { - msg: WebInboundMsg; + msg: WebInboundMessage; authDir?: string; mode: ContextVisibilityMode; groupPolicy: "open" | "allowlist" | "disabled"; @@ -81,6 +87,7 @@ export function resolveVisibleWhatsAppReplyContext(params: { ? true : isWhatsAppSupplementalSenderAllowed({ allowFrom: params.groupAllowFrom, + authDir: params.authDir, sender: replyTo.sender, }); const visible = filterChannelInboundQuoteContext(params.mode, { diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts index 29c087d877e6..5e343bfe229a 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts @@ -13,13 +13,14 @@ import { deliverInboundReplyWithMessageSendContext } from "openclaw/plugin-sdk/c 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 type { WebInboundMessage } from "../../inbound/types.js"; import { type DeliverableWhatsAppOutboundPayload, normalizeWhatsAppOutboundPayload, normalizeWhatsAppPayloadTextPreservingIndentation, } from "../../outbound-media-contract.js"; import type { WhatsAppReplyDeliveryResult } from "../deliver-reply.js"; -import type { WebInboundMsg } from "../types.js"; +import { markWhatsAppVisibleDeliveryError } from "../util.js"; import { formatGroupMembers } from "./group-members.js"; import type { GroupHistoryEntry } from "./inbound-context.js"; import { @@ -109,25 +110,11 @@ function whatsAppReplyDeliveryVisibilityFromDurableResult(result: { return whatsAppReplyDeliveryVisibility(result.visibleReplySent === true); } -function markWhatsAppReplyDeliveryErrorVisible(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; -} - function markWhatsAppReplyDeliveryErrorVisibleAfterFlush( error: unknown, flushResult: WhatsAppMediaOnlyFlushResult, ): unknown { - return flushResult.delivered > 0 ? markWhatsAppReplyDeliveryErrorVisible(error) : error; + return flushResult.delivered > 0 ? markWhatsAppVisibleDeliveryError(error) : error; } function logWhatsAppReplyDeliveryError(params: { @@ -135,19 +122,19 @@ function logWhatsAppReplyDeliveryError(params: { info: ReplyDeliveryInfo; connectionId: string; conversationId: string; - msg: WebInboundMsg; + msg: WebInboundMessage; replyLogger: ReturnType; }) { params.replyLogger.error( { err: normalizeErrForLog(params.err), replyKind: params.info.kind, - correlationId: params.msg.id ?? null, + correlationId: params.msg.event.id ?? null, connectionId: params.connectionId, conversationId: params.conversationId, - chatId: params.msg.chatId ?? null, + chatId: params.msg.platform.chatJid ?? null, to: params.msg.from ?? null, - from: params.msg.to ?? null, + from: params.msg.platform.recipientJid ?? null, }, "auto-reply delivery failed", ); @@ -288,7 +275,7 @@ export async function buildWhatsAppInboundContext(params: { groupHistory?: GroupHistoryEntry[]; groupMemberRoster?: Map; groupSystemPrompt?: string; - msg: WebInboundMsg; + msg: WebInboundMessage; rawBody?: string; route: ReturnType; sender: SenderContext; @@ -296,6 +283,7 @@ export async function buildWhatsAppInboundContext(params: { mediaTranscribedIndexes?: number[]; replyThreading?: ReplyThreadingContext; visibleReplyTo?: VisibleReplyTarget; + suppressMessageReceivedHooks?: boolean; }): Promise { const inboundHistory = params.msg.chatType === "group" @@ -311,12 +299,12 @@ export async function buildWhatsAppInboundContext(params: { : undefined; const media = toInboundMediaFacts( - params.msg.mediaPath || params.msg.mediaUrl + params.msg.payload.media?.path || params.msg.payload.media?.url ? [ { - path: params.msg.mediaPath, - url: params.msg.mediaUrl ?? params.msg.mediaPath, - contentType: params.msg.mediaType, + path: params.msg.payload.media?.path, + url: params.msg.payload.media?.url ?? params.msg.payload.media?.path, + contentType: params.msg.payload.media?.type, }, ] : undefined, @@ -334,11 +322,11 @@ export async function buildWhatsAppInboundContext(params: { } : undefined, groupSystemPrompt: params.groupSystemPrompt, - untrustedContext: params.msg.untrustedStructuredContext, + untrustedContext: params.msg.payload.untrustedStructuredContext, }, media, - messageId: params.msg.id, - timestamp: params.msg.timestamp, + messageId: params.msg.event.id, + timestamp: params.msg.event.timestamp, from: params.msg.from, sender: { id: params.sender.id ?? params.sender.e164, @@ -355,15 +343,15 @@ export async function buildWhatsAppInboundContext(params: { routeSessionKey: params.route.sessionKey, }, reply: { - to: params.msg.to, + to: params.msg.platform.recipientJid, originatingTo: params.msg.from, }, message: { body: params.combinedBody, - bodyForAgent: params.bodyForAgent ?? params.msg.body, + bodyForAgent: params.bodyForAgent ?? params.msg.payload.body, inboundHistory, - rawBody: params.rawBody ?? params.msg.body, - commandBody: params.commandBody ?? params.msg.body, + rawBody: params.rawBody ?? params.msg.payload.body, + commandBody: params.commandBody ?? params.msg.payload.body, }, access: { ...(params.msg.wasMentioned !== undefined @@ -381,9 +369,9 @@ export async function buildWhatsAppInboundContext(params: { commandTurn: params.commandTurn, extra: { Transcript: params.transcript, - GroupSubject: params.msg.groupSubject, + GroupSubject: params.msg.group?.subject, GroupMembers: formatGroupMembers({ - participants: params.msg.groupParticipants, + participants: params.msg.group?.participants, roster: params.groupMemberRoster, fallbackE164: params.sender.e164, }), @@ -394,7 +382,8 @@ export async function buildWhatsAppInboundContext(params: { ? params.commandTurn.source : undefined), ReplyThreading: params.replyThreading, - ...(params.msg.location ? toLocationContext(params.msg.location) : {}), + SuppressMessageReceivedHooks: params.suppressMessageReceivedHooks, + ...(params.msg.payload.location ? toLocationContext(params.msg.payload.location) : {}), }, }); } @@ -437,7 +426,7 @@ function normalizeCommandTurnFromContext(value: unknown): CommandTurnContext | u } export function resolveWhatsAppDmRouteTarget(params: { - msg: WebInboundMsg; + msg: WebInboundMessage; senderE164?: string; normalizeE164: (value: string) => string | null; }): string | undefined { @@ -518,7 +507,7 @@ export async function dispatchWhatsAppBufferedReply(params: { deliverReply: (params: { replyResult: ReplyPayload; normalizedReplyResult?: DeliverableWhatsAppOutboundPayload; - msg: WebInboundMsg; + msg: WebInboundMessage; mediaLocalRoots: readonly string[]; maxMediaBytes: number; textLimit: number; @@ -532,7 +521,7 @@ export async function dispatchWhatsAppBufferedReply(params: { groupHistoryKey: string; maxMediaBytes: number; maxMediaTextChunkLimit?: number; - msg: WebInboundMsg; + msg: WebInboundMessage; onModelSelected?: ChannelReplyOnModelSelected; rememberSentText: ( text: string | undefined, @@ -617,12 +606,12 @@ export async function dispatchWhatsAppBufferedReply(params: { if (!delivery.providerAccepted) { params.replyLogger.warn( { - correlationId: params.msg.id ?? null, + correlationId: params.msg.event.id ?? null, connectionId: params.connectionId, conversationId: params.conversationId, - chatId: params.msg.chatId, + chatId: params.msg.platform.chatJid, to: params.msg.from, - from: params.msg.to, + from: params.msg.platform.recipientJid, replyKind: info.kind, }, "auto-reply was not accepted by WhatsApp provider", @@ -704,7 +693,7 @@ export async function dispatchWhatsAppBufferedReply(params: { }); if (durable.status === "failed") { if (durable.sentBeforeError === true) { - throw markWhatsAppReplyDeliveryErrorVisible(durable.error); + throw markWhatsAppVisibleDeliveryError(durable.error); } throw durable.error; } @@ -756,7 +745,7 @@ export async function dispatchWhatsAppBufferedReply(params: { logWhatsAppMediaOnlyFlushResult(flushResult); return whatsAppReplyDeliveryVisibility(flushResult.delivered > 0); }, - onReplyStart: params.msg.sendComposing, + onReplyStart: params.msg.platform.sendComposing, ...(statusReactionController ? { onCompactionStart: async () => { diff --git a/extensions/whatsapp/src/auto-reply/monitor/message-line.ts b/extensions/whatsapp/src/auto-reply/monitor/message-line.ts index f792f86b5686..7cd15e8a0ffb 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/message-line.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/message-line.ts @@ -1,15 +1,19 @@ // Whatsapp plugin module implements message line behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { getPrimaryIdentityId, getReplyContext, getSenderIdentity } from "../../identity.js"; -import type { WebInboundMsg } from "../types.js"; +import { + getPrimaryIdentityId, + getReplyContext, + getSenderIdentity, + type WhatsAppReplyContext, +} from "../../identity.js"; +import type { WebInboundMessage } from "../../inbound/types.js"; import { formatInboundEnvelope, resolveMessagePrefix, type EnvelopeFormatOptions, } from "./message-line.runtime.js"; -export function formatReplyContext(msg: WebInboundMsg) { - const replyTo = getReplyContext(msg); +function formatReplyTarget(replyTo: WhatsAppReplyContext | null) { if (!replyTo?.body) { return null; } @@ -18,12 +22,17 @@ export function formatReplyContext(msg: WebInboundMsg) { return `[Replying to ${sender}${idPart}]\n${replyTo.body}\n[/Replying]`; } +export function formatReplyContext(msg: WebInboundMessage) { + return formatReplyTarget(getReplyContext(msg)); +} + export function buildInboundLine(params: { cfg: OpenClawConfig; - msg: WebInboundMsg; + msg: WebInboundMessage; 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 @@ -32,15 +41,18 @@ export function buildInboundLine(params: { hasAllowFrom: (cfg.channels?.whatsapp?.allowFrom?.length ?? 0) > 0, }); const prefixStr = messagePrefix ? `${messagePrefix} ` : ""; - const replyContext = formatReplyContext(msg); - const baseLine = `${prefixStr}${msg.body}${replyContext ? `\n\n${replyContext}` : ""}`; + 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: msg.chatType === "group" ? msg.from : msg.from?.replace(/^whatsapp:/, ""), - timestamp: msg.timestamp, + timestamp: msg.event.timestamp, body: baseLine, chatType: msg.chatType, sender: { @@ -50,6 +62,6 @@ export function buildInboundLine(params: { }, previousTimestamp, envelope, - fromMe: msg.fromMe, + fromMe: msg.platform.fromMe, }); } diff --git a/extensions/whatsapp/src/auto-reply/monitor/on-message.ts b/extensions/whatsapp/src/auto-reply/monitor/on-message.ts index 1e1ef56ecb43..1c08595279f7 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/on-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/on-message.ts @@ -9,10 +9,15 @@ 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 { + normalizeWebInboundMessage, + withDeprecatedWebInboundMessageFlatAliases, +} from "../../inbound/message-aliases.js"; +import type { WebInboundMessageInput } from "../../inbound/types.js"; +import type { WebInboundMessage } from "../../inbound/types.js"; import { normalizeE164 } from "../../text-runtime.js"; import { buildMentionConfig } from "../mentions.js"; import type { MentionConfig } from "../mentions.js"; -import type { WebInboundMsg } from "../types.js"; import { maybeSendAckReaction } from "./ack-reaction.js"; import { maybeBroadcastMessage } from "./broadcast.js"; import type { EchoTracker } from "./echo.js"; @@ -42,9 +47,32 @@ export function createWebOnMessageHandler(params: { baseMentionConfig: MentionConfig; account: { authDir?: string; accountId?: string; selfChatMode?: boolean }; }) { + const withDirectSenderPeer = (msg: WebInboundMessage, peerId: string): WebInboundMessage => { + if ( + msg.chatType === "group" || + msg.platform.sender?.e164 || + msg.platform.senderE164 || + !peerId.startsWith("+") + ) { + return msg; + } + const normalized = normalizeE164(peerId); + if (!normalized) { + return msg; + } + return withDeprecatedWebInboundMessageFlatAliases({ + ...msg, + platform: { + ...msg.platform, + sender: { ...msg.platform.sender, e164: normalized }, + senderE164: normalized, + }, + }); + }; + const processForRoute = async ( cfg: OpenClawConfig, - msg: WebInboundMsg, + msg: WebInboundMessage, route: ReturnType, groupHistoryKey: string, opts?: { @@ -95,10 +123,12 @@ export function createWebOnMessageHandler(params: { return processMessage(processParams); }; - return async (msg: WebInboundMsg) => { + return async (rawMsg: WebInboundMessageInput) => { + const normalizedMsg = normalizeWebInboundMessage(rawMsg); const cfg = params.loadConfig?.() ?? params.cfg; + const peerId = resolvePeerId(normalizedMsg); + const msg = withDirectSenderPeer(normalizedMsg, peerId); const conversationId = msg.conversationId ?? msg.from; - const peerId = resolvePeerId(msg); const baseRoute = resolveAgentRoute({ cfg, channel: "whatsapp", @@ -126,14 +156,14 @@ export function createWebOnMessageHandler(params: { const baseMentionConfig = buildMentionConfig(cfg); // Same-phone mode logging retained - if (msg.from === msg.to) { + if (msg.from === msg.platform.recipientJid) { logVerbose(`📱 Same-phone mode detected (from === to: ${msg.from})`); } // Skip if this is a message we just sent (echo detection) - if (params.echoTracker.has(msg.body)) { + if (params.echoTracker.has(msg.payload.body)) { logVerbose("Skipping auto-reply: detected echo (message matches recently sent text)"); - params.echoTracker.forget(msg.body); + params.echoTracker.forget(msg.payload.body); return; } @@ -146,7 +176,8 @@ export function createWebOnMessageHandler(params: { // undefined = preflight was not attempted (non-audio message). let preflightAudioTranscript: string | null | undefined; const hasAudioBody = - msg.mediaType?.startsWith("audio/") === true && msg.body === ""; + msg.payload.media?.type?.startsWith("audio/") === true && + msg.payload.body === ""; const canRunEarlyAudioPreflight = msg.chatType === "group" || msg.accessControlPassed === true; let ackAlreadySent = false; let ackReaction: AckReactionHandle | null = null; @@ -156,7 +187,7 @@ export function createWebOnMessageHandler(params: { preflightAudioTranscript !== undefined || !canRunEarlyAudioPreflight || !hasAudioBody || - !msg.mediaPath + !msg.payload.media?.path ) { return; } @@ -194,10 +225,10 @@ export function createWebOnMessageHandler(params: { preflightAudioTranscript = (await transcribeFirstAudio({ ctx: { - MediaPaths: [msg.mediaPath], - MediaTypes: msg.mediaType ? [msg.mediaType] : undefined, + MediaPaths: [msg.payload.media?.path], + MediaTypes: msg.payload.media?.type ? [msg.payload.media?.type] : undefined, From: msg.from, - To: msg.to, + To: msg.platform.recipientJid, Provider: "whatsapp", Surface: "whatsapp", OriginatingChannel: "whatsapp", @@ -216,12 +247,12 @@ export function createWebOnMessageHandler(params: { const sender = getSenderIdentity(msg); const metaCtx = { From: msg.from, - To: msg.to, + To: msg.platform.recipientJid, SessionKey: route.sessionKey, AccountId: route.accountId, ChatType: msg.chatType, ConversationLabel: conversationId, - GroupSubject: msg.groupSubject, + GroupSubject: msg.group?.subject, SenderName: sender.name ?? undefined, SenderId: getPrimaryIdentityId(sender) ?? undefined, SenderE164: sender.e164 ?? undefined, @@ -245,7 +276,7 @@ export function createWebOnMessageHandler(params: { let gating = await applyGroupGating({ cfg, msg, - deferMissingMention: hasAudioBody && Boolean(msg.mediaPath), + deferMissingMention: hasAudioBody && Boolean(msg.payload.media?.path), conversationId, groupHistoryKey, agentId: route.agentId, @@ -290,13 +321,6 @@ export function createWebOnMessageHandler(params: { if (!gating.shouldProcess) { return; } - } else if (!msg.sender?.e164 && !msg.senderE164 && peerId && peerId.startsWith("+")) { - // Ensure `peerId` for DMs is stable and stored as E.164 when possible. - const normalized = normalizeE164(peerId); - if (normalized) { - msg.sender = { ...msg.sender, e164: normalized }; - msg.senderE164 = normalized; - } } await runAudioPreflightOnce(); diff --git a/extensions/whatsapp/src/auto-reply/monitor/peer.ts b/extensions/whatsapp/src/auto-reply/monitor/peer.ts index 6f486147f48c..67c7308e0baa 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/peer.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/peer.ts @@ -1,9 +1,9 @@ // Whatsapp plugin module implements peer behavior. import { getSenderIdentity } from "../../identity.js"; +import type { WebInboundMessage } from "../../inbound/types.js"; import { jidToE164, normalizeE164 } from "../../text-runtime.js"; -import type { WebInboundMsg } from "../types.js"; -export function resolvePeerId(msg: WebInboundMsg) { +export function resolvePeerId(msg: WebInboundMessage) { if (msg.chatType === "group") { return msg.conversationId ?? msg.from; } diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts index efc21c8ad839..19dda9d57736 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts @@ -25,6 +25,7 @@ import { resolveWhatsAppCommandAuthorized, resolveWhatsAppInboundPolicy, } from "../../inbound-policy.js"; +import type { WebInboundMessage } from "../../inbound/types.js"; import { newConnectionId } from "../../reconnect.js"; import { formatError } from "../../session.js"; import { @@ -33,7 +34,6 @@ import { } from "../../system-prompt.js"; import { deliverWebReply } from "../deliver-reply.js"; import { whatsappInboundLog } from "../loggers.js"; -import type { WebInboundMsg } from "../types.js"; import { elide } from "../util.js"; import { maybeSendAckReaction } from "./ack-reaction.js"; import { @@ -184,7 +184,7 @@ function resolvePinnedMainDmRecipient(params: { export async function processMessage(params: { cfg: ReturnType; - msg: WebInboundMsg; + msg: WebInboundMessage; route: ReturnType; groupHistoryKey: string; groupHistories: Map; @@ -249,16 +249,21 @@ export async function processMessage(params: { // undefined → caller did not attempt; run internal STT let audioTranscript: string | undefined = params.preflightAudioTranscript ?? undefined; const hasAudioBody = - params.msg.mediaType?.startsWith("audio/") === true && params.msg.body === ""; - if (params.preflightAudioTranscript === undefined && hasAudioBody && params.msg.mediaPath) { + params.msg.payload.media?.type?.startsWith("audio/") === true && + params.msg.payload.body === ""; + 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.mediaPath], - MediaTypes: params.msg.mediaType ? [params.msg.mediaType] : undefined, + MediaPaths: [params.msg.payload.media?.path], + MediaTypes: params.msg.payload.media?.type ? [params.msg.payload.media?.type] : undefined, From: params.msg.from, - To: params.msg.to, + To: params.msg.platform.recipientJid, Provider: "whatsapp", Surface: "whatsapp", OriginatingChannel: "whatsapp", @@ -281,7 +286,16 @@ export async function processMessage(params: { // audio message. The transcript and transcribed media index are also stored on // context so downstream media understanding does not transcribe it again. const msgForAgent = - audioTranscript !== undefined ? { ...params.msg, body: audioTranscript } : params.msg; + 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, @@ -289,6 +303,7 @@ export async function processMessage(params: { agentId: params.route.agentId, previousTimestamp, envelope: envelopeOptions, + visibleReplyTo, }); let shouldClearGroupHistory = false; const visibleGroupHistory = @@ -298,6 +313,7 @@ export async function processMessage(params: { mode: contextVisibilityMode, groupPolicy: inboundPolicy.groupPolicy, groupAllowFrom: inboundPolicy.groupAllowFrom, + authDir: account.authDir, }) : undefined; @@ -380,44 +396,40 @@ export async function processMessage(params: { }); } - const correlationId = params.msg.id ?? newConnectionId(); + const correlationId = params.msg.event.id ?? newConnectionId(); params.replyLogger.info( { connectionId: params.connectionId, correlationId, from: params.msg.chatType === "group" ? conversationId : params.msg.from, - to: params.msg.to, + to: params.msg.platform.recipientJid, body: elide(combinedBody, 240), - mediaType: params.msg.mediaType ?? null, - mediaPath: params.msg.mediaPath ?? null, + mediaType: params.msg.payload.media?.type ?? null, + mediaPath: params.msg.payload.media?.path ?? null, }, "inbound web message", ); const fromDisplay = params.msg.chatType === "group" ? conversationId : params.msg.from; - const kindLabel = params.msg.mediaType ? `, ${params.msg.mediaType}` : ""; + const kindLabel = params.msg.payload.media?.type ? `, ${params.msg.payload.media?.type}` : ""; whatsappInboundLog.info( - `Inbound message ${fromDisplay} -> ${params.msg.to} (${params.msg.chatType}${kindLabel}, ${combinedBody.length} chars)`, + `Inbound message ${fromDisplay} -> ${params.msg.platform.recipientJid} (${params.msg.chatType}${kindLabel}, ${combinedBody.length} chars)`, ); if (shouldLogVerbose()) { whatsappInboundLog.debug(`Inbound body: ${elide(combinedBody, 400)}`); } const sender = getSenderIdentity(params.msg); - const visibleReplyTo = resolveVisibleWhatsAppReplyContext({ - msg: params.msg, - authDir: account.authDir, - mode: contextVisibilityMode, - groupPolicy: inboundPolicy.groupPolicy, - groupAllowFrom: inboundPolicy.groupAllowFrom, - }); const dmRouteTarget = resolveWhatsAppDmRouteTarget({ msg: params.msg, senderE164: sender.e164 ?? undefined, normalizeE164, }); - const shouldCheckCommandAuth = shouldComputeCommandAuthorized(params.msg.body, params.cfg); - const isTextCommand = isControlCommandMessage(params.msg.body, params.cfg); + const shouldCheckCommandAuth = shouldComputeCommandAuthorized( + params.msg.payload.body, + params.cfg, + ); + const isTextCommand = isControlCommandMessage(params.msg.payload.body, params.cfg); const commandAuthorized = shouldCheckCommandAuth ? await resolveWhatsAppCommandAuthorized({ cfg: params.cfg, @@ -430,13 +442,13 @@ export async function processMessage(params: { kind: "text-slash", source: "text", authorized: Boolean(commandAuthorized), - body: params.msg.body, + body: params.msg.payload.body, } : { kind: "normal", source: "message", authorized: false, - body: params.msg.body, + body: params.msg.payload.body, }; const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({ cfg: params.cfg, @@ -452,7 +464,7 @@ export async function processMessage(params: { }); const replyThreading = resolveBatchedReplyThreadingPolicy( account.replyToMode ?? "off", - params.msg.isBatched === true, + params.msg.event.isBatched === true, ); // Resolve combined conversation system prompt using the group or direct surface. @@ -468,9 +480,9 @@ export async function processMessage(params: { }); const ctxPayload = await buildWhatsAppInboundContext({ - bodyForAgent: msgForAgent.body, + bodyForAgent: msgForAgent.payload.body, combinedBody, - commandBody: params.msg.body, + commandBody: params.msg.payload.body, commandAuthorized, commandTurn, conversationId, @@ -478,7 +490,7 @@ export async function processMessage(params: { groupMemberRoster: params.groupMemberNames.get(params.groupHistoryKey), groupSystemPrompt: conversationSystemPrompt, msg: params.msg, - rawBody: params.msg.body, + rawBody: params.msg.payload.body, route: params.route, sender: { id: getPrimaryIdentityId(sender) ?? undefined, @@ -489,6 +501,7 @@ export async function processMessage(params: { ...(audioTranscript !== undefined ? { mediaTranscribedIndexes: [0] } : {}), replyThreading, visibleReplyTo: visibleReplyTo ?? undefined, + suppressMessageReceivedHooks: true, }); emitWhatsAppMessageReceivedHooksIfEnabled({ cfg: params.cfg, @@ -518,8 +531,8 @@ export async function processMessage(params: { raw: params.msg, adapter: { ingest: () => ({ - id: params.msg.id ?? `${conversationId}:${Date.now()}`, - timestamp: params.msg.timestamp, + id: params.msg.event.id ?? `${conversationId}:${Date.now()}`, + timestamp: params.msg.event.timestamp, rawText: ctxPayload.RawBody ?? "", textForAgent: ctxPayload.BodyForAgent, textForCommands: ctxPayload.CommandBody, @@ -582,7 +595,7 @@ export async function processMessage(params: { logAckFailure({ log: logVerbose, channel: "whatsapp", - target: `${params.msg.chatId ?? conversationId}/${params.msg.id ?? "unknown"}`, + target: `${params.msg.platform.chatJid ?? conversationId}/${params.msg.event.id ?? "unknown"}`, error: err, }); }, diff --git a/extensions/whatsapp/src/auto-reply/monitor/status-reaction.ts b/extensions/whatsapp/src/auto-reply/monitor/status-reaction.ts index 0283dff9e998..d465e6717831 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/status-reaction.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/status-reaction.ts @@ -7,9 +7,9 @@ import { import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { getSenderIdentity } from "../../identity.js"; +import type { WebInboundMessage } from "../../inbound/types.js"; import { resolveWhatsAppReactionLevel } from "../../reaction-level.js"; import { sendReactionWhatsApp } from "../../send.js"; -import type { WebInboundMsg } from "../types.js"; import { resolveWhatsAppAckEmoji } from "./ack-emoji.js"; import { resolveGroupActivationFor } from "./group-activation.js"; @@ -17,7 +17,7 @@ export type { StatusReactionController }; export type WhatsAppStatusReactionParams = { cfg: OpenClawConfig; - msg: WebInboundMsg; + msg: WebInboundMessage; agentId: string; sessionKey: string; conversationId: string; @@ -28,7 +28,7 @@ export type WhatsAppStatusReactionParams = { export async function createWhatsAppStatusReactionController( params: WhatsAppStatusReactionParams, ): Promise { - if (!params.msg.id) { + if (!params.msg.event.id) { return null; } @@ -91,8 +91,8 @@ export async function createWhatsAppStatusReactionController( ...(params.accountId ? { accountId: params.accountId } : {}), cfg: params.cfg, }; - const chatId = params.msg.chatId; - const msgId = params.msg.id; + const chatId = params.msg.platform.chatJid; + const msgId = params.msg.event.id; return createStatusReactionController({ enabled: true, diff --git a/extensions/whatsapp/src/auto-reply/types.ts b/extensions/whatsapp/src/auto-reply/types.ts index 4bbdddc4d661..427220ef0a1d 100644 --- a/extensions/whatsapp/src/auto-reply/types.ts +++ b/extensions/whatsapp/src/auto-reply/types.ts @@ -13,6 +13,7 @@ export type WebChannelHealthState = | "logged-out" | "stopped"; +/** @deprecated Use `WebInboundMessage`. */ export type WebInboundMsg = WebInboundMessage; export type WebChannelStatus = { diff --git a/extensions/whatsapp/src/auto-reply/util.ts b/extensions/whatsapp/src/auto-reply/util.ts index c390b010163f..5609b61dce8c 100644 --- a/extensions/whatsapp/src/auto-reply/util.ts +++ b/extensions/whatsapp/src/auto-reply/util.ts @@ -11,6 +11,20 @@ export function elide(text?: string, limit = 400) { return `${text.slice(0, limit)}… (truncated ${text.length - limit} 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) { diff --git a/extensions/whatsapp/src/identity.ts b/extensions/whatsapp/src/identity.ts index c278c8bb092f..6a27d22a6dd8 100644 --- a/extensions/whatsapp/src/identity.ts +++ b/extensions/whatsapp/src/identity.ts @@ -24,31 +24,42 @@ export type WhatsAppReplyContext = { }; type LegacySenderLike = { - sender?: WhatsAppIdentity; - senderJid?: string; - senderE164?: string; - senderName?: string; + platform: { + sender?: WhatsAppIdentity; + senderJid?: string; + senderE164?: string; + senderName?: string; + }; }; type LegacySelfLike = { - self?: WhatsAppSelfIdentity; - selfJid?: string | null; - selfLid?: string | null; - selfE164?: string | null; + platform: { + self?: WhatsAppSelfIdentity; + selfJid?: string | null; + selfLid?: string | null; + selfE164?: string | null; + }; }; type LegacyReplyLike = { - replyTo?: WhatsAppReplyContext; - replyToId?: string; - replyToBody?: string; - replyToSender?: string; - replyToSenderJid?: string; - replyToSenderE164?: string; + quote?: { + context?: WhatsAppReplyContext; + id?: string; + body?: string; + sender?: { + displayName?: string; + jid?: string; + e164?: string; + }; + }; }; type LegacyMentionsLike = { - mentions?: string[]; - mentionedJids?: string[]; + group?: { + mentions?: { + jids?: string[]; + }; + }; }; function normalizeDeviceScopedJid(jid: string | null | undefined): string | null { @@ -102,10 +113,10 @@ export function identitiesOverlap( export function getSenderIdentity(msg: LegacySenderLike, authDir?: string): WhatsAppIdentity { return resolveComparableIdentity( - msg.sender ?? { - jid: msg.senderJid ?? null, - e164: msg.senderE164 ?? null, - name: msg.senderName ?? null, + msg.platform.sender ?? { + jid: msg.platform.senderJid ?? null, + e164: msg.platform.senderE164 ?? null, + name: msg.platform.senderName ?? null, }, authDir, ); @@ -113,10 +124,10 @@ export function getSenderIdentity(msg: LegacySenderLike, authDir?: string): What export function getSelfIdentity(msg: LegacySelfLike, authDir?: string): WhatsAppSelfIdentity { return resolveComparableIdentity( - msg.self ?? { - jid: msg.selfJid ?? null, - lid: msg.selfLid ?? null, - e164: msg.selfE164 ?? null, + msg.platform.self ?? { + jid: msg.platform.selfJid ?? null, + lid: msg.platform.selfLid ?? null, + e164: msg.platform.selfE164 ?? null, }, authDir, ); @@ -126,23 +137,23 @@ export function getReplyContext( msg: LegacyReplyLike, authDir?: string, ): WhatsAppReplyContext | null { - if (msg.replyTo) { + if (msg.quote?.context) { return { - ...msg.replyTo, - sender: resolveComparableIdentity(msg.replyTo.sender, authDir), + ...msg.quote.context, + sender: resolveComparableIdentity(msg.quote.context.sender, authDir), }; } - if (!msg.replyToBody) { + if (!msg.quote?.body) { return null; } return { - id: msg.replyToId, - body: msg.replyToBody, + id: msg.quote.id, + body: msg.quote.body, sender: resolveComparableIdentity( { - jid: msg.replyToSenderJid ?? null, - e164: msg.replyToSenderE164 ?? null, - label: msg.replyToSender ?? null, + jid: msg.quote.sender?.jid ?? null, + e164: msg.quote.sender?.e164 ?? null, + label: msg.quote.sender?.displayName ?? null, }, authDir, ), @@ -150,7 +161,7 @@ export function getReplyContext( } function getMentionJids(msg: LegacyMentionsLike): string[] { - return msg.mentions ?? msg.mentionedJids ?? []; + return msg.group?.mentions?.jids ?? []; } export function getMentionIdentities( diff --git a/extensions/whatsapp/src/inbound-policy.ts b/extensions/whatsapp/src/inbound-policy.ts index a508a42e3b54..892ac020ff4a 100644 --- a/extensions/whatsapp/src/inbound-policy.ts +++ b/extensions/whatsapp/src/inbound-policy.ts @@ -207,7 +207,7 @@ export async function resolveWhatsAppCommandAuthorized(params: { cfg: params.cfg, policy, isGroup, - conversationId: params.msg.conversationId ?? params.msg.chatId ?? params.msg.from, + conversationId: params.msg.conversationId ?? params.msg.platform.chatJid ?? params.msg.from, senderId: isGroup ? groupSender : dmSender, dmSenderId: dmSender, includeCommand: true, diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 2cc4c984e6a5..45a014740bca 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -2013,7 +2013,7 @@ export async function dispatchReplyFromConfig( } // Trigger plugin hooks (fire-and-forget) - if (hookRunner?.hasHooks("message_received")) { + if (ctx.SuppressMessageReceivedHooks !== true && hookRunner?.hasHooks("message_received")) { fireAndForgetHook( hookRunner.runMessageReceived( toPluginMessageReceivedEvent(hookContext), @@ -2024,7 +2024,7 @@ export async function dispatchReplyFromConfig( } // Bridge to internal hooks (HOOK.md discovery system) - refs #8807 - if (sessionKey) { + if (ctx.SuppressMessageReceivedHooks !== true && sessionKey) { fireAndForgetHook( triggerInternalHook( createInternalHookEvent("message", "received", sessionKey, { diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index e43839f9c9c4..b347f45c55e3 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -309,6 +309,11 @@ export type MsgContext = { * OriginatingChannel/OriginatingTo, rather than inheriting stale session route metadata. */ ExplicitDeliverRoute?: boolean; + /** + * Internal flag for channels that emit message_received through a channel-specific + * privacy gate before entering the shared reply dispatcher. + */ + SuppressMessageReceivedHooks?: boolean; /** * Provider-specific parent conversation id for threaded contexts. * For Discord threads, this is the parent channel id.