mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
fix(whatsapp): track provider-accepted auto-replies
This commit is contained in:
@@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Codex: flush accepted debounced steering messages before normal app-server turn cleanup, so inbound follow-ups acknowledged as queued are not dropped when the turn completes before the debounce fires. Thanks @vincentkoc.
|
||||
- Slack/interactive replies: keep rendered buttons and selects within Slack Block Kit value and count limits, and align command argument select values with Slack's option limit, so overlong agent-authored choices no longer make Slack reject the whole block payload. Thanks @slackapi.
|
||||
- Slack/interactive replies: drop overlong Block Kit button URLs while preserving valid callback values, so malformed link buttons no longer make Slack reject the whole interactive reply. Thanks @slackapi.
|
||||
- Channels/WhatsApp: require Baileys outbound message ids before marking auto-replies delivered, so transcript text and ack reactions no longer make failed group replies look sent. Fixes #49225. Thanks @TinyTb.
|
||||
- CLI/update: scope packaged Node compile caches by OpenClaw version and install metadata, so global installs no longer reuse stale compiled chunks after package updates. Thanks @pashpashpash.
|
||||
- Channels/Voice call: keep pre-auth webhook in-flight limiting active when socket remote address metadata is missing, so slow-body requests from stripped-IP proxy paths still share the fallback bucket. (#74453) Thanks @davidangularme.
|
||||
- Plugin SDK/testing: lazy-load TypeScript from the plugin test-contract runtime and add release checks for critical SDK contract entrypoint imports and bundle size, so published packages fail preflight before shipping ESM-incompatible or oversized contract helpers. Thanks @vincentkoc.
|
||||
|
||||
@@ -569,6 +569,15 @@ Behavior notes:
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Reply appears in transcript but not in WhatsApp">
|
||||
Transcript rows record what the agent generated. WhatsApp delivery is checked separately: OpenClaw only treats an auto-reply as sent after Baileys returns an outbound message id for at least one visible text or media send.
|
||||
|
||||
Ack reactions are independent pre-reply receipts. A successful reaction does not prove that the later text or media reply was accepted by WhatsApp.
|
||||
|
||||
Check gateway logs for `auto-reply delivery failed` or `auto-reply was not accepted by WhatsApp provider`.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Group messages unexpectedly ignored">
|
||||
Check in this order:
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
sendWebDirectInboundAndCollectSessionKeys,
|
||||
} from "./auto-reply.broadcast-groups.test-harness.js";
|
||||
import {
|
||||
createAcceptedWhatsAppSendResult,
|
||||
installWebAutoReplyTestHomeHooks,
|
||||
installWebAutoReplyUnitTestHooks,
|
||||
resetLoadConfigMock,
|
||||
@@ -202,8 +203,8 @@ describe("broadcast groups", () => {
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendMedia = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1"));
|
||||
const reply = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1"));
|
||||
const sendComposing = vi.fn();
|
||||
|
||||
let started = 0;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, vi, type Mock } from "vitest";
|
||||
import type { WebChannelStatus } from "./auto-reply/types.js";
|
||||
import type { WebInboundMessage, WebListenerCloseReason } from "./inbound.js";
|
||||
import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
|
||||
import {
|
||||
resetBaileysMocks as _resetBaileysMocks,
|
||||
resetLoadConfigMock as _resetLoadConfigMock,
|
||||
@@ -27,9 +28,9 @@ type MockWebListener = {
|
||||
close: () => Promise<void>;
|
||||
onClose: Promise<WebListenerCloseReason>;
|
||||
signalClose: () => void;
|
||||
sendMessage: () => Promise<{ messageId: string }>;
|
||||
sendPoll: () => Promise<{ messageId: string }>;
|
||||
sendReaction: () => Promise<void>;
|
||||
sendMessage: () => Promise<WhatsAppSendResult>;
|
||||
sendPoll: () => Promise<WhatsAppSendResult>;
|
||||
sendReaction: () => Promise<WhatsAppSendResult>;
|
||||
sendComposingTo: () => Promise<void>;
|
||||
};
|
||||
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
||||
@@ -253,13 +254,26 @@ export function createMockWebListener(): MockWebListener {
|
||||
close: vi.fn(async () => undefined),
|
||||
onClose: new Promise<WebListenerCloseReason>(() => {}),
|
||||
signalClose: vi.fn(),
|
||||
sendMessage: vi.fn(async () => ({ messageId: "msg-1" })),
|
||||
sendPoll: vi.fn(async () => ({ messageId: "poll-1" })),
|
||||
sendReaction: vi.fn(async () => undefined),
|
||||
sendMessage: vi.fn(async () => createAcceptedWhatsAppSendResult("text", "msg-1")),
|
||||
sendPoll: vi.fn(async () => createAcceptedWhatsAppSendResult("poll", "poll-1")),
|
||||
sendReaction: vi.fn(async () => createAcceptedWhatsAppSendResult("reaction", "reaction-1")),
|
||||
sendComposingTo: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAcceptedWhatsAppSendResult(
|
||||
kind: WhatsAppSendKind,
|
||||
id: string,
|
||||
): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
messageIds: [id],
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function createScriptedWebListenerFactory(): AnyExport {
|
||||
const onMessages: Array<(msg: WebInboundMessage) => Promise<void>> = [];
|
||||
const closeResolvers: Array<(reason: unknown) => void> = [];
|
||||
@@ -294,8 +308,8 @@ export function createScriptedWebListenerFactory(): AnyExport {
|
||||
|
||||
export function createWebInboundDeliverySpies(): AnyExport {
|
||||
return {
|
||||
sendMedia: vi.fn(),
|
||||
reply: vi.fn().mockResolvedValue(undefined),
|
||||
sendMedia: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1")),
|
||||
reply: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1")),
|
||||
sendComposing: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import sharp from "sharp";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createMockWebListener,
|
||||
createAcceptedWhatsAppSendResult,
|
||||
installWebAutoReplyTestHomeHooks,
|
||||
installWebAutoReplyUnitTestHooks,
|
||||
resetLoadConfigMock,
|
||||
@@ -29,7 +30,8 @@ describe("web auto-reply", () => {
|
||||
sendMedia: ReturnType<typeof vi.fn>;
|
||||
reply?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
const reply = params.reply ?? vi.fn().mockResolvedValue(undefined);
|
||||
const reply =
|
||||
params.reply ?? vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1"));
|
||||
const sendComposing = vi.fn(async () => undefined);
|
||||
const resolver = vi.fn().mockResolvedValue(params.resolverValue);
|
||||
|
||||
@@ -384,7 +386,7 @@ describe("web auto-reply", () => {
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
it("sends media with a caption when delivery succeeds", async () => {
|
||||
const sendMedia = vi.fn().mockResolvedValue(undefined);
|
||||
const sendMedia = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1"));
|
||||
const { reply, dispatch } = await setupSingleInboundMessage({
|
||||
resolverValue: {
|
||||
text: "hi",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { WhatsAppAuthUnstableError, resolveWebCredsPath } from "./auth-store.js"
|
||||
import { resolveOAuthDir } from "./auth-store.runtime.js";
|
||||
import {
|
||||
createWebInboundDeliverySpies,
|
||||
createAcceptedWhatsAppSendResult,
|
||||
createMockWebListener,
|
||||
createScriptedWebListenerFactory,
|
||||
createWebListenerFactoryCapture,
|
||||
@@ -713,7 +714,7 @@ describe("web auto-reply connection", () => {
|
||||
|
||||
try {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const reply = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1"));
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
@@ -861,9 +862,9 @@ describe("web auto-reply connection", () => {
|
||||
markDispatchIdle,
|
||||
cleanup: vi.fn(),
|
||||
};
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const reply = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1"));
|
||||
const sendComposing = vi.fn().mockResolvedValue(undefined);
|
||||
const sendMedia = vi.fn().mockResolvedValue(undefined);
|
||||
const sendMedia = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1"));
|
||||
|
||||
const replyResolver = vi.fn().mockImplementation(async (ctx, opts) => {
|
||||
void ctx;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import "./test-helpers.js";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { installWebAutoReplyUnitTestHooks, makeSessionStore } from "./auto-reply.test-harness.js";
|
||||
import {
|
||||
createAcceptedWhatsAppSendResult,
|
||||
installWebAutoReplyUnitTestHooks,
|
||||
makeSessionStore,
|
||||
} from "./auto-reply.test-harness.js";
|
||||
|
||||
const updateLastRouteInBackgroundMock = vi.hoisted(() => vi.fn());
|
||||
let awaitBackgroundTasks: typeof import("./auto-reply/monitor/last-route.js").awaitBackgroundTasks;
|
||||
@@ -92,8 +96,8 @@ function buildInboundMessage(params: {
|
||||
senderName: params.senderName,
|
||||
selfE164: params.selfE164,
|
||||
sendComposing: vi.fn().mockResolvedValue(undefined),
|
||||
reply: vi.fn().mockResolvedValue(undefined),
|
||||
sendMedia: vi.fn().mockResolvedValue(undefined),
|
||||
reply: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1")),
|
||||
sendMedia: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1")),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,26 @@ vi.mock("../media.js", () => ({
|
||||
let deliverWebReply: typeof import("./deliver-reply.js").deliverWebReply;
|
||||
let whatsappOutbound: typeof import("../outbound-adapter.js").whatsappOutbound;
|
||||
|
||||
function acceptedSendResult(kind: "media" | "text", id: string) {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
messageIds: [id],
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function unacceptedSendResult(kind: "media" | "text") {
|
||||
return {
|
||||
kind,
|
||||
messageId: "unknown",
|
||||
messageIds: [],
|
||||
keys: [],
|
||||
providerAccepted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMsg(): WebInboundMsg {
|
||||
return {
|
||||
from: "+10000000000",
|
||||
@@ -58,8 +78,8 @@ function makeMsg(): WebInboundMsg {
|
||||
id: "msg-1",
|
||||
body: "latest batch body",
|
||||
senderJid: "222@s.whatsapp.net",
|
||||
reply: vi.fn(async () => undefined),
|
||||
sendMedia: vi.fn(async () => undefined),
|
||||
reply: vi.fn(async () => acceptedSendResult("text", "reply-sent-1")),
|
||||
sendMedia: vi.fn(async () => acceptedSendResult("media", "media-sent-1")),
|
||||
} as unknown as WebInboundMsg;
|
||||
}
|
||||
|
||||
@@ -99,7 +119,7 @@ function expectFirstSendMediaPayload(msg: WebInboundMsg) {
|
||||
|
||||
function mockSecondReplySuccess(msg: WebInboundMsg) {
|
||||
(msg.reply as unknown as { mockResolvedValueOnce: (v: unknown) => void }).mockResolvedValueOnce(
|
||||
undefined,
|
||||
acceptedSendResult("text", "reply-retry-2"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,7 +182,7 @@ describe("deliverWebReply", () => {
|
||||
it("sends chunked text replies and logs a summary", async () => {
|
||||
const msg = makeMsg();
|
||||
|
||||
await deliverWebReply({
|
||||
const delivery = await deliverWebReply({
|
||||
replyResult: { text: "aaaaaa" },
|
||||
msg,
|
||||
maxMediaBytes: 1024 * 1024,
|
||||
@@ -175,6 +195,32 @@ describe("deliverWebReply", () => {
|
||||
expect(msg.reply).toHaveBeenNthCalledWith(1, "aaa", undefined);
|
||||
expect(msg.reply).toHaveBeenNthCalledWith(2, "aaa", undefined);
|
||||
expect(replyLogger.info).toHaveBeenCalledWith(expect.any(Object), "auto-reply sent (text)");
|
||||
expect(delivery.providerAccepted).toBe(true);
|
||||
expect(delivery.messageIds).toEqual(["reply-sent-1"]);
|
||||
});
|
||||
|
||||
it("reports text replies that Baileys did not accept", async () => {
|
||||
const msg = makeMsg();
|
||||
vi.mocked(msg.reply).mockResolvedValueOnce(unacceptedSendResult("text"));
|
||||
|
||||
const delivery = await deliverWebReply({
|
||||
replyResult: { text: "hello" },
|
||||
msg,
|
||||
maxMediaBytes: 1024 * 1024,
|
||||
textLimit: 200,
|
||||
replyLogger,
|
||||
skipLog: true,
|
||||
});
|
||||
|
||||
expect(msg.reply).toHaveBeenCalledTimes(1);
|
||||
expect(delivery).toMatchObject({
|
||||
messageIds: [],
|
||||
providerAccepted: false,
|
||||
});
|
||||
expect(replyLogger.warn).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
"auto-reply text was not accepted by WhatsApp provider",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips raw XML tool-call blocks before WhatsApp text delivery", async () => {
|
||||
@@ -421,7 +467,7 @@ describe("deliverWebReply", () => {
|
||||
mockFirstSendMediaFailure(msg, "socket reset");
|
||||
(
|
||||
msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
|
||||
).mockResolvedValueOnce(undefined);
|
||||
).mockResolvedValueOnce(acceptedSendResult("media", "media-retry-2"));
|
||||
|
||||
await deliverWebReply({
|
||||
replyResult: { text: "caption", mediaUrl: "http://example.com/img.jpg" },
|
||||
@@ -484,7 +530,7 @@ describe("deliverWebReply", () => {
|
||||
mockFirstSendMediaFailure(msg, "boom");
|
||||
(
|
||||
msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
|
||||
).mockResolvedValueOnce(undefined);
|
||||
).mockResolvedValueOnce(acceptedSendResult("media", "media-second-1"));
|
||||
|
||||
await deliverWebReply({
|
||||
replyResult: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
sendMediaWithLeadingCaption,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { WhatsAppSendResult } from "../inbound/send-result.js";
|
||||
import { loadWebMedia } from "../media.js";
|
||||
import {
|
||||
type DeliverableWhatsAppOutboundPayload,
|
||||
@@ -23,6 +24,12 @@ import { whatsappOutboundLog } from "./loggers.js";
|
||||
import type { WebInboundMsg } from "./types.js";
|
||||
import { elide } from "./util.js";
|
||||
|
||||
export type WhatsAppReplyDeliveryResult = {
|
||||
results: WhatsAppSendResult[];
|
||||
messageIds: string[];
|
||||
providerAccepted: boolean;
|
||||
};
|
||||
|
||||
export async function deliverWebReply(params: {
|
||||
replyResult: ReplyPayload;
|
||||
normalizedReplyResult?: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
|
||||
@@ -38,12 +45,26 @@ export async function deliverWebReply(params: {
|
||||
connectionId?: string;
|
||||
skipLog?: boolean;
|
||||
tableMode?: MarkdownTableMode;
|
||||
}) {
|
||||
}): Promise<WhatsAppReplyDeliveryResult> {
|
||||
const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params;
|
||||
const replyStarted = Date.now();
|
||||
const sendResults: WhatsAppSendResult[] = [];
|
||||
const rememberSendResult = (result: WhatsAppSendResult | undefined) => {
|
||||
if (result) {
|
||||
sendResults.push(result);
|
||||
}
|
||||
};
|
||||
const finishDelivery = (): WhatsAppReplyDeliveryResult => {
|
||||
const messageIds = [...new Set(sendResults.flatMap((result) => result.messageIds))];
|
||||
return {
|
||||
results: sendResults,
|
||||
messageIds,
|
||||
providerAccepted: sendResults.some((result) => result.providerAccepted),
|
||||
};
|
||||
};
|
||||
if (isReasoningReplyPayload(replyResult)) {
|
||||
whatsappOutboundLog.debug(`Suppressed reasoning payload to ${msg.from}`);
|
||||
return;
|
||||
return finishDelivery();
|
||||
}
|
||||
const tableMode = params.tableMode ?? "code";
|
||||
const chunkMode = params.chunkMode ?? "length";
|
||||
@@ -75,7 +96,7 @@ export async function deliverWebReply(params: {
|
||||
});
|
||||
};
|
||||
|
||||
const sendWithRetry = async (fn: () => Promise<unknown>, label: string, maxAttempts = 3) => {
|
||||
const sendWithRetry = async <T>(fn: () => Promise<T>, label: string, maxAttempts = 3) => {
|
||||
return await sendWhatsAppOutboundWithRetry({
|
||||
send: fn,
|
||||
maxAttempts,
|
||||
@@ -93,7 +114,7 @@ export async function deliverWebReply(params: {
|
||||
for (const [index, chunk] of textChunks.entries()) {
|
||||
const chunkStarted = Date.now();
|
||||
const quote = getQuote();
|
||||
await sendWithRetry(() => msg.reply(chunk, quote), "text");
|
||||
rememberSendResult(await sendWithRetry(() => msg.reply(chunk, quote), "text"));
|
||||
if (!skipLog) {
|
||||
const durationMs = Date.now() - chunkStarted;
|
||||
whatsappOutboundLog.debug(
|
||||
@@ -101,21 +122,24 @@ export async function deliverWebReply(params: {
|
||||
);
|
||||
}
|
||||
}
|
||||
replyLogger.info(
|
||||
{
|
||||
correlationId: msg.id ?? newConnectionId(),
|
||||
connectionId: connectionId ?? null,
|
||||
to: msg.from,
|
||||
from: msg.to,
|
||||
text: elide(replyResult.text, 240),
|
||||
mediaUrl: null,
|
||||
mediaSizeBytes: null,
|
||||
mediaKind: null,
|
||||
durationMs: Date.now() - replyStarted,
|
||||
},
|
||||
"auto-reply sent (text)",
|
||||
);
|
||||
return;
|
||||
const delivery = finishDelivery();
|
||||
const logPayload = {
|
||||
correlationId: msg.id ?? newConnectionId(),
|
||||
connectionId: connectionId ?? null,
|
||||
to: msg.from,
|
||||
from: msg.to,
|
||||
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];
|
||||
@@ -141,63 +165,73 @@ export async function deliverWebReply(params: {
|
||||
}
|
||||
if (media.kind === "image") {
|
||||
const quote = getQuote();
|
||||
await sendWithRetry(
|
||||
() =>
|
||||
msg.sendMedia(
|
||||
{
|
||||
image: media.buffer,
|
||||
caption,
|
||||
mimetype: media.mimetype,
|
||||
},
|
||||
quote,
|
||||
),
|
||||
"media:image",
|
||||
rememberSendResult(
|
||||
await sendWithRetry(
|
||||
() =>
|
||||
msg.sendMedia(
|
||||
{
|
||||
image: media.buffer,
|
||||
caption,
|
||||
mimetype: media.mimetype,
|
||||
},
|
||||
quote,
|
||||
),
|
||||
"media:image",
|
||||
),
|
||||
);
|
||||
} else if (media.kind === "audio") {
|
||||
const quote = getQuote();
|
||||
await sendWithRetry(
|
||||
() =>
|
||||
msg.sendMedia(
|
||||
{
|
||||
audio: media.buffer,
|
||||
ptt: true,
|
||||
mimetype: media.mimetype,
|
||||
},
|
||||
quote,
|
||||
),
|
||||
"media:audio",
|
||||
rememberSendResult(
|
||||
await sendWithRetry(
|
||||
() =>
|
||||
msg.sendMedia(
|
||||
{
|
||||
audio: media.buffer,
|
||||
ptt: true,
|
||||
mimetype: media.mimetype,
|
||||
},
|
||||
quote,
|
||||
),
|
||||
"media:audio",
|
||||
),
|
||||
);
|
||||
if (caption) {
|
||||
await sendWithRetry(() => msg.reply(caption, quote), "media:audio-text");
|
||||
rememberSendResult(
|
||||
await sendWithRetry(() => msg.reply(caption, quote), "media:audio-text"),
|
||||
);
|
||||
}
|
||||
} else if (media.kind === "video") {
|
||||
const quote = getQuote();
|
||||
await sendWithRetry(
|
||||
() =>
|
||||
msg.sendMedia(
|
||||
{
|
||||
video: media.buffer,
|
||||
caption,
|
||||
mimetype: media.mimetype,
|
||||
},
|
||||
quote,
|
||||
),
|
||||
"media:video",
|
||||
rememberSendResult(
|
||||
await sendWithRetry(
|
||||
() =>
|
||||
msg.sendMedia(
|
||||
{
|
||||
video: media.buffer,
|
||||
caption,
|
||||
mimetype: media.mimetype,
|
||||
},
|
||||
quote,
|
||||
),
|
||||
"media:video",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const quote = getQuote();
|
||||
await sendWithRetry(
|
||||
() =>
|
||||
msg.sendMedia(
|
||||
{
|
||||
document: media.buffer,
|
||||
fileName: media.fileName,
|
||||
caption,
|
||||
mimetype: media.mimetype,
|
||||
},
|
||||
quote,
|
||||
),
|
||||
"media:document",
|
||||
rememberSendResult(
|
||||
await sendWithRetry(
|
||||
() =>
|
||||
msg.sendMedia(
|
||||
{
|
||||
document: media.buffer,
|
||||
fileName: media.fileName,
|
||||
caption,
|
||||
mimetype: media.mimetype,
|
||||
},
|
||||
quote,
|
||||
),
|
||||
"media:document",
|
||||
),
|
||||
);
|
||||
}
|
||||
whatsappOutboundLog.info(
|
||||
@@ -231,12 +265,15 @@ export async function deliverWebReply(params: {
|
||||
return;
|
||||
}
|
||||
whatsappOutboundLog.warn(`Media skipped; sent text-only to ${msg.from}`);
|
||||
await msg.reply(fallbackText, getQuote());
|
||||
rememberSendResult(
|
||||
await sendWithRetry(() => msg.reply(fallbackText, getQuote()), "media:fallback-text"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Remaining text chunks after media
|
||||
for (const chunk of remainingText) {
|
||||
await msg.reply(chunk, getQuote());
|
||||
rememberSendResult(await sendWithRetry(() => msg.reply(chunk, getQuote()), "media:text"));
|
||||
}
|
||||
return finishDelivery();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
|
||||
import type { WebInboundMessage } from "../../inbound/types.js";
|
||||
import { maybeSendAckReaction } from "./ack-reaction.js";
|
||||
|
||||
@@ -11,6 +12,16 @@ vi.mock("../../send.js", () => ({
|
||||
sendReactionWhatsApp: hoisted.sendReactionWhatsApp,
|
||||
}));
|
||||
|
||||
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
messageIds: [id],
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function createMessage(overrides: Partial<WebInboundMessage> = {}): WebInboundMessage {
|
||||
return {
|
||||
id: "msg-1",
|
||||
@@ -22,8 +33,8 @@ function createMessage(overrides: Partial<WebInboundMessage> = {}): WebInboundMe
|
||||
chatType: "direct",
|
||||
chatId: "15551234567@s.whatsapp.net",
|
||||
sendComposing: async () => {},
|
||||
reply: async () => {},
|
||||
sendMedia: async () => {},
|
||||
reply: async () => acceptedSendResult("text", "r1"),
|
||||
sendMedia: async () => acceptedSendResult("media", "m1"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
|
||||
import {
|
||||
resolveVisibleWhatsAppGroupHistory,
|
||||
resolveVisibleWhatsAppReplyContext,
|
||||
@@ -6,6 +7,16 @@ import {
|
||||
|
||||
type ReplyContextParams = Parameters<typeof resolveVisibleWhatsAppReplyContext>[0];
|
||||
|
||||
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
messageIds: [id],
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
const makeBlockedQuotedReplyMessage = (id: string): ReplyContextParams["msg"] => ({
|
||||
id,
|
||||
from: "123@g.us",
|
||||
@@ -24,8 +35,8 @@ const makeBlockedQuotedReplyMessage = (id: string): ReplyContextParams["msg"] =>
|
||||
replyToSender: "Mallory (+999)",
|
||||
replyToSenderJid: "999@s.whatsapp.net",
|
||||
sendComposing: async () => {},
|
||||
reply: async () => {},
|
||||
sendMedia: async () => {},
|
||||
reply: async () => acceptedSendResult("text", "r1"),
|
||||
sendMedia: async () => acceptedSendResult("media", "m1"),
|
||||
});
|
||||
|
||||
describe("whatsapp inbound context visibility", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
|
||||
|
||||
let capturedDispatchParams: unknown;
|
||||
|
||||
@@ -75,6 +76,16 @@ import {
|
||||
type TestRoute = Parameters<typeof buildWhatsAppInboundContext>[0]["route"];
|
||||
type TestMsg = Parameters<typeof buildWhatsAppInboundContext>[0]["msg"];
|
||||
|
||||
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
messageIds: [id],
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRoute(overrides: Partial<TestRoute> = {}): TestRoute {
|
||||
return {
|
||||
agentId: "main",
|
||||
@@ -99,8 +110,8 @@ function makeMsg(overrides: Partial<TestMsg> = {}): TestMsg {
|
||||
chatType: "direct",
|
||||
body: "hi",
|
||||
sendComposing: async () => {},
|
||||
reply: async () => {},
|
||||
sendMedia: async () => {},
|
||||
reply: async () => acceptedSendResult("text", "r1"),
|
||||
sendMedia: async () => acceptedSendResult("media", "m1"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -139,13 +150,37 @@ function makeReplyLogger(): BufferedReplyParams["replyLogger"] {
|
||||
} as never;
|
||||
}
|
||||
|
||||
function acceptedDeliveryResult() {
|
||||
return {
|
||||
results: [
|
||||
{
|
||||
kind: "text" as const,
|
||||
messageId: "wa-sent-1",
|
||||
messageIds: ["wa-sent-1"],
|
||||
keys: [{ id: "wa-sent-1" }],
|
||||
providerAccepted: true,
|
||||
},
|
||||
],
|
||||
messageIds: ["wa-sent-1"],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function unacceptedDeliveryResult() {
|
||||
return {
|
||||
results: [],
|
||||
messageIds: [],
|
||||
providerAccepted: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchBufferedReply(overrides: Partial<BufferedReplyParams> = {}) {
|
||||
const params: BufferedReplyParams = {
|
||||
cfg: { channels: { whatsapp: { blockStreaming: true } } } as never,
|
||||
connectionId: "conn",
|
||||
context: { Body: "hi" },
|
||||
conversationId: "+1000",
|
||||
deliverReply: async () => {},
|
||||
deliverReply: async () => acceptedDeliveryResult(),
|
||||
groupHistories: new Map(),
|
||||
groupHistoryKey: "+1000",
|
||||
maxMediaBytes: 1,
|
||||
@@ -390,7 +425,7 @@ describe("whatsapp inbound dispatch", () => {
|
||||
});
|
||||
|
||||
it("delivers block and final WhatsApp payloads; suppresses text-only tool payloads but delivers media", async () => {
|
||||
const deliverReply = vi.fn(async () => undefined);
|
||||
const deliverReply = vi.fn(async () => acceptedDeliveryResult());
|
||||
const rememberSentText = vi.fn();
|
||||
|
||||
await dispatchBufferedReply({
|
||||
@@ -446,7 +481,7 @@ describe("whatsapp inbound dispatch", () => {
|
||||
});
|
||||
|
||||
it("normalizes WhatsApp payload text before delivery and echo bookkeeping", async () => {
|
||||
const deliverReply = vi.fn(async () => undefined);
|
||||
const deliverReply = vi.fn(async () => acceptedDeliveryResult());
|
||||
const rememberSentText = vi.fn();
|
||||
|
||||
await dispatchBufferedReply({
|
||||
@@ -479,7 +514,7 @@ describe("whatsapp inbound dispatch", () => {
|
||||
});
|
||||
|
||||
it("suppresses reasoning and compaction payloads before WhatsApp delivery", async () => {
|
||||
const deliverReply = vi.fn(async () => undefined);
|
||||
const deliverReply = vi.fn(async () => acceptedDeliveryResult());
|
||||
const rememberSentText = vi.fn();
|
||||
|
||||
await dispatchBufferedReply({
|
||||
@@ -500,7 +535,7 @@ describe("whatsapp inbound dispatch", () => {
|
||||
});
|
||||
|
||||
it("suppresses payloads that normalize to no visible WhatsApp content", async () => {
|
||||
const deliverReply = vi.fn(async () => undefined);
|
||||
const deliverReply = vi.fn(async () => acceptedDeliveryResult());
|
||||
const rememberSentText = vi.fn();
|
||||
|
||||
await dispatchBufferedReply({
|
||||
@@ -523,7 +558,7 @@ describe("whatsapp inbound dispatch", () => {
|
||||
});
|
||||
|
||||
it("suppresses error payload text", async () => {
|
||||
const deliverReply = vi.fn(async () => undefined);
|
||||
const deliverReply = vi.fn(async () => acceptedDeliveryResult());
|
||||
const rememberSentText = vi.fn();
|
||||
|
||||
await dispatchBufferedReply({ deliverReply, rememberSentText });
|
||||
@@ -578,7 +613,7 @@ describe("whatsapp inbound dispatch", () => {
|
||||
});
|
||||
|
||||
it("treats block-only turns as visible replies instead of silent turns", async () => {
|
||||
const deliverReply = vi.fn(async () => undefined);
|
||||
const deliverReply = vi.fn(async () => acceptedDeliveryResult());
|
||||
const rememberSentText = vi.fn();
|
||||
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce(
|
||||
async (params: {
|
||||
@@ -607,8 +642,52 @@ describe("whatsapp inbound dispatch", () => {
|
||||
expect(rememberSentText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not treat generated WhatsApp text as sent when the provider did not accept it", async () => {
|
||||
const deliverReply = vi.fn(async () => unacceptedDeliveryResult());
|
||||
const rememberSentText = vi.fn();
|
||||
const replyLogger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
} as unknown as BufferedReplyParams["replyLogger"];
|
||||
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce(
|
||||
async (params: {
|
||||
ctx: unknown;
|
||||
dispatcherOptions?: {
|
||||
deliver?: (
|
||||
payload: { text?: string },
|
||||
info: { kind: "tool" | "block" | "final" },
|
||||
) => Promise<void>;
|
||||
};
|
||||
}) => {
|
||||
capturedDispatchParams = params;
|
||||
await params.dispatcherOptions?.deliver?.({ text: "final text" }, { kind: "final" });
|
||||
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 1 } };
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
dispatchBufferedReply({
|
||||
deliverReply,
|
||||
rememberSentText,
|
||||
replyLogger,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(deliverReply).toHaveBeenCalledTimes(1);
|
||||
expect(rememberSentText).not.toHaveBeenCalled();
|
||||
expect(replyLogger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyKind: "final",
|
||||
conversationId: "+1000",
|
||||
}),
|
||||
"auto-reply was not accepted by WhatsApp provider",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true for tool-only media turns after delivering media", async () => {
|
||||
const deliverReply = vi.fn(async () => undefined);
|
||||
const deliverReply = vi.fn(async () => acceptedDeliveryResult());
|
||||
const rememberSentText = vi.fn();
|
||||
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce(
|
||||
async (params: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
normalizeWhatsAppOutboundPayload,
|
||||
normalizeWhatsAppPayloadTextPreservingIndentation,
|
||||
} from "../../outbound-media-contract.js";
|
||||
import type { WhatsAppReplyDeliveryResult } from "../deliver-reply.js";
|
||||
import type { WebInboundMsg } from "../types.js";
|
||||
import { formatGroupMembers } from "./group-members.js";
|
||||
import type { GroupHistoryEntry } from "./inbound-context.js";
|
||||
@@ -283,7 +284,7 @@ export async function dispatchWhatsAppBufferedReply(params: {
|
||||
connectionId?: string;
|
||||
skipLog?: boolean;
|
||||
tableMode?: ReturnType<typeof resolveMarkdownTableMode>;
|
||||
}) => Promise<void>;
|
||||
}) => Promise<WhatsAppReplyDeliveryResult>;
|
||||
groupHistories: Map<string, GroupHistoryEntry[]>;
|
||||
groupHistoryKey: string;
|
||||
maxMediaBytes: number;
|
||||
@@ -344,7 +345,7 @@ export async function dispatchWhatsAppBufferedReply(params: {
|
||||
if (!reply.hasMedia && !reply.text.trim()) {
|
||||
return;
|
||||
}
|
||||
await params.deliverReply({
|
||||
const delivery = await params.deliverReply({
|
||||
replyResult: normalizedDeliveryPayload,
|
||||
normalizedReplyResult: normalizedDeliveryPayload,
|
||||
msg: params.msg,
|
||||
@@ -357,6 +358,21 @@ export async function dispatchWhatsAppBufferedReply(params: {
|
||||
skipLog: false,
|
||||
tableMode,
|
||||
});
|
||||
if (!delivery.providerAccepted) {
|
||||
params.replyLogger.warn(
|
||||
{
|
||||
correlationId: params.msg.id ?? null,
|
||||
connectionId: params.connectionId,
|
||||
conversationId: params.conversationId,
|
||||
chatId: params.msg.chatId,
|
||||
to: params.msg.from,
|
||||
from: params.msg.to,
|
||||
replyKind: info.kind,
|
||||
},
|
||||
"auto-reply was not accepted by WhatsApp provider",
|
||||
);
|
||||
return;
|
||||
}
|
||||
didSendReply = true;
|
||||
const shouldLog = normalizedDeliveryPayload.text ? true : undefined;
|
||||
params.rememberSentText(normalizedDeliveryPayload.text, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
|
||||
|
||||
// Hoisted mocks used across tests so vi.mock factories can reference them.
|
||||
const { resolvePolicyMock, buildContextMock, runMessageReceivedMock, trackBackgroundTaskMock } =
|
||||
@@ -9,6 +10,16 @@ const { resolvePolicyMock, buildContextMock, runMessageReceivedMock, trackBackgr
|
||||
trackBackgroundTaskMock: vi.fn(),
|
||||
}));
|
||||
|
||||
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
messageIds: [id],
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../../inbound-policy.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../inbound-policy.js")>();
|
||||
return {
|
||||
@@ -169,8 +180,8 @@ const baseMsg = {
|
||||
chatType: "group" as const,
|
||||
body: "hi",
|
||||
sendComposing: async () => {},
|
||||
reply: async () => {},
|
||||
sendMedia: async () => {},
|
||||
reply: async () => acceptedSendResult("text", "r1"),
|
||||
sendMedia: async () => acceptedSendResult("media", "m1"),
|
||||
};
|
||||
|
||||
const baseRoute = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { WhatsAppSendResult } from "../inbound/send-result.js";
|
||||
import { buildMentionConfig } from "./mentions.js";
|
||||
import { applyGroupGating, type GroupHistoryEntry } from "./monitor/group-gating.js";
|
||||
import { buildInboundLine, formatReplyContext } from "./monitor/message-line.js";
|
||||
@@ -11,6 +12,16 @@ import type { WebInboundMsg } from "./types.js";
|
||||
let sessionDir: string | undefined;
|
||||
let sessionStorePath: string;
|
||||
|
||||
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
messageIds: [id],
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-group-gating-"));
|
||||
sessionStorePath = path.join(sessionDir, "sessions.json");
|
||||
@@ -82,8 +93,8 @@ function createGroupMessage(overrides: Partial<WebInboundMsg> = {}): WebInboundM
|
||||
senderName: "Alice",
|
||||
selfE164: "+999",
|
||||
sendComposing: async () => {},
|
||||
reply: async (_text, _options) => {},
|
||||
sendMedia: async (_payload, _options) => {},
|
||||
reply: async (_text, _options) => acceptedSendResult("text", "r1"),
|
||||
sendMedia: async (_payload, _options) => acceptedSendResult("media", "m1"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { withTempDir } from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { WhatsAppSendResult } from "../inbound/send-result.js";
|
||||
import {
|
||||
debugMention,
|
||||
isBotMentionedFromTargets,
|
||||
@@ -13,6 +14,16 @@ import { getSessionSnapshot } from "./session-snapshot.js";
|
||||
import type { WebInboundMsg } from "./types.js";
|
||||
import { elide, isLikelyWhatsAppCryptoError } from "./util.js";
|
||||
|
||||
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
messageIds: [id],
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
const makeMsg = (overrides: Partial<WebInboundMsg>): WebInboundMsg =>
|
||||
({
|
||||
id: "m1",
|
||||
@@ -24,8 +35,8 @@ const makeMsg = (overrides: Partial<WebInboundMsg>): WebInboundMsg =>
|
||||
chatType: "group",
|
||||
chatId: "120363401234567890@g.us",
|
||||
sendComposing: async () => {},
|
||||
reply: async () => {},
|
||||
sendMedia: async () => {},
|
||||
reply: async () => acceptedSendResult("text", "r1"),
|
||||
sendMedia: async () => acceptedSendResult("media", "m1"),
|
||||
...overrides,
|
||||
}) as WebInboundMsg;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { EventEmitter } from "node:events";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
|
||||
import { WhatsAppConnectionController } from "./connection-controller.js";
|
||||
import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
|
||||
import { createWaSocket, waitForWaConnection } from "./session.js";
|
||||
|
||||
vi.mock("./session.js", async () => {
|
||||
@@ -16,11 +17,21 @@ vi.mock("./session.js", async () => {
|
||||
const createWaSocketMock = vi.mocked(createWaSocket);
|
||||
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
|
||||
|
||||
function acceptedSendResult(kind: WhatsAppSendKind, id: string): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
messageIds: [id],
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function createListenerStub(messageId = "ok") {
|
||||
return {
|
||||
sendMessage: vi.fn(async () => ({ messageId })),
|
||||
sendPoll: vi.fn(async () => ({ messageId })),
|
||||
sendReaction: vi.fn(async () => {}),
|
||||
sendMessage: vi.fn(async () => acceptedSendResult("text", messageId)),
|
||||
sendPoll: vi.fn(async () => acceptedSendResult("poll", messageId)),
|
||||
sendReaction: vi.fn(async () => acceptedSendResult("reaction", messageId)),
|
||||
sendComposingTo: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { attachEmitterListener, closeInboundMonitorSocket } from "./lifecycle.js
|
||||
import { downloadInboundMedia } from "./media.js";
|
||||
import { DisconnectReason, isJidGroup, saveMediaBuffer } from "./runtime-api.js";
|
||||
import { createWebSendApi } from "./send-api.js";
|
||||
import { normalizeWhatsAppSendResult } from "./send-result.js";
|
||||
import type { WebInboundMessage, WebListenerCloseReason } from "./types.js";
|
||||
|
||||
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
|
||||
@@ -622,13 +623,15 @@ export async function attachWebInboxToSocket(
|
||||
}
|
||||
};
|
||||
const reply = async (text: string, options?: MiscMessageGenerationOptions) => {
|
||||
await sendTrackedMessage(chatJid, { text }, options);
|
||||
const result = await sendTrackedMessage(chatJid, { text }, options);
|
||||
return normalizeWhatsAppSendResult(result, "text");
|
||||
};
|
||||
const sendMedia = async (
|
||||
payload: AnyMessageContent,
|
||||
options?: MiscMessageGenerationOptions,
|
||||
) => {
|
||||
await sendTrackedMessage(chatJid, payload, options);
|
||||
const result = await sendTrackedMessage(chatJid, payload, options);
|
||||
return normalizeWhatsAppSendResult(result, "media");
|
||||
};
|
||||
const timestamp = inbound.messageTimestampMs;
|
||||
const mentionedJids = extractMentionedJids(msg.message as proto.IMessage | undefined);
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
AnyMessageContent,
|
||||
MiscMessageGenerationOptions,
|
||||
WAMessage,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createWebSendApi } from "./send-api.js";
|
||||
|
||||
@@ -14,7 +19,13 @@ vi.mock("openclaw/plugin-sdk/channel-activity-runtime", async () => {
|
||||
});
|
||||
|
||||
describe("createWebSendApi", () => {
|
||||
const sendMessage = vi.fn(async () => ({ key: { id: "msg-1" } }));
|
||||
const sendMessage = vi.fn(
|
||||
async (
|
||||
_jid: string,
|
||||
_content: AnyMessageContent,
|
||||
_options?: MiscMessageGenerationOptions,
|
||||
): Promise<WAMessage | undefined> => ({ key: { id: "msg-1" } }) as WAMessage,
|
||||
);
|
||||
const sendPresenceUpdate = vi.fn(async () => {});
|
||||
let api: ReturnType<typeof createWebSendApi>;
|
||||
|
||||
@@ -60,8 +71,14 @@ describe("createWebSendApi", () => {
|
||||
});
|
||||
|
||||
it("sends plain text messages", async () => {
|
||||
await api.sendMessage("+1555", "hello");
|
||||
const res = await api.sendMessage("+1555", "hello");
|
||||
expect(sendMessage).toHaveBeenCalledWith("1555@s.whatsapp.net", { text: "hello" });
|
||||
expect(res).toMatchObject({
|
||||
kind: "text",
|
||||
messageId: "msg-1",
|
||||
messageIds: ["msg-1"],
|
||||
providerAccepted: true,
|
||||
});
|
||||
expect(recordChannelActivity).toHaveBeenCalledWith({
|
||||
channel: "whatsapp",
|
||||
accountId: "main",
|
||||
@@ -102,7 +119,10 @@ describe("createWebSendApi", () => {
|
||||
|
||||
it("sends visible text separately from push-to-talk voice notes", async () => {
|
||||
const payload = Buffer.from("aud");
|
||||
await api.sendMessage("+1555", "voice text", payload, "audio/ogg");
|
||||
sendMessage
|
||||
.mockResolvedValueOnce({ key: { id: "voice-1" } })
|
||||
.mockResolvedValueOnce({ key: { id: "voice-text-1" } });
|
||||
const res = await api.sendMessage("+1555", "voice text", payload, "audio/ogg");
|
||||
expect(sendMessage).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"1555@s.whatsapp.net",
|
||||
@@ -115,6 +135,12 @@ describe("createWebSendApi", () => {
|
||||
expect(sendMessage).toHaveBeenNthCalledWith(2, "1555@s.whatsapp.net", {
|
||||
text: "voice text",
|
||||
});
|
||||
expect(res).toMatchObject({
|
||||
kind: "media",
|
||||
messageId: "voice-1",
|
||||
messageIds: ["voice-1", "voice-text-1"],
|
||||
providerAccepted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("supports video media and gifPlayback option", async () => {
|
||||
@@ -158,7 +184,7 @@ describe("createWebSendApi", () => {
|
||||
});
|
||||
|
||||
it("sends reactions with participant JID normalization", async () => {
|
||||
await api.sendReaction("+1555", "msg-2", "👍", false, "+1999");
|
||||
const res = await api.sendReaction("+1555", "msg-2", "👍", false, "+1999");
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"1555@s.whatsapp.net",
|
||||
expect.objectContaining({
|
||||
@@ -173,6 +199,24 @@ describe("createWebSendApi", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res).toMatchObject({
|
||||
kind: "reaction",
|
||||
messageId: "msg-1",
|
||||
providerAccepted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports provider-unaccepted sends when Baileys returns no message", async () => {
|
||||
sendMessage.mockResolvedValueOnce(undefined);
|
||||
|
||||
const res = await api.sendMessage("+1555", "hello");
|
||||
|
||||
expect(res).toMatchObject({
|
||||
kind: "text",
|
||||
messageId: "unknown",
|
||||
messageIds: [],
|
||||
providerAccepted: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps direct-chat reactions without a participant key", async () => {
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type {
|
||||
AnyMessageContent,
|
||||
MiscMessageGenerationOptions,
|
||||
WAMessage,
|
||||
WAPresence,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
|
||||
import { buildQuotedMessageOptions } from "../quoted-message.js";
|
||||
import { toWhatsappJid } from "../text-runtime.js";
|
||||
import {
|
||||
combineWhatsAppSendResults,
|
||||
normalizeWhatsAppSendResult,
|
||||
type WhatsAppSendResult,
|
||||
} from "./send-result.js";
|
||||
import type { ActiveWebSendOptions } from "./types.js";
|
||||
|
||||
function recordWhatsAppOutbound(accountId: string) {
|
||||
@@ -16,19 +22,13 @@ function recordWhatsAppOutbound(accountId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveOutboundMessageId(result: unknown): string {
|
||||
return typeof result === "object" && result && "key" in result
|
||||
? ((result as { key?: { id?: string } }).key?.id ?? "unknown")
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
export function createWebSendApi(params: {
|
||||
sock: {
|
||||
sendMessage: (
|
||||
jid: string,
|
||||
content: AnyMessageContent,
|
||||
options?: MiscMessageGenerationOptions,
|
||||
) => Promise<unknown>;
|
||||
) => Promise<WAMessage | undefined>;
|
||||
sendPresenceUpdate: (presence: WAPresence, jid?: string) => Promise<unknown>;
|
||||
};
|
||||
defaultAccountId: string;
|
||||
@@ -40,7 +40,7 @@ export function createWebSendApi(params: {
|
||||
mediaBuffer?: Buffer,
|
||||
mediaType?: string,
|
||||
sendOptions?: ActiveWebSendOptions,
|
||||
): Promise<{ messageId: string }> => {
|
||||
): Promise<WhatsAppSendResult> => {
|
||||
const jid = toWhatsappJid(to);
|
||||
let payload: AnyMessageContent;
|
||||
if (mediaBuffer) {
|
||||
@@ -85,23 +85,22 @@ export function createWebSendApi(params: {
|
||||
const result = quotedOpts
|
||||
? await params.sock.sendMessage(jid, payload, quotedOpts)
|
||||
: await params.sock.sendMessage(jid, payload);
|
||||
const results = [normalizeWhatsAppSendResult(result, mediaBuffer ? "media" : "text")];
|
||||
if (mediaBuffer && mediaType?.startsWith("audio/") && text.trim()) {
|
||||
const textPayload: AnyMessageContent = { text };
|
||||
if (quotedOpts) {
|
||||
await params.sock.sendMessage(jid, textPayload, quotedOpts);
|
||||
} else {
|
||||
await params.sock.sendMessage(jid, textPayload);
|
||||
}
|
||||
const textResult = quotedOpts
|
||||
? await params.sock.sendMessage(jid, textPayload, quotedOpts)
|
||||
: await params.sock.sendMessage(jid, textPayload);
|
||||
results.push(normalizeWhatsAppSendResult(textResult, "text"));
|
||||
}
|
||||
const accountId = sendOptions?.accountId ?? params.defaultAccountId;
|
||||
recordWhatsAppOutbound(accountId);
|
||||
const messageId = resolveOutboundMessageId(result);
|
||||
return { messageId };
|
||||
return combineWhatsAppSendResults(mediaBuffer ? "media" : "text", results);
|
||||
},
|
||||
sendPoll: async (
|
||||
to: string,
|
||||
poll: { question: string; options: string[]; maxSelections?: number },
|
||||
): Promise<{ messageId: string }> => {
|
||||
): Promise<WhatsAppSendResult> => {
|
||||
const jid = toWhatsappJid(to);
|
||||
const result = await params.sock.sendMessage(jid, {
|
||||
poll: {
|
||||
@@ -111,8 +110,7 @@ export function createWebSendApi(params: {
|
||||
},
|
||||
} as AnyMessageContent);
|
||||
recordWhatsAppOutbound(params.defaultAccountId);
|
||||
const messageId = resolveOutboundMessageId(result);
|
||||
return { messageId };
|
||||
return normalizeWhatsAppSendResult(result, "poll");
|
||||
},
|
||||
sendReaction: async (
|
||||
chatJid: string,
|
||||
@@ -120,9 +118,9 @@ export function createWebSendApi(params: {
|
||||
emoji: string,
|
||||
fromMe: boolean,
|
||||
participant?: string,
|
||||
): Promise<void> => {
|
||||
): Promise<WhatsAppSendResult> => {
|
||||
const jid = toWhatsappJid(chatJid);
|
||||
await params.sock.sendMessage(jid, {
|
||||
const result = await params.sock.sendMessage(jid, {
|
||||
react: {
|
||||
text: emoji,
|
||||
key: {
|
||||
@@ -133,6 +131,7 @@ export function createWebSendApi(params: {
|
||||
},
|
||||
},
|
||||
} as AnyMessageContent);
|
||||
return normalizeWhatsAppSendResult(result, "reaction");
|
||||
},
|
||||
sendComposingTo: async (to: string): Promise<void> => {
|
||||
const jid = toWhatsappJid(to);
|
||||
|
||||
67
extensions/whatsapp/src/inbound/send-result.ts
Normal file
67
extensions/whatsapp/src/inbound/send-result.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { WAMessage, WAMessageKey } from "@whiskeysockets/baileys";
|
||||
|
||||
export type WhatsAppSendKind = "media" | "poll" | "reaction" | "text";
|
||||
|
||||
export type WhatsAppSendKey = Omit<
|
||||
Pick<WAMessageKey, "fromMe" | "id" | "participant" | "remoteJid">,
|
||||
"id"
|
||||
> & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type WhatsAppSendResult = {
|
||||
kind: WhatsAppSendKind;
|
||||
messageId: string;
|
||||
messageIds: string[];
|
||||
keys: WhatsAppSendKey[];
|
||||
providerAccepted: boolean;
|
||||
};
|
||||
|
||||
function normalizeKey(key: WAMessageKey | undefined): WhatsAppSendKey | undefined {
|
||||
const id = typeof key?.id === "string" ? key.id.trim() : "";
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
remoteJid: key?.remoteJid,
|
||||
fromMe: key?.fromMe,
|
||||
participant: key?.participant,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWhatsAppSendResult(
|
||||
result: WAMessage | undefined,
|
||||
kind: WhatsAppSendKind,
|
||||
): WhatsAppSendResult {
|
||||
const key = normalizeKey(result?.key);
|
||||
const messageId = key?.id ?? "unknown";
|
||||
return {
|
||||
kind,
|
||||
messageId,
|
||||
messageIds: key ? [key.id] : [],
|
||||
keys: key ? [key] : [],
|
||||
providerAccepted: Boolean(key),
|
||||
};
|
||||
}
|
||||
|
||||
export function combineWhatsAppSendResults(
|
||||
kind: WhatsAppSendKind,
|
||||
results: readonly WhatsAppSendResult[],
|
||||
): WhatsAppSendResult {
|
||||
const messageIds = [...new Set(results.flatMap((result) => result.messageIds))];
|
||||
const keys = results.flatMap((result) => result.keys);
|
||||
return {
|
||||
kind,
|
||||
messageId: messageIds[0] ?? "unknown",
|
||||
messageIds,
|
||||
keys,
|
||||
providerAccepted: results.some((result) => result.providerAccepted),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasAcceptedWhatsAppSendResult(
|
||||
result: WhatsAppSendResult | undefined,
|
||||
): result is WhatsAppSendResult {
|
||||
return result?.providerAccepted === true;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { AnyMessageContent, MiscMessageGenerationOptions } from "@whiskeyso
|
||||
import type { NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import type { PollInput } from "openclaw/plugin-sdk/poll-runtime";
|
||||
import type { WhatsAppIdentity, WhatsAppReplyContext, WhatsAppSelfIdentity } from "../identity.js";
|
||||
import type { WhatsAppSendResult } from "./send-result.js";
|
||||
|
||||
export type WebListenerCloseReason = {
|
||||
status?: number;
|
||||
@@ -29,15 +30,15 @@ export type ActiveWebListener = {
|
||||
mediaBuffer?: Buffer,
|
||||
mediaType?: string,
|
||||
options?: ActiveWebSendOptions,
|
||||
) => Promise<{ messageId: string }>;
|
||||
sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>;
|
||||
) => Promise<WhatsAppSendResult>;
|
||||
sendPoll: (to: string, poll: PollInput) => Promise<WhatsAppSendResult>;
|
||||
sendReaction: (
|
||||
chatJid: string,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
fromMe: boolean,
|
||||
participant?: string,
|
||||
) => Promise<void>;
|
||||
) => Promise<WhatsAppSendResult>;
|
||||
sendComposingTo: (to: string) => Promise<void>;
|
||||
close?: () => Promise<void>;
|
||||
};
|
||||
@@ -85,8 +86,11 @@ export type WebInboundMessage = {
|
||||
fromMe?: boolean;
|
||||
location?: NormalizedLocation;
|
||||
sendComposing: () => Promise<void>;
|
||||
reply: (text: string, options?: MiscMessageGenerationOptions) => Promise<void>;
|
||||
sendMedia: (payload: AnyMessageContent, options?: MiscMessageGenerationOptions) => Promise<void>;
|
||||
reply: (text: string, options?: MiscMessageGenerationOptions) => Promise<WhatsAppSendResult>;
|
||||
sendMedia: (
|
||||
payload: AnyMessageContent,
|
||||
options?: MiscMessageGenerationOptions,
|
||||
) => Promise<WhatsAppSendResult>;
|
||||
mediaPath?: string;
|
||||
mediaType?: string;
|
||||
mediaFileName?: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
|
||||
import type { ActiveWebListener } from "./inbound/types.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
@@ -23,6 +24,16 @@ const WHATSAPP_TEST_CFG: OpenClawConfig = {
|
||||
channels: { whatsapp: {} },
|
||||
};
|
||||
|
||||
function acceptedSendResult(kind: WhatsAppSendKind, id: string): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
messageIds: [id],
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("./connection-controller-registry.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./connection-controller-registry.js")>(
|
||||
"./connection-controller-registry.js",
|
||||
@@ -70,9 +81,9 @@ vi.mock("./text-runtime.js", async () => {
|
||||
|
||||
describe("web outbound", () => {
|
||||
const sendComposingTo = vi.fn(async () => {});
|
||||
const sendMessage = vi.fn(async () => ({ messageId: "msg123" }));
|
||||
const sendPoll = vi.fn(async () => ({ messageId: "poll123" }));
|
||||
const sendReaction = vi.fn(async () => {});
|
||||
const sendMessage = vi.fn(async () => acceptedSendResult("text", "msg123"));
|
||||
const sendPoll = vi.fn(async () => acceptedSendResult("poll", "poll123"));
|
||||
const sendReaction = vi.fn(async () => acceptedSendResult("reaction", "reaction123"));
|
||||
|
||||
beforeAll(async () => {
|
||||
({ sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp } = await import("./send.js"));
|
||||
|
||||
Reference in New Issue
Block a user