

























@@ -60,9 +60,22 @@ type SenderContext = {
6060e164?: string;
6161};
626263+type ReplyDeliveryInfo = { kind: ReplyLifecycleKind };
64+65+type PendingWhatsAppMediaOnlyPayload = {
66+info: ReplyDeliveryInfo;
67+mediaUrls: Set<string>;
68+payload: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
69+};
70+71+type WhatsAppMediaOnlyFlushResult = {
72+delivered: number;
73+droppedDuplicateMedia: number;
74+};
75+6376function logWhatsAppReplyDeliveryError(params: {
6477err: unknown;
65-info: { kind: ReplyLifecycleKind };
78+info: ReplyDeliveryInfo;
6679connectionId: string;
6780conversationId: string;
6881msg: WebInboundMsg;
@@ -109,6 +122,85 @@ function resolveWhatsAppDeliverablePayload(
109122return payload;
110123}
111124125+function getWhatsAppPayloadMediaUrls(payload: ReplyPayload): Set<string> {
126+return new Set(
127+[
128+ ...(Array.isArray(payload.mediaUrls) ? payload.mediaUrls : []),
129+ ...(typeof payload.mediaUrl === "string" ? [payload.mediaUrl] : []),
130+]
131+.map((url) => url.trim())
132+.filter(Boolean),
133+);
134+}
135+136+function hasWhatsAppMediaUrlOverlap(left: Set<string>, right: Set<string>): boolean {
137+for (const url of left) {
138+if (right.has(url)) {
139+return true;
140+}
141+}
142+return false;
143+}
144+145+function shouldDeferWhatsAppMediaOnlyPayload(params: {
146+info: ReplyDeliveryInfo;
147+mediaUrls: Set<string>;
148+reply: ReturnType<typeof resolveSendableOutboundReplyParts>;
149+}): boolean {
150+return (
151+params.info.kind !== "final" &&
152+params.reply.hasMedia &&
153+!params.reply.text.trim() &&
154+params.mediaUrls.size > 0
155+);
156+}
157+158+function createWhatsAppMediaOnlyReplyCoalescer(params: {
159+deliver: (pending: PendingWhatsAppMediaOnlyPayload) => Promise<void>;
160+}) {
161+const pendingMediaOnlyPayloads: PendingWhatsAppMediaOnlyPayload[] = [];
162+const flushExceptDuplicateMedia = async (
163+mediaUrls?: Set<string>,
164+): Promise<WhatsAppMediaOnlyFlushResult> => {
165+const flushResult: WhatsAppMediaOnlyFlushResult = {
166+delivered: 0,
167+droppedDuplicateMedia: 0,
168+};
169+const pending = pendingMediaOnlyPayloads.splice(0);
170+for (const candidate of pending) {
171+if (mediaUrls && hasWhatsAppMediaUrlOverlap(candidate.mediaUrls, mediaUrls)) {
172+flushResult.droppedDuplicateMedia += 1;
173+continue;
174+}
175+await params.deliver(candidate);
176+flushResult.delivered += 1;
177+}
178+return flushResult;
179+};
180+181+return {
182+defer(pending: PendingWhatsAppMediaOnlyPayload) {
183+pendingMediaOnlyPayloads.push(pending);
184+},
185+ flushExceptDuplicateMedia,
186+flushAll: () => flushExceptDuplicateMedia(),
187+};
188+}
189+190+function logWhatsAppMediaOnlyFlushResult(result: WhatsAppMediaOnlyFlushResult) {
191+if (!shouldLogVerbose()) {
192+return;
193+}
194+if (result.droppedDuplicateMedia > 0) {
195+logVerbose(
196+`Dropped ${result.droppedDuplicateMedia} deferred media-only WhatsApp reply payload(s) superseded by captioned media`,
197+);
198+}
199+if (result.delivered > 0) {
200+logVerbose(`Flushed ${result.delivered} deferred media-only WhatsApp reply payload(s)`);
201+}
202+}
203+112204export function resolveWhatsAppResponsePrefix(params: {
113205cfg: ReturnType<LoadConfigFn>;
114206agentId: string;
@@ -335,6 +427,63 @@ export async function dispatchWhatsAppBufferedReply(params: {
335427let didSendReply = false;
336428let didLogHeartbeatStrip = false;
337429430+const deliverNormalizedPayload = async (
431+normalizedDeliveryPayload: DeliverableWhatsAppOutboundPayload<ReplyPayload>,
432+info: ReplyDeliveryInfo,
433+) => {
434+const reply = resolveSendableOutboundReplyParts(normalizedDeliveryPayload);
435+if (!reply.hasMedia && !reply.text.trim()) {
436+return;
437+}
438+const delivery = await params.deliverReply({
439+replyResult: normalizedDeliveryPayload,
440+normalizedReplyResult: normalizedDeliveryPayload,
441+msg: params.msg,
442+ mediaLocalRoots,
443+maxMediaBytes: params.maxMediaBytes,
444+ textLimit,
445+ chunkMode,
446+replyLogger: params.replyLogger,
447+connectionId: params.connectionId,
448+skipLog: false,
449+ tableMode,
450+});
451+if (!delivery.providerAccepted) {
452+params.replyLogger.warn(
453+{
454+correlationId: params.msg.id ?? null,
455+connectionId: params.connectionId,
456+conversationId: params.conversationId,
457+chatId: params.msg.chatId,
458+to: params.msg.from,
459+from: params.msg.to,
460+replyKind: info.kind,
461+},
462+"auto-reply was not accepted by WhatsApp provider",
463+);
464+return;
465+}
466+didSendReply = true;
467+const shouldLog = normalizedDeliveryPayload.text ? true : undefined;
468+params.rememberSentText(normalizedDeliveryPayload.text, {
469+combinedBody: params.context.Body as string | undefined,
470+combinedBodySessionKey: params.route.sessionKey,
471+logVerboseMessage: shouldLog,
472+});
473+const fromDisplay =
474+params.msg.chatType === "group" ? params.conversationId : (params.msg.from ?? "unknown");
475+if (shouldLogVerbose()) {
476+const preview = normalizedDeliveryPayload.text != null ? reply.text : "<media>";
477+logVerbose(`Reply body: ${preview}${reply.hasMedia ? " (media)" : ""} -> ${fromDisplay}`);
478+}
479+};
480+481+const mediaOnlyCoalescer = createWhatsAppMediaOnlyReplyCoalescer({
482+deliver: async (pending) => {
483+await deliverNormalizedPayload(pending.payload, pending.info);
484+},
485+});
486+338487const { queuedFinal, counts } = await dispatchReplyWithBufferedBlockDispatcher({
339488ctx: params.context,
340489cfg: params.cfg,
@@ -364,6 +513,7 @@ export async function dispatchWhatsAppBufferedReply(params: {
364513return;
365514}
366515if (!reply.hasMedia) {
516+logWhatsAppMediaOnlyFlushResult(await mediaOnlyCoalescer.flushAll());
367517const durable = await deliverInboundReplyWithMessageSendContext({
368518cfg: params.cfg,
369519channel: "whatsapp",
@@ -395,48 +545,22 @@ export async function dispatchWhatsAppBufferedReply(params: {
395545if (durable.status === "handled_no_send") {
396546return;
397547}
398-}
399-const delivery = await params.deliverReply({
400-replyResult: normalizedDeliveryPayload,
401-normalizedReplyResult: normalizedDeliveryPayload,
402-msg: params.msg,
403- mediaLocalRoots,
404-maxMediaBytes: params.maxMediaBytes,
405- textLimit,
406- chunkMode,
407-replyLogger: params.replyLogger,
408-connectionId: params.connectionId,
409-skipLog: false,
410- tableMode,
411-});
412-if (!delivery.providerAccepted) {
413-params.replyLogger.warn(
414-{
415-correlationId: params.msg.id ?? null,
416-connectionId: params.connectionId,
417-conversationId: params.conversationId,
418-chatId: params.msg.chatId,
419-to: params.msg.from,
420-from: params.msg.to,
421-replyKind: info.kind,
422-},
423-"auto-reply was not accepted by WhatsApp provider",
424-);
548+await deliverNormalizedPayload(normalizedDeliveryPayload, info);
425549return;
426550}
427-didSendReply = true;
428-const shouldLog = normalizedDeliveryPayload.text ? true : undefined;
429-params.rememberSentText(normalizedDeliveryPayload.text, {
430-combinedBody: params.context.Body as string | undefined,
431-combinedBodySessionKey: params.route.sessionKey,
432-logVerboseMessage: shouldLog,
433-});
434-const fromDisplay =
435-params.msg.chatType === "group" ? params.conversationId : (params.msg.from ?? "unknown");
436-if (shouldLogVerbose()) {
437-const preview = normalizedDeliveryPayload.text != null ? reply.text : "<media>";
438-logVerbose(`Reply body: ${preview}${reply.hasMedia ? " (media)" : ""} -> ${fromDisplay}`);
551+const mediaUrls = getWhatsAppPayloadMediaUrls(normalizedDeliveryPayload);
552+if (shouldDeferWhatsAppMediaOnlyPayload({ info, mediaUrls, reply })) {
553+mediaOnlyCoalescer.defer({
554+ info,
555+ mediaUrls,
556+payload: normalizedDeliveryPayload,
557+});
558+return;
439559}
560+logWhatsAppMediaOnlyFlushResult(
561+await mediaOnlyCoalescer.flushExceptDuplicateMedia(mediaUrls),
562+);
563+await deliverNormalizedPayload(normalizedDeliveryPayload, info);
440564},
441565onReplyStart: params.msg.sendComposing,
442566onError: (err, info) => {
@@ -456,6 +580,7 @@ export async function dispatchWhatsAppBufferedReply(params: {
456580onModelSelected: params.onModelSelected,
457581},
458582});
583+logWhatsAppMediaOnlyFlushResult(await mediaOnlyCoalescer.flushAll());
459584460585const didQueueVisibleReply = hasVisibleInboundReplyDispatch({ queuedFinal, counts });
461586if (!didQueueVisibleReply) {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。