mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 19:31:00 +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.
|
- 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: 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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>
|
||||||
|
|
||||||
|
<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">
|
<Accordion title="Group messages unexpectedly ignored">
|
||||||
Check in this order:
|
Check in this order:
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
sendWebDirectInboundAndCollectSessionKeys,
|
sendWebDirectInboundAndCollectSessionKeys,
|
||||||
} from "./auto-reply.broadcast-groups.test-harness.js";
|
} from "./auto-reply.broadcast-groups.test-harness.js";
|
||||||
import {
|
import {
|
||||||
|
createAcceptedWhatsAppSendResult,
|
||||||
installWebAutoReplyTestHomeHooks,
|
installWebAutoReplyTestHomeHooks,
|
||||||
installWebAutoReplyUnitTestHooks,
|
installWebAutoReplyUnitTestHooks,
|
||||||
resetLoadConfigMock,
|
resetLoadConfigMock,
|
||||||
@@ -202,8 +203,8 @@ describe("broadcast groups", () => {
|
|||||||
},
|
},
|
||||||
} satisfies OpenClawConfig);
|
} satisfies OpenClawConfig);
|
||||||
|
|
||||||
const sendMedia = vi.fn();
|
const sendMedia = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1"));
|
||||||
const reply = vi.fn().mockResolvedValue(undefined);
|
const reply = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1"));
|
||||||
const sendComposing = vi.fn();
|
const sendComposing = vi.fn();
|
||||||
|
|
||||||
let started = 0;
|
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 { afterAll, afterEach, beforeAll, beforeEach, vi, type Mock } from "vitest";
|
||||||
import type { WebChannelStatus } from "./auto-reply/types.js";
|
import type { WebChannelStatus } from "./auto-reply/types.js";
|
||||||
import type { WebInboundMessage, WebListenerCloseReason } from "./inbound.js";
|
import type { WebInboundMessage, WebListenerCloseReason } from "./inbound.js";
|
||||||
|
import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
|
||||||
import {
|
import {
|
||||||
resetBaileysMocks as _resetBaileysMocks,
|
resetBaileysMocks as _resetBaileysMocks,
|
||||||
resetLoadConfigMock as _resetLoadConfigMock,
|
resetLoadConfigMock as _resetLoadConfigMock,
|
||||||
@@ -27,9 +28,9 @@ type MockWebListener = {
|
|||||||
close: () => Promise<void>;
|
close: () => Promise<void>;
|
||||||
onClose: Promise<WebListenerCloseReason>;
|
onClose: Promise<WebListenerCloseReason>;
|
||||||
signalClose: () => void;
|
signalClose: () => void;
|
||||||
sendMessage: () => Promise<{ messageId: string }>;
|
sendMessage: () => Promise<WhatsAppSendResult>;
|
||||||
sendPoll: () => Promise<{ messageId: string }>;
|
sendPoll: () => Promise<WhatsAppSendResult>;
|
||||||
sendReaction: () => Promise<void>;
|
sendReaction: () => Promise<WhatsAppSendResult>;
|
||||||
sendComposingTo: () => Promise<void>;
|
sendComposingTo: () => Promise<void>;
|
||||||
};
|
};
|
||||||
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
||||||
@@ -253,13 +254,26 @@ export function createMockWebListener(): MockWebListener {
|
|||||||
close: vi.fn(async () => undefined),
|
close: vi.fn(async () => undefined),
|
||||||
onClose: new Promise<WebListenerCloseReason>(() => {}),
|
onClose: new Promise<WebListenerCloseReason>(() => {}),
|
||||||
signalClose: vi.fn(),
|
signalClose: vi.fn(),
|
||||||
sendMessage: vi.fn(async () => ({ messageId: "msg-1" })),
|
sendMessage: vi.fn(async () => createAcceptedWhatsAppSendResult("text", "msg-1")),
|
||||||
sendPoll: vi.fn(async () => ({ messageId: "poll-1" })),
|
sendPoll: vi.fn(async () => createAcceptedWhatsAppSendResult("poll", "poll-1")),
|
||||||
sendReaction: vi.fn(async () => undefined),
|
sendReaction: vi.fn(async () => createAcceptedWhatsAppSendResult("reaction", "reaction-1")),
|
||||||
sendComposingTo: vi.fn(async () => undefined),
|
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 {
|
export function createScriptedWebListenerFactory(): AnyExport {
|
||||||
const onMessages: Array<(msg: WebInboundMessage) => Promise<void>> = [];
|
const onMessages: Array<(msg: WebInboundMessage) => Promise<void>> = [];
|
||||||
const closeResolvers: Array<(reason: unknown) => void> = [];
|
const closeResolvers: Array<(reason: unknown) => void> = [];
|
||||||
@@ -294,8 +308,8 @@ export function createScriptedWebListenerFactory(): AnyExport {
|
|||||||
|
|
||||||
export function createWebInboundDeliverySpies(): AnyExport {
|
export function createWebInboundDeliverySpies(): AnyExport {
|
||||||
return {
|
return {
|
||||||
sendMedia: vi.fn(),
|
sendMedia: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1")),
|
||||||
reply: vi.fn().mockResolvedValue(undefined),
|
reply: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1")),
|
||||||
sendComposing: vi.fn(),
|
sendComposing: vi.fn(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import sharp from "sharp";
|
|||||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
createMockWebListener,
|
createMockWebListener,
|
||||||
|
createAcceptedWhatsAppSendResult,
|
||||||
installWebAutoReplyTestHomeHooks,
|
installWebAutoReplyTestHomeHooks,
|
||||||
installWebAutoReplyUnitTestHooks,
|
installWebAutoReplyUnitTestHooks,
|
||||||
resetLoadConfigMock,
|
resetLoadConfigMock,
|
||||||
@@ -29,7 +30,8 @@ describe("web auto-reply", () => {
|
|||||||
sendMedia: ReturnType<typeof vi.fn>;
|
sendMedia: ReturnType<typeof vi.fn>;
|
||||||
reply?: 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 sendComposing = vi.fn(async () => undefined);
|
||||||
const resolver = vi.fn().mockResolvedValue(params.resolverValue);
|
const resolver = vi.fn().mockResolvedValue(params.resolverValue);
|
||||||
|
|
||||||
@@ -384,7 +386,7 @@ describe("web auto-reply", () => {
|
|||||||
fetchMock.mockRestore();
|
fetchMock.mockRestore();
|
||||||
});
|
});
|
||||||
it("sends media with a caption when delivery succeeds", async () => {
|
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({
|
const { reply, dispatch } = await setupSingleInboundMessage({
|
||||||
resolverValue: {
|
resolverValue: {
|
||||||
text: "hi",
|
text: "hi",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { WhatsAppAuthUnstableError, resolveWebCredsPath } from "./auth-store.js"
|
|||||||
import { resolveOAuthDir } from "./auth-store.runtime.js";
|
import { resolveOAuthDir } from "./auth-store.runtime.js";
|
||||||
import {
|
import {
|
||||||
createWebInboundDeliverySpies,
|
createWebInboundDeliverySpies,
|
||||||
|
createAcceptedWhatsAppSendResult,
|
||||||
createMockWebListener,
|
createMockWebListener,
|
||||||
createScriptedWebListenerFactory,
|
createScriptedWebListenerFactory,
|
||||||
createWebListenerFactoryCapture,
|
createWebListenerFactoryCapture,
|
||||||
@@ -713,7 +714,7 @@ describe("web auto-reply connection", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const sendMedia = vi.fn();
|
const sendMedia = vi.fn();
|
||||||
const reply = vi.fn().mockResolvedValue(undefined);
|
const reply = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1"));
|
||||||
const sendComposing = vi.fn();
|
const sendComposing = vi.fn();
|
||||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||||
|
|
||||||
@@ -861,9 +862,9 @@ describe("web auto-reply connection", () => {
|
|||||||
markDispatchIdle,
|
markDispatchIdle,
|
||||||
cleanup: vi.fn(),
|
cleanup: vi.fn(),
|
||||||
};
|
};
|
||||||
const reply = vi.fn().mockResolvedValue(undefined);
|
const reply = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1"));
|
||||||
const sendComposing = vi.fn().mockResolvedValue(undefined);
|
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) => {
|
const replyResolver = vi.fn().mockImplementation(async (ctx, opts) => {
|
||||||
void ctx;
|
void ctx;
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import "./test-helpers.js";
|
import "./test-helpers.js";
|
||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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());
|
const updateLastRouteInBackgroundMock = vi.hoisted(() => vi.fn());
|
||||||
let awaitBackgroundTasks: typeof import("./auto-reply/monitor/last-route.js").awaitBackgroundTasks;
|
let awaitBackgroundTasks: typeof import("./auto-reply/monitor/last-route.js").awaitBackgroundTasks;
|
||||||
@@ -92,8 +96,8 @@ function buildInboundMessage(params: {
|
|||||||
senderName: params.senderName,
|
senderName: params.senderName,
|
||||||
selfE164: params.selfE164,
|
selfE164: params.selfE164,
|
||||||
sendComposing: vi.fn().mockResolvedValue(undefined),
|
sendComposing: vi.fn().mockResolvedValue(undefined),
|
||||||
reply: vi.fn().mockResolvedValue(undefined),
|
reply: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1")),
|
||||||
sendMedia: vi.fn().mockResolvedValue(undefined),
|
sendMedia: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,26 @@ vi.mock("../media.js", () => ({
|
|||||||
let deliverWebReply: typeof import("./deliver-reply.js").deliverWebReply;
|
let deliverWebReply: typeof import("./deliver-reply.js").deliverWebReply;
|
||||||
let whatsappOutbound: typeof import("../outbound-adapter.js").whatsappOutbound;
|
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 {
|
function makeMsg(): WebInboundMsg {
|
||||||
return {
|
return {
|
||||||
from: "+10000000000",
|
from: "+10000000000",
|
||||||
@@ -58,8 +78,8 @@ function makeMsg(): WebInboundMsg {
|
|||||||
id: "msg-1",
|
id: "msg-1",
|
||||||
body: "latest batch body",
|
body: "latest batch body",
|
||||||
senderJid: "222@s.whatsapp.net",
|
senderJid: "222@s.whatsapp.net",
|
||||||
reply: vi.fn(async () => undefined),
|
reply: vi.fn(async () => acceptedSendResult("text", "reply-sent-1")),
|
||||||
sendMedia: vi.fn(async () => undefined),
|
sendMedia: vi.fn(async () => acceptedSendResult("media", "media-sent-1")),
|
||||||
} as unknown as WebInboundMsg;
|
} as unknown as WebInboundMsg;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +119,7 @@ function expectFirstSendMediaPayload(msg: WebInboundMsg) {
|
|||||||
|
|
||||||
function mockSecondReplySuccess(msg: WebInboundMsg) {
|
function mockSecondReplySuccess(msg: WebInboundMsg) {
|
||||||
(msg.reply as unknown as { mockResolvedValueOnce: (v: unknown) => void }).mockResolvedValueOnce(
|
(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 () => {
|
it("sends chunked text replies and logs a summary", async () => {
|
||||||
const msg = makeMsg();
|
const msg = makeMsg();
|
||||||
|
|
||||||
await deliverWebReply({
|
const delivery = await deliverWebReply({
|
||||||
replyResult: { text: "aaaaaa" },
|
replyResult: { text: "aaaaaa" },
|
||||||
msg,
|
msg,
|
||||||
maxMediaBytes: 1024 * 1024,
|
maxMediaBytes: 1024 * 1024,
|
||||||
@@ -175,6 +195,32 @@ describe("deliverWebReply", () => {
|
|||||||
expect(msg.reply).toHaveBeenNthCalledWith(1, "aaa", undefined);
|
expect(msg.reply).toHaveBeenNthCalledWith(1, "aaa", undefined);
|
||||||
expect(msg.reply).toHaveBeenNthCalledWith(2, "aaa", undefined);
|
expect(msg.reply).toHaveBeenNthCalledWith(2, "aaa", undefined);
|
||||||
expect(replyLogger.info).toHaveBeenCalledWith(expect.any(Object), "auto-reply sent (text)");
|
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 () => {
|
it("strips raw XML tool-call blocks before WhatsApp text delivery", async () => {
|
||||||
@@ -421,7 +467,7 @@ describe("deliverWebReply", () => {
|
|||||||
mockFirstSendMediaFailure(msg, "socket reset");
|
mockFirstSendMediaFailure(msg, "socket reset");
|
||||||
(
|
(
|
||||||
msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
|
msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
|
||||||
).mockResolvedValueOnce(undefined);
|
).mockResolvedValueOnce(acceptedSendResult("media", "media-retry-2"));
|
||||||
|
|
||||||
await deliverWebReply({
|
await deliverWebReply({
|
||||||
replyResult: { text: "caption", mediaUrl: "http://example.com/img.jpg" },
|
replyResult: { text: "caption", mediaUrl: "http://example.com/img.jpg" },
|
||||||
@@ -484,7 +530,7 @@ describe("deliverWebReply", () => {
|
|||||||
mockFirstSendMediaFailure(msg, "boom");
|
mockFirstSendMediaFailure(msg, "boom");
|
||||||
(
|
(
|
||||||
msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
|
msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
|
||||||
).mockResolvedValueOnce(undefined);
|
).mockResolvedValueOnce(acceptedSendResult("media", "media-second-1"));
|
||||||
|
|
||||||
await deliverWebReply({
|
await deliverWebReply({
|
||||||
replyResult: {
|
replyResult: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
sendMediaWithLeadingCaption,
|
sendMediaWithLeadingCaption,
|
||||||
} from "openclaw/plugin-sdk/reply-payload";
|
} from "openclaw/plugin-sdk/reply-payload";
|
||||||
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||||
|
import type { WhatsAppSendResult } from "../inbound/send-result.js";
|
||||||
import { loadWebMedia } from "../media.js";
|
import { loadWebMedia } from "../media.js";
|
||||||
import {
|
import {
|
||||||
type DeliverableWhatsAppOutboundPayload,
|
type DeliverableWhatsAppOutboundPayload,
|
||||||
@@ -23,6 +24,12 @@ import { whatsappOutboundLog } from "./loggers.js";
|
|||||||
import type { WebInboundMsg } from "./types.js";
|
import type { WebInboundMsg } from "./types.js";
|
||||||
import { elide } from "./util.js";
|
import { elide } from "./util.js";
|
||||||
|
|
||||||
|
export type WhatsAppReplyDeliveryResult = {
|
||||||
|
results: WhatsAppSendResult[];
|
||||||
|
messageIds: string[];
|
||||||
|
providerAccepted: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export async function deliverWebReply(params: {
|
export async function deliverWebReply(params: {
|
||||||
replyResult: ReplyPayload;
|
replyResult: ReplyPayload;
|
||||||
normalizedReplyResult?: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
|
normalizedReplyResult?: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
|
||||||
@@ -38,12 +45,26 @@ export async function deliverWebReply(params: {
|
|||||||
connectionId?: string;
|
connectionId?: string;
|
||||||
skipLog?: boolean;
|
skipLog?: boolean;
|
||||||
tableMode?: MarkdownTableMode;
|
tableMode?: MarkdownTableMode;
|
||||||
}) {
|
}): Promise<WhatsAppReplyDeliveryResult> {
|
||||||
const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params;
|
const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params;
|
||||||
const replyStarted = Date.now();
|
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)) {
|
if (isReasoningReplyPayload(replyResult)) {
|
||||||
whatsappOutboundLog.debug(`Suppressed reasoning payload to ${msg.from}`);
|
whatsappOutboundLog.debug(`Suppressed reasoning payload to ${msg.from}`);
|
||||||
return;
|
return finishDelivery();
|
||||||
}
|
}
|
||||||
const tableMode = params.tableMode ?? "code";
|
const tableMode = params.tableMode ?? "code";
|
||||||
const chunkMode = params.chunkMode ?? "length";
|
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({
|
return await sendWhatsAppOutboundWithRetry({
|
||||||
send: fn,
|
send: fn,
|
||||||
maxAttempts,
|
maxAttempts,
|
||||||
@@ -93,7 +114,7 @@ export async function deliverWebReply(params: {
|
|||||||
for (const [index, chunk] of textChunks.entries()) {
|
for (const [index, chunk] of textChunks.entries()) {
|
||||||
const chunkStarted = Date.now();
|
const chunkStarted = Date.now();
|
||||||
const quote = getQuote();
|
const quote = getQuote();
|
||||||
await sendWithRetry(() => msg.reply(chunk, quote), "text");
|
rememberSendResult(await sendWithRetry(() => msg.reply(chunk, quote), "text"));
|
||||||
if (!skipLog) {
|
if (!skipLog) {
|
||||||
const durationMs = Date.now() - chunkStarted;
|
const durationMs = Date.now() - chunkStarted;
|
||||||
whatsappOutboundLog.debug(
|
whatsappOutboundLog.debug(
|
||||||
@@ -101,21 +122,24 @@ export async function deliverWebReply(params: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
replyLogger.info(
|
const delivery = finishDelivery();
|
||||||
{
|
const logPayload = {
|
||||||
correlationId: msg.id ?? newConnectionId(),
|
correlationId: msg.id ?? newConnectionId(),
|
||||||
connectionId: connectionId ?? null,
|
connectionId: connectionId ?? null,
|
||||||
to: msg.from,
|
to: msg.from,
|
||||||
from: msg.to,
|
from: msg.to,
|
||||||
text: elide(replyResult.text, 240),
|
text: elide(replyResult.text, 240),
|
||||||
mediaUrl: null,
|
mediaUrl: null,
|
||||||
mediaSizeBytes: null,
|
mediaSizeBytes: null,
|
||||||
mediaKind: null,
|
mediaKind: null,
|
||||||
durationMs: Date.now() - replyStarted,
|
durationMs: Date.now() - replyStarted,
|
||||||
},
|
};
|
||||||
"auto-reply sent (text)",
|
if (delivery.providerAccepted) {
|
||||||
);
|
replyLogger.info(logPayload, "auto-reply sent (text)");
|
||||||
return;
|
} else {
|
||||||
|
replyLogger.warn(logPayload, "auto-reply text was not accepted by WhatsApp provider");
|
||||||
|
}
|
||||||
|
return delivery;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingText = [...textChunks];
|
const remainingText = [...textChunks];
|
||||||
@@ -141,63 +165,73 @@ export async function deliverWebReply(params: {
|
|||||||
}
|
}
|
||||||
if (media.kind === "image") {
|
if (media.kind === "image") {
|
||||||
const quote = getQuote();
|
const quote = getQuote();
|
||||||
await sendWithRetry(
|
rememberSendResult(
|
||||||
() =>
|
await sendWithRetry(
|
||||||
msg.sendMedia(
|
() =>
|
||||||
{
|
msg.sendMedia(
|
||||||
image: media.buffer,
|
{
|
||||||
caption,
|
image: media.buffer,
|
||||||
mimetype: media.mimetype,
|
caption,
|
||||||
},
|
mimetype: media.mimetype,
|
||||||
quote,
|
},
|
||||||
),
|
quote,
|
||||||
"media:image",
|
),
|
||||||
|
"media:image",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else if (media.kind === "audio") {
|
} else if (media.kind === "audio") {
|
||||||
const quote = getQuote();
|
const quote = getQuote();
|
||||||
await sendWithRetry(
|
rememberSendResult(
|
||||||
() =>
|
await sendWithRetry(
|
||||||
msg.sendMedia(
|
() =>
|
||||||
{
|
msg.sendMedia(
|
||||||
audio: media.buffer,
|
{
|
||||||
ptt: true,
|
audio: media.buffer,
|
||||||
mimetype: media.mimetype,
|
ptt: true,
|
||||||
},
|
mimetype: media.mimetype,
|
||||||
quote,
|
},
|
||||||
),
|
quote,
|
||||||
"media:audio",
|
),
|
||||||
|
"media:audio",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (caption) {
|
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") {
|
} else if (media.kind === "video") {
|
||||||
const quote = getQuote();
|
const quote = getQuote();
|
||||||
await sendWithRetry(
|
rememberSendResult(
|
||||||
() =>
|
await sendWithRetry(
|
||||||
msg.sendMedia(
|
() =>
|
||||||
{
|
msg.sendMedia(
|
||||||
video: media.buffer,
|
{
|
||||||
caption,
|
video: media.buffer,
|
||||||
mimetype: media.mimetype,
|
caption,
|
||||||
},
|
mimetype: media.mimetype,
|
||||||
quote,
|
},
|
||||||
),
|
quote,
|
||||||
"media:video",
|
),
|
||||||
|
"media:video",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const quote = getQuote();
|
const quote = getQuote();
|
||||||
await sendWithRetry(
|
rememberSendResult(
|
||||||
() =>
|
await sendWithRetry(
|
||||||
msg.sendMedia(
|
() =>
|
||||||
{
|
msg.sendMedia(
|
||||||
document: media.buffer,
|
{
|
||||||
fileName: media.fileName,
|
document: media.buffer,
|
||||||
caption,
|
fileName: media.fileName,
|
||||||
mimetype: media.mimetype,
|
caption,
|
||||||
},
|
mimetype: media.mimetype,
|
||||||
quote,
|
},
|
||||||
),
|
quote,
|
||||||
"media:document",
|
),
|
||||||
|
"media:document",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
whatsappOutboundLog.info(
|
whatsappOutboundLog.info(
|
||||||
@@ -231,12 +265,15 @@ export async function deliverWebReply(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
whatsappOutboundLog.warn(`Media skipped; sent text-only to ${msg.from}`);
|
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
|
// Remaining text chunks after media
|
||||||
for (const chunk of remainingText) {
|
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 type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
|
||||||
import type { WebInboundMessage } from "../../inbound/types.js";
|
import type { WebInboundMessage } from "../../inbound/types.js";
|
||||||
import { maybeSendAckReaction } from "./ack-reaction.js";
|
import { maybeSendAckReaction } from "./ack-reaction.js";
|
||||||
|
|
||||||
@@ -11,6 +12,16 @@ vi.mock("../../send.js", () => ({
|
|||||||
sendReactionWhatsApp: hoisted.sendReactionWhatsApp,
|
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 {
|
function createMessage(overrides: Partial<WebInboundMessage> = {}): WebInboundMessage {
|
||||||
return {
|
return {
|
||||||
id: "msg-1",
|
id: "msg-1",
|
||||||
@@ -22,8 +33,8 @@ function createMessage(overrides: Partial<WebInboundMessage> = {}): WebInboundMe
|
|||||||
chatType: "direct",
|
chatType: "direct",
|
||||||
chatId: "15551234567@s.whatsapp.net",
|
chatId: "15551234567@s.whatsapp.net",
|
||||||
sendComposing: async () => {},
|
sendComposing: async () => {},
|
||||||
reply: async () => {},
|
reply: async () => acceptedSendResult("text", "r1"),
|
||||||
sendMedia: async () => {},
|
sendMedia: async () => acceptedSendResult("media", "m1"),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
|
||||||
import {
|
import {
|
||||||
resolveVisibleWhatsAppGroupHistory,
|
resolveVisibleWhatsAppGroupHistory,
|
||||||
resolveVisibleWhatsAppReplyContext,
|
resolveVisibleWhatsAppReplyContext,
|
||||||
@@ -6,6 +7,16 @@ import {
|
|||||||
|
|
||||||
type ReplyContextParams = Parameters<typeof resolveVisibleWhatsAppReplyContext>[0];
|
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"] => ({
|
const makeBlockedQuotedReplyMessage = (id: string): ReplyContextParams["msg"] => ({
|
||||||
id,
|
id,
|
||||||
from: "123@g.us",
|
from: "123@g.us",
|
||||||
@@ -24,8 +35,8 @@ const makeBlockedQuotedReplyMessage = (id: string): ReplyContextParams["msg"] =>
|
|||||||
replyToSender: "Mallory (+999)",
|
replyToSender: "Mallory (+999)",
|
||||||
replyToSenderJid: "999@s.whatsapp.net",
|
replyToSenderJid: "999@s.whatsapp.net",
|
||||||
sendComposing: async () => {},
|
sendComposing: async () => {},
|
||||||
reply: async () => {},
|
reply: async () => acceptedSendResult("text", "r1"),
|
||||||
sendMedia: async () => {},
|
sendMedia: async () => acceptedSendResult("media", "m1"),
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("whatsapp inbound context visibility", () => {
|
describe("whatsapp inbound context visibility", () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
|
||||||
|
|
||||||
let capturedDispatchParams: unknown;
|
let capturedDispatchParams: unknown;
|
||||||
|
|
||||||
@@ -75,6 +76,16 @@ import {
|
|||||||
type TestRoute = Parameters<typeof buildWhatsAppInboundContext>[0]["route"];
|
type TestRoute = Parameters<typeof buildWhatsAppInboundContext>[0]["route"];
|
||||||
type TestMsg = Parameters<typeof buildWhatsAppInboundContext>[0]["msg"];
|
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 {
|
function makeRoute(overrides: Partial<TestRoute> = {}): TestRoute {
|
||||||
return {
|
return {
|
||||||
agentId: "main",
|
agentId: "main",
|
||||||
@@ -99,8 +110,8 @@ function makeMsg(overrides: Partial<TestMsg> = {}): TestMsg {
|
|||||||
chatType: "direct",
|
chatType: "direct",
|
||||||
body: "hi",
|
body: "hi",
|
||||||
sendComposing: async () => {},
|
sendComposing: async () => {},
|
||||||
reply: async () => {},
|
reply: async () => acceptedSendResult("text", "r1"),
|
||||||
sendMedia: async () => {},
|
sendMedia: async () => acceptedSendResult("media", "m1"),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -139,13 +150,37 @@ function makeReplyLogger(): BufferedReplyParams["replyLogger"] {
|
|||||||
} as never;
|
} 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> = {}) {
|
async function dispatchBufferedReply(overrides: Partial<BufferedReplyParams> = {}) {
|
||||||
const params: BufferedReplyParams = {
|
const params: BufferedReplyParams = {
|
||||||
cfg: { channels: { whatsapp: { blockStreaming: true } } } as never,
|
cfg: { channels: { whatsapp: { blockStreaming: true } } } as never,
|
||||||
connectionId: "conn",
|
connectionId: "conn",
|
||||||
context: { Body: "hi" },
|
context: { Body: "hi" },
|
||||||
conversationId: "+1000",
|
conversationId: "+1000",
|
||||||
deliverReply: async () => {},
|
deliverReply: async () => acceptedDeliveryResult(),
|
||||||
groupHistories: new Map(),
|
groupHistories: new Map(),
|
||||||
groupHistoryKey: "+1000",
|
groupHistoryKey: "+1000",
|
||||||
maxMediaBytes: 1,
|
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 () => {
|
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();
|
const rememberSentText = vi.fn();
|
||||||
|
|
||||||
await dispatchBufferedReply({
|
await dispatchBufferedReply({
|
||||||
@@ -446,7 +481,7 @@ describe("whatsapp inbound dispatch", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes WhatsApp payload text before delivery and echo bookkeeping", async () => {
|
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();
|
const rememberSentText = vi.fn();
|
||||||
|
|
||||||
await dispatchBufferedReply({
|
await dispatchBufferedReply({
|
||||||
@@ -479,7 +514,7 @@ describe("whatsapp inbound dispatch", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("suppresses reasoning and compaction payloads before WhatsApp delivery", async () => {
|
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();
|
const rememberSentText = vi.fn();
|
||||||
|
|
||||||
await dispatchBufferedReply({
|
await dispatchBufferedReply({
|
||||||
@@ -500,7 +535,7 @@ describe("whatsapp inbound dispatch", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("suppresses payloads that normalize to no visible WhatsApp content", async () => {
|
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();
|
const rememberSentText = vi.fn();
|
||||||
|
|
||||||
await dispatchBufferedReply({
|
await dispatchBufferedReply({
|
||||||
@@ -523,7 +558,7 @@ describe("whatsapp inbound dispatch", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("suppresses error payload text", async () => {
|
it("suppresses error payload text", async () => {
|
||||||
const deliverReply = vi.fn(async () => undefined);
|
const deliverReply = vi.fn(async () => acceptedDeliveryResult());
|
||||||
const rememberSentText = vi.fn();
|
const rememberSentText = vi.fn();
|
||||||
|
|
||||||
await dispatchBufferedReply({ deliverReply, rememberSentText });
|
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 () => {
|
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();
|
const rememberSentText = vi.fn();
|
||||||
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce(
|
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce(
|
||||||
async (params: {
|
async (params: {
|
||||||
@@ -607,8 +642,52 @@ describe("whatsapp inbound dispatch", () => {
|
|||||||
expect(rememberSentText).toHaveBeenCalledTimes(1);
|
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 () => {
|
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();
|
const rememberSentText = vi.fn();
|
||||||
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce(
|
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce(
|
||||||
async (params: {
|
async (params: {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
normalizeWhatsAppOutboundPayload,
|
normalizeWhatsAppOutboundPayload,
|
||||||
normalizeWhatsAppPayloadTextPreservingIndentation,
|
normalizeWhatsAppPayloadTextPreservingIndentation,
|
||||||
} from "../../outbound-media-contract.js";
|
} from "../../outbound-media-contract.js";
|
||||||
|
import type { WhatsAppReplyDeliveryResult } from "../deliver-reply.js";
|
||||||
import type { WebInboundMsg } from "../types.js";
|
import type { WebInboundMsg } from "../types.js";
|
||||||
import { formatGroupMembers } from "./group-members.js";
|
import { formatGroupMembers } from "./group-members.js";
|
||||||
import type { GroupHistoryEntry } from "./inbound-context.js";
|
import type { GroupHistoryEntry } from "./inbound-context.js";
|
||||||
@@ -283,7 +284,7 @@ export async function dispatchWhatsAppBufferedReply(params: {
|
|||||||
connectionId?: string;
|
connectionId?: string;
|
||||||
skipLog?: boolean;
|
skipLog?: boolean;
|
||||||
tableMode?: ReturnType<typeof resolveMarkdownTableMode>;
|
tableMode?: ReturnType<typeof resolveMarkdownTableMode>;
|
||||||
}) => Promise<void>;
|
}) => Promise<WhatsAppReplyDeliveryResult>;
|
||||||
groupHistories: Map<string, GroupHistoryEntry[]>;
|
groupHistories: Map<string, GroupHistoryEntry[]>;
|
||||||
groupHistoryKey: string;
|
groupHistoryKey: string;
|
||||||
maxMediaBytes: number;
|
maxMediaBytes: number;
|
||||||
@@ -344,7 +345,7 @@ export async function dispatchWhatsAppBufferedReply(params: {
|
|||||||
if (!reply.hasMedia && !reply.text.trim()) {
|
if (!reply.hasMedia && !reply.text.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await params.deliverReply({
|
const delivery = await params.deliverReply({
|
||||||
replyResult: normalizedDeliveryPayload,
|
replyResult: normalizedDeliveryPayload,
|
||||||
normalizedReplyResult: normalizedDeliveryPayload,
|
normalizedReplyResult: normalizedDeliveryPayload,
|
||||||
msg: params.msg,
|
msg: params.msg,
|
||||||
@@ -357,6 +358,21 @@ export async function dispatchWhatsAppBufferedReply(params: {
|
|||||||
skipLog: false,
|
skipLog: false,
|
||||||
tableMode,
|
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;
|
didSendReply = true;
|
||||||
const shouldLog = normalizedDeliveryPayload.text ? true : undefined;
|
const shouldLog = normalizedDeliveryPayload.text ? true : undefined;
|
||||||
params.rememberSentText(normalizedDeliveryPayload.text, {
|
params.rememberSentText(normalizedDeliveryPayload.text, {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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.
|
// Hoisted mocks used across tests so vi.mock factories can reference them.
|
||||||
const { resolvePolicyMock, buildContextMock, runMessageReceivedMock, trackBackgroundTaskMock } =
|
const { resolvePolicyMock, buildContextMock, runMessageReceivedMock, trackBackgroundTaskMock } =
|
||||||
@@ -9,6 +10,16 @@ const { resolvePolicyMock, buildContextMock, runMessageReceivedMock, trackBackgr
|
|||||||
trackBackgroundTaskMock: vi.fn(),
|
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) => {
|
vi.mock("../../inbound-policy.js", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../../inbound-policy.js")>();
|
const actual = await importOriginal<typeof import("../../inbound-policy.js")>();
|
||||||
return {
|
return {
|
||||||
@@ -169,8 +180,8 @@ const baseMsg = {
|
|||||||
chatType: "group" as const,
|
chatType: "group" as const,
|
||||||
body: "hi",
|
body: "hi",
|
||||||
sendComposing: async () => {},
|
sendComposing: async () => {},
|
||||||
reply: async () => {},
|
reply: async () => acceptedSendResult("text", "r1"),
|
||||||
sendMedia: async () => {},
|
sendMedia: async () => acceptedSendResult("media", "m1"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseRoute = {
|
const baseRoute = {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import type { WhatsAppSendResult } from "../inbound/send-result.js";
|
||||||
import { buildMentionConfig } from "./mentions.js";
|
import { buildMentionConfig } from "./mentions.js";
|
||||||
import { applyGroupGating, type GroupHistoryEntry } from "./monitor/group-gating.js";
|
import { applyGroupGating, type GroupHistoryEntry } from "./monitor/group-gating.js";
|
||||||
import { buildInboundLine, formatReplyContext } from "./monitor/message-line.js";
|
import { buildInboundLine, formatReplyContext } from "./monitor/message-line.js";
|
||||||
@@ -11,6 +12,16 @@ import type { WebInboundMsg } from "./types.js";
|
|||||||
let sessionDir: string | undefined;
|
let sessionDir: string | undefined;
|
||||||
let sessionStorePath: string;
|
let sessionStorePath: string;
|
||||||
|
|
||||||
|
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
messageId: id,
|
||||||
|
messageIds: [id],
|
||||||
|
keys: [{ id }],
|
||||||
|
providerAccepted: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-group-gating-"));
|
sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-group-gating-"));
|
||||||
sessionStorePath = path.join(sessionDir, "sessions.json");
|
sessionStorePath = path.join(sessionDir, "sessions.json");
|
||||||
@@ -82,8 +93,8 @@ function createGroupMessage(overrides: Partial<WebInboundMsg> = {}): WebInboundM
|
|||||||
senderName: "Alice",
|
senderName: "Alice",
|
||||||
selfE164: "+999",
|
selfE164: "+999",
|
||||||
sendComposing: async () => {},
|
sendComposing: async () => {},
|
||||||
reply: async (_text, _options) => {},
|
reply: async (_text, _options) => acceptedSendResult("text", "r1"),
|
||||||
sendMedia: async (_payload, _options) => {},
|
sendMedia: async (_payload, _options) => acceptedSendResult("media", "m1"),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
|||||||
import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
|
import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
|
||||||
import { withTempDir } from "openclaw/plugin-sdk/test-env";
|
import { withTempDir } from "openclaw/plugin-sdk/test-env";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { WhatsAppSendResult } from "../inbound/send-result.js";
|
||||||
import {
|
import {
|
||||||
debugMention,
|
debugMention,
|
||||||
isBotMentionedFromTargets,
|
isBotMentionedFromTargets,
|
||||||
@@ -13,6 +14,16 @@ import { getSessionSnapshot } from "./session-snapshot.js";
|
|||||||
import type { WebInboundMsg } from "./types.js";
|
import type { WebInboundMsg } from "./types.js";
|
||||||
import { elide, isLikelyWhatsAppCryptoError } from "./util.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 =>
|
const makeMsg = (overrides: Partial<WebInboundMsg>): WebInboundMsg =>
|
||||||
({
|
({
|
||||||
id: "m1",
|
id: "m1",
|
||||||
@@ -24,8 +35,8 @@ const makeMsg = (overrides: Partial<WebInboundMsg>): WebInboundMsg =>
|
|||||||
chatType: "group",
|
chatType: "group",
|
||||||
chatId: "120363401234567890@g.us",
|
chatId: "120363401234567890@g.us",
|
||||||
sendComposing: async () => {},
|
sendComposing: async () => {},
|
||||||
reply: async () => {},
|
reply: async () => acceptedSendResult("text", "r1"),
|
||||||
sendMedia: async () => {},
|
sendMedia: async () => acceptedSendResult("media", "m1"),
|
||||||
...overrides,
|
...overrides,
|
||||||
}) as WebInboundMsg;
|
}) as WebInboundMsg;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { EventEmitter } from "node:events";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
|
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
|
||||||
import { WhatsAppConnectionController } from "./connection-controller.js";
|
import { WhatsAppConnectionController } from "./connection-controller.js";
|
||||||
|
import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
|
||||||
import { createWaSocket, waitForWaConnection } from "./session.js";
|
import { createWaSocket, waitForWaConnection } from "./session.js";
|
||||||
|
|
||||||
vi.mock("./session.js", async () => {
|
vi.mock("./session.js", async () => {
|
||||||
@@ -16,11 +17,21 @@ vi.mock("./session.js", async () => {
|
|||||||
const createWaSocketMock = vi.mocked(createWaSocket);
|
const createWaSocketMock = vi.mocked(createWaSocket);
|
||||||
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
|
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") {
|
function createListenerStub(messageId = "ok") {
|
||||||
return {
|
return {
|
||||||
sendMessage: vi.fn(async () => ({ messageId })),
|
sendMessage: vi.fn(async () => acceptedSendResult("text", messageId)),
|
||||||
sendPoll: vi.fn(async () => ({ messageId })),
|
sendPoll: vi.fn(async () => acceptedSendResult("poll", messageId)),
|
||||||
sendReaction: vi.fn(async () => {}),
|
sendReaction: vi.fn(async () => acceptedSendResult("reaction", messageId)),
|
||||||
sendComposingTo: vi.fn(async () => {}),
|
sendComposingTo: vi.fn(async () => {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { attachEmitterListener, closeInboundMonitorSocket } from "./lifecycle.js
|
|||||||
import { downloadInboundMedia } from "./media.js";
|
import { downloadInboundMedia } from "./media.js";
|
||||||
import { DisconnectReason, isJidGroup, saveMediaBuffer } from "./runtime-api.js";
|
import { DisconnectReason, isJidGroup, saveMediaBuffer } from "./runtime-api.js";
|
||||||
import { createWebSendApi } from "./send-api.js";
|
import { createWebSendApi } from "./send-api.js";
|
||||||
|
import { normalizeWhatsAppSendResult } from "./send-result.js";
|
||||||
import type { WebInboundMessage, WebListenerCloseReason } from "./types.js";
|
import type { WebInboundMessage, WebListenerCloseReason } from "./types.js";
|
||||||
|
|
||||||
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
|
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
|
||||||
@@ -622,13 +623,15 @@ export async function attachWebInboxToSocket(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const reply = async (text: string, options?: MiscMessageGenerationOptions) => {
|
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 (
|
const sendMedia = async (
|
||||||
payload: AnyMessageContent,
|
payload: AnyMessageContent,
|
||||||
options?: MiscMessageGenerationOptions,
|
options?: MiscMessageGenerationOptions,
|
||||||
) => {
|
) => {
|
||||||
await sendTrackedMessage(chatJid, payload, options);
|
const result = await sendTrackedMessage(chatJid, payload, options);
|
||||||
|
return normalizeWhatsAppSendResult(result, "media");
|
||||||
};
|
};
|
||||||
const timestamp = inbound.messageTimestampMs;
|
const timestamp = inbound.messageTimestampMs;
|
||||||
const mentionedJids = extractMentionedJids(msg.message as proto.IMessage | undefined);
|
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createWebSendApi } from "./send-api.js";
|
import { createWebSendApi } from "./send-api.js";
|
||||||
|
|
||||||
@@ -14,7 +19,13 @@ vi.mock("openclaw/plugin-sdk/channel-activity-runtime", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("createWebSendApi", () => {
|
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 () => {});
|
const sendPresenceUpdate = vi.fn(async () => {});
|
||||||
let api: ReturnType<typeof createWebSendApi>;
|
let api: ReturnType<typeof createWebSendApi>;
|
||||||
|
|
||||||
@@ -60,8 +71,14 @@ describe("createWebSendApi", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sends plain text messages", async () => {
|
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(sendMessage).toHaveBeenCalledWith("1555@s.whatsapp.net", { text: "hello" });
|
||||||
|
expect(res).toMatchObject({
|
||||||
|
kind: "text",
|
||||||
|
messageId: "msg-1",
|
||||||
|
messageIds: ["msg-1"],
|
||||||
|
providerAccepted: true,
|
||||||
|
});
|
||||||
expect(recordChannelActivity).toHaveBeenCalledWith({
|
expect(recordChannelActivity).toHaveBeenCalledWith({
|
||||||
channel: "whatsapp",
|
channel: "whatsapp",
|
||||||
accountId: "main",
|
accountId: "main",
|
||||||
@@ -102,7 +119,10 @@ describe("createWebSendApi", () => {
|
|||||||
|
|
||||||
it("sends visible text separately from push-to-talk voice notes", async () => {
|
it("sends visible text separately from push-to-talk voice notes", async () => {
|
||||||
const payload = Buffer.from("aud");
|
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(
|
expect(sendMessage).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
"1555@s.whatsapp.net",
|
"1555@s.whatsapp.net",
|
||||||
@@ -115,6 +135,12 @@ describe("createWebSendApi", () => {
|
|||||||
expect(sendMessage).toHaveBeenNthCalledWith(2, "1555@s.whatsapp.net", {
|
expect(sendMessage).toHaveBeenNthCalledWith(2, "1555@s.whatsapp.net", {
|
||||||
text: "voice text",
|
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 () => {
|
it("supports video media and gifPlayback option", async () => {
|
||||||
@@ -158,7 +184,7 @@ describe("createWebSendApi", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sends reactions with participant JID normalization", async () => {
|
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(
|
expect(sendMessage).toHaveBeenCalledWith(
|
||||||
"1555@s.whatsapp.net",
|
"1555@s.whatsapp.net",
|
||||||
expect.objectContaining({
|
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 () => {
|
it("keeps direct-chat reactions without a participant key", async () => {
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import type {
|
import type {
|
||||||
AnyMessageContent,
|
AnyMessageContent,
|
||||||
MiscMessageGenerationOptions,
|
MiscMessageGenerationOptions,
|
||||||
|
WAMessage,
|
||||||
WAPresence,
|
WAPresence,
|
||||||
} from "@whiskeysockets/baileys";
|
} from "@whiskeysockets/baileys";
|
||||||
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
|
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
|
||||||
import { buildQuotedMessageOptions } from "../quoted-message.js";
|
import { buildQuotedMessageOptions } from "../quoted-message.js";
|
||||||
import { toWhatsappJid } from "../text-runtime.js";
|
import { toWhatsappJid } from "../text-runtime.js";
|
||||||
|
import {
|
||||||
|
combineWhatsAppSendResults,
|
||||||
|
normalizeWhatsAppSendResult,
|
||||||
|
type WhatsAppSendResult,
|
||||||
|
} from "./send-result.js";
|
||||||
import type { ActiveWebSendOptions } from "./types.js";
|
import type { ActiveWebSendOptions } from "./types.js";
|
||||||
|
|
||||||
function recordWhatsAppOutbound(accountId: string) {
|
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: {
|
export function createWebSendApi(params: {
|
||||||
sock: {
|
sock: {
|
||||||
sendMessage: (
|
sendMessage: (
|
||||||
jid: string,
|
jid: string,
|
||||||
content: AnyMessageContent,
|
content: AnyMessageContent,
|
||||||
options?: MiscMessageGenerationOptions,
|
options?: MiscMessageGenerationOptions,
|
||||||
) => Promise<unknown>;
|
) => Promise<WAMessage | undefined>;
|
||||||
sendPresenceUpdate: (presence: WAPresence, jid?: string) => Promise<unknown>;
|
sendPresenceUpdate: (presence: WAPresence, jid?: string) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
defaultAccountId: string;
|
defaultAccountId: string;
|
||||||
@@ -40,7 +40,7 @@ export function createWebSendApi(params: {
|
|||||||
mediaBuffer?: Buffer,
|
mediaBuffer?: Buffer,
|
||||||
mediaType?: string,
|
mediaType?: string,
|
||||||
sendOptions?: ActiveWebSendOptions,
|
sendOptions?: ActiveWebSendOptions,
|
||||||
): Promise<{ messageId: string }> => {
|
): Promise<WhatsAppSendResult> => {
|
||||||
const jid = toWhatsappJid(to);
|
const jid = toWhatsappJid(to);
|
||||||
let payload: AnyMessageContent;
|
let payload: AnyMessageContent;
|
||||||
if (mediaBuffer) {
|
if (mediaBuffer) {
|
||||||
@@ -85,23 +85,22 @@ export function createWebSendApi(params: {
|
|||||||
const result = quotedOpts
|
const result = quotedOpts
|
||||||
? await params.sock.sendMessage(jid, payload, quotedOpts)
|
? await params.sock.sendMessage(jid, payload, quotedOpts)
|
||||||
: await params.sock.sendMessage(jid, payload);
|
: await params.sock.sendMessage(jid, payload);
|
||||||
|
const results = [normalizeWhatsAppSendResult(result, mediaBuffer ? "media" : "text")];
|
||||||
if (mediaBuffer && mediaType?.startsWith("audio/") && text.trim()) {
|
if (mediaBuffer && mediaType?.startsWith("audio/") && text.trim()) {
|
||||||
const textPayload: AnyMessageContent = { text };
|
const textPayload: AnyMessageContent = { text };
|
||||||
if (quotedOpts) {
|
const textResult = quotedOpts
|
||||||
await params.sock.sendMessage(jid, textPayload, quotedOpts);
|
? await params.sock.sendMessage(jid, textPayload, quotedOpts)
|
||||||
} else {
|
: await params.sock.sendMessage(jid, textPayload);
|
||||||
await params.sock.sendMessage(jid, textPayload);
|
results.push(normalizeWhatsAppSendResult(textResult, "text"));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const accountId = sendOptions?.accountId ?? params.defaultAccountId;
|
const accountId = sendOptions?.accountId ?? params.defaultAccountId;
|
||||||
recordWhatsAppOutbound(accountId);
|
recordWhatsAppOutbound(accountId);
|
||||||
const messageId = resolveOutboundMessageId(result);
|
return combineWhatsAppSendResults(mediaBuffer ? "media" : "text", results);
|
||||||
return { messageId };
|
|
||||||
},
|
},
|
||||||
sendPoll: async (
|
sendPoll: async (
|
||||||
to: string,
|
to: string,
|
||||||
poll: { question: string; options: string[]; maxSelections?: number },
|
poll: { question: string; options: string[]; maxSelections?: number },
|
||||||
): Promise<{ messageId: string }> => {
|
): Promise<WhatsAppSendResult> => {
|
||||||
const jid = toWhatsappJid(to);
|
const jid = toWhatsappJid(to);
|
||||||
const result = await params.sock.sendMessage(jid, {
|
const result = await params.sock.sendMessage(jid, {
|
||||||
poll: {
|
poll: {
|
||||||
@@ -111,8 +110,7 @@ export function createWebSendApi(params: {
|
|||||||
},
|
},
|
||||||
} as AnyMessageContent);
|
} as AnyMessageContent);
|
||||||
recordWhatsAppOutbound(params.defaultAccountId);
|
recordWhatsAppOutbound(params.defaultAccountId);
|
||||||
const messageId = resolveOutboundMessageId(result);
|
return normalizeWhatsAppSendResult(result, "poll");
|
||||||
return { messageId };
|
|
||||||
},
|
},
|
||||||
sendReaction: async (
|
sendReaction: async (
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
@@ -120,9 +118,9 @@ export function createWebSendApi(params: {
|
|||||||
emoji: string,
|
emoji: string,
|
||||||
fromMe: boolean,
|
fromMe: boolean,
|
||||||
participant?: string,
|
participant?: string,
|
||||||
): Promise<void> => {
|
): Promise<WhatsAppSendResult> => {
|
||||||
const jid = toWhatsappJid(chatJid);
|
const jid = toWhatsappJid(chatJid);
|
||||||
await params.sock.sendMessage(jid, {
|
const result = await params.sock.sendMessage(jid, {
|
||||||
react: {
|
react: {
|
||||||
text: emoji,
|
text: emoji,
|
||||||
key: {
|
key: {
|
||||||
@@ -133,6 +131,7 @@ export function createWebSendApi(params: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as AnyMessageContent);
|
} as AnyMessageContent);
|
||||||
|
return normalizeWhatsAppSendResult(result, "reaction");
|
||||||
},
|
},
|
||||||
sendComposingTo: async (to: string): Promise<void> => {
|
sendComposingTo: async (to: string): Promise<void> => {
|
||||||
const jid = toWhatsappJid(to);
|
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 { NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import type { PollInput } from "openclaw/plugin-sdk/poll-runtime";
|
import type { PollInput } from "openclaw/plugin-sdk/poll-runtime";
|
||||||
import type { WhatsAppIdentity, WhatsAppReplyContext, WhatsAppSelfIdentity } from "../identity.js";
|
import type { WhatsAppIdentity, WhatsAppReplyContext, WhatsAppSelfIdentity } from "../identity.js";
|
||||||
|
import type { WhatsAppSendResult } from "./send-result.js";
|
||||||
|
|
||||||
export type WebListenerCloseReason = {
|
export type WebListenerCloseReason = {
|
||||||
status?: number;
|
status?: number;
|
||||||
@@ -29,15 +30,15 @@ export type ActiveWebListener = {
|
|||||||
mediaBuffer?: Buffer,
|
mediaBuffer?: Buffer,
|
||||||
mediaType?: string,
|
mediaType?: string,
|
||||||
options?: ActiveWebSendOptions,
|
options?: ActiveWebSendOptions,
|
||||||
) => Promise<{ messageId: string }>;
|
) => Promise<WhatsAppSendResult>;
|
||||||
sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>;
|
sendPoll: (to: string, poll: PollInput) => Promise<WhatsAppSendResult>;
|
||||||
sendReaction: (
|
sendReaction: (
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
emoji: string,
|
emoji: string,
|
||||||
fromMe: boolean,
|
fromMe: boolean,
|
||||||
participant?: string,
|
participant?: string,
|
||||||
) => Promise<void>;
|
) => Promise<WhatsAppSendResult>;
|
||||||
sendComposingTo: (to: string) => Promise<void>;
|
sendComposingTo: (to: string) => Promise<void>;
|
||||||
close?: () => Promise<void>;
|
close?: () => Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -85,8 +86,11 @@ export type WebInboundMessage = {
|
|||||||
fromMe?: boolean;
|
fromMe?: boolean;
|
||||||
location?: NormalizedLocation;
|
location?: NormalizedLocation;
|
||||||
sendComposing: () => Promise<void>;
|
sendComposing: () => Promise<void>;
|
||||||
reply: (text: string, options?: MiscMessageGenerationOptions) => Promise<void>;
|
reply: (text: string, options?: MiscMessageGenerationOptions) => Promise<WhatsAppSendResult>;
|
||||||
sendMedia: (payload: AnyMessageContent, options?: MiscMessageGenerationOptions) => Promise<void>;
|
sendMedia: (
|
||||||
|
payload: AnyMessageContent,
|
||||||
|
options?: MiscMessageGenerationOptions,
|
||||||
|
) => Promise<WhatsAppSendResult>;
|
||||||
mediaPath?: string;
|
mediaPath?: string;
|
||||||
mediaType?: string;
|
mediaType?: string;
|
||||||
mediaFileName?: string;
|
mediaFileName?: string;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
|||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||||
import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
|
import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
|
||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
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";
|
import type { ActiveWebListener } from "./inbound/types.js";
|
||||||
|
|
||||||
const hoisted = vi.hoisted(() => ({
|
const hoisted = vi.hoisted(() => ({
|
||||||
@@ -23,6 +24,16 @@ const WHATSAPP_TEST_CFG: OpenClawConfig = {
|
|||||||
channels: { whatsapp: {} },
|
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 () => {
|
vi.mock("./connection-controller-registry.js", async () => {
|
||||||
const actual = await vi.importActual<typeof import("./connection-controller-registry.js")>(
|
const actual = await vi.importActual<typeof import("./connection-controller-registry.js")>(
|
||||||
"./connection-controller-registry.js",
|
"./connection-controller-registry.js",
|
||||||
@@ -70,9 +81,9 @@ vi.mock("./text-runtime.js", async () => {
|
|||||||
|
|
||||||
describe("web outbound", () => {
|
describe("web outbound", () => {
|
||||||
const sendComposingTo = vi.fn(async () => {});
|
const sendComposingTo = vi.fn(async () => {});
|
||||||
const sendMessage = vi.fn(async () => ({ messageId: "msg123" }));
|
const sendMessage = vi.fn(async () => acceptedSendResult("text", "msg123"));
|
||||||
const sendPoll = vi.fn(async () => ({ messageId: "poll123" }));
|
const sendPoll = vi.fn(async () => acceptedSendResult("poll", "poll123"));
|
||||||
const sendReaction = vi.fn(async () => {});
|
const sendReaction = vi.fn(async () => acceptedSendResult("reaction", "reaction123"));
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
({ sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp } = await import("./send.js"));
|
({ sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp } = await import("./send.js"));
|
||||||
|
|||||||
Reference in New Issue
Block a user