refactor(whatsapp): read inbound contexts in auto reply

This commit is contained in:
Marcus Castro
2026-05-30 13:40:16 -03:00
committed by Shakker
parent b5295a6a34
commit eebcb100b8
19 changed files with 318 additions and 231 deletions

View File

@@ -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<ReplyPayload>;
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();
}

View File

@@ -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<string, unknown> } {
@@ -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 };

View File

@@ -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;

View File

@@ -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<AckReactionHandle | null> {
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)}`,
);
},
});
}

View File

@@ -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<typeof resolveAgentRoute>;
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<typeof resolveAgentRoute>;
groupHistoryKey: string;
groupHistories: Map<string, GroupHistoryEntry[]>;
processMessage: (
msg: WebInboundMsg,
msg: WebInboundMessage,
route: ReturnType<typeof resolveAgentRoute>,
groupHistoryKey: string,
opts?: {

View File

@@ -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<string, GroupHistoryEntry[]>;
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,
);
}

View File

@@ -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, {

View File

@@ -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<typeof getChildLogger>;
}) {
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<string, string>;
groupSystemPrompt?: string;
msg: WebInboundMsg;
msg: WebInboundMessage;
rawBody?: string;
route: ReturnType<typeof resolveAgentRoute>;
sender: SenderContext;
@@ -296,6 +283,7 @@ export async function buildWhatsAppInboundContext(params: {
mediaTranscribedIndexes?: number[];
replyThreading?: ReplyThreadingContext;
visibleReplyTo?: VisibleReplyTarget;
suppressMessageReceivedHooks?: boolean;
}): Promise<FinalizedMsgContext> {
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<ReplyPayload>;
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 () => {

View File

@@ -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,
});
}

View File

@@ -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<typeof resolveAgentRoute>,
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 === "<media:audio>";
msg.payload.media?.type?.startsWith("audio/") === true &&
msg.payload.body === "<media:audio>";
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();

View File

@@ -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;
}

View File

@@ -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<LoadConfigFn>;
msg: WebInboundMsg;
msg: WebInboundMessage;
route: ReturnType<typeof resolveAgentRoute>;
groupHistoryKey: string;
groupHistories: Map<string, GroupHistoryEntry[]>;
@@ -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 === "<media:audio>";
if (params.preflightAudioTranscript === undefined && hasAudioBody && params.msg.mediaPath) {
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.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,
});
},

View File

@@ -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<StatusReactionController | null> {
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,

View File

@@ -13,6 +13,7 @@ export type WebChannelHealthState =
| "logged-out"
| "stopped";
/** @deprecated Use `WebInboundMessage`. */
export type WebInboundMsg = WebInboundMessage;
export type WebChannelStatus = {

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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,

View File

@@ -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, {

View File

@@ -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.