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