Skip to content

Commit c80db1d

Browse files
committed
fix(whatsapp): keep pre-connect recovery replayable
1 parent 90e4658 commit c80db1d

3 files changed

Lines changed: 143 additions & 22 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Whatsapp tests cover the durable outbound handoff across startup recovery.
2+
import {
3+
createEmptyPluginRegistry,
4+
createOutboundTestPlugin,
5+
createTestRegistry,
6+
deliverOutboundPayloads,
7+
releasePinnedPluginChannelRegistry,
8+
setActivePluginRegistry,
9+
} from "openclaw/plugin-sdk/channel-test-helpers";
10+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
11+
import { drainPendingDeliveries } from "openclaw/plugin-sdk/delivery-queue-runtime";
12+
import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime";
13+
import { withStateDirEnv } from "openclaw/plugin-sdk/test-env";
14+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15+
import { whatsappChannelOutbound } from "./channel-outbound.js";
16+
import {
17+
getRegisteredWhatsAppConnectionController,
18+
registerWhatsAppConnectionController,
19+
unregisterWhatsAppConnectionController,
20+
} from "./connection-controller-registry.js";
21+
import { createAcceptedWhatsAppSendResult } from "./inbound/send-result.test-helper.js";
22+
import type { ActiveWebListener } from "./inbound/types.js";
23+
24+
const cfg = { channels: { whatsapp: {} } } as OpenClawConfig;
25+
const accountId = "default";
26+
27+
function clearDefaultController(): void {
28+
const controller = getRegisteredWhatsAppConnectionController(accountId);
29+
if (controller) {
30+
unregisterWhatsAppConnectionController(accountId, controller);
31+
}
32+
}
33+
34+
async function drainDefaultWhatsAppDeliveries(stateDir: string) {
35+
const log = {
36+
info: vi.fn(),
37+
warn: vi.fn(),
38+
error: vi.fn(),
39+
};
40+
await drainPendingDeliveries({
41+
drainKey: `whatsapp:${accountId}`,
42+
logLabel: "WhatsApp reconnect drain",
43+
cfg,
44+
log,
45+
stateDir,
46+
selectEntry: (entry) => ({
47+
match:
48+
entry.channel === "whatsapp" && ((entry.accountId ?? "").trim() || accountId) === accountId,
49+
bypassBackoff:
50+
typeof entry.lastError === "string" &&
51+
entry.lastError.includes("No active WhatsApp Web listener"),
52+
}),
53+
});
54+
return log;
55+
}
56+
57+
describe("WhatsApp delivery recovery", () => {
58+
beforeEach(() => {
59+
clearDefaultController();
60+
setActivePluginRegistry(
61+
createTestRegistry([
62+
{
63+
pluginId: "whatsapp",
64+
source: "test",
65+
plugin: createOutboundTestPlugin({
66+
id: "whatsapp",
67+
outbound: whatsappChannelOutbound,
68+
}),
69+
},
70+
]),
71+
);
72+
});
73+
74+
afterEach(() => {
75+
clearDefaultController();
76+
releasePinnedPluginChannelRegistry();
77+
setActivePluginRegistry(createEmptyPluginRegistry());
78+
});
79+
80+
it("keeps pre-connect recovery replayable, then sends exactly once after connect", async () => {
81+
await withStateDirEnv("openclaw-whatsapp-delivery-recovery-", async ({ stateDir }) => {
82+
const initialError = await deliverOutboundPayloads({
83+
cfg,
84+
channel: "whatsapp",
85+
to: "+1555",
86+
payloads: [{ text: "queued before listener startup" }],
87+
queuePolicy: "required",
88+
}).catch((err: unknown) => err);
89+
expect(initialError).toMatchObject({
90+
cause: expect.any(PlatformMessageNotDispatchedError),
91+
});
92+
93+
const preConnectLog = await drainDefaultWhatsAppDeliveries(stateDir);
94+
expect(preConnectLog.warn).toHaveBeenCalledWith(
95+
expect.stringContaining("No active WhatsApp Web listener"),
96+
);
97+
98+
const sendMessage = vi.fn(async () =>
99+
createAcceptedWhatsAppSendResult("text", "recovered-message"),
100+
);
101+
const listener: ActiveWebListener = {
102+
sendComposingTo: vi.fn(async () => {}),
103+
sendMessage,
104+
sendPoll: vi.fn(async () => createAcceptedWhatsAppSendResult("poll", "poll")),
105+
sendReaction: vi.fn(async () => createAcceptedWhatsAppSendResult("reaction", "reaction")),
106+
};
107+
const controller = {
108+
getActiveListener: () => listener,
109+
getCurrentSock: () => null,
110+
getSelfIdentity: () => null,
111+
};
112+
registerWhatsAppConnectionController(accountId, controller);
113+
114+
await drainDefaultWhatsAppDeliveries(stateDir);
115+
await drainDefaultWhatsAppDeliveries(stateDir);
116+
117+
expect(sendMessage).toHaveBeenCalledTimes(1);
118+
expect(sendMessage).toHaveBeenCalledWith(
119+
"+1555",
120+
"queued before listener startup",
121+
undefined,
122+
undefined,
123+
);
124+
});
125+
});
126+
});

extensions/whatsapp/src/send.test.ts

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import fsSync from "node:fs";
44
import os from "node:os";
55
import path from "node:path";
66
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
7+
import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime";
78
import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
89
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS } from "openclaw/plugin-sdk/media-runtime";
910
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
@@ -326,27 +327,19 @@ describe("web outbound", () => {
326327

327328
it("throws a helpful error when no active listener exists", async () => {
328329
hoisted.controllerListeners.clear();
329-
await expect(
330-
sendMessageWhatsApp("+1555", "hi", {
331-
verbose: false,
332-
cfg: WHATSAPP_TEST_CFG,
333-
accountId: "work",
334-
}),
335-
).rejects.toThrow(/No active WhatsApp Web listener/);
336-
await expect(
337-
sendMessageWhatsApp("+1555", "hi", {
338-
verbose: false,
339-
cfg: WHATSAPP_TEST_CFG,
340-
accountId: "work",
341-
}),
342-
).rejects.toThrow(/channels login/);
343-
await expect(
344-
sendMessageWhatsApp("+1555", "hi", {
345-
verbose: false,
346-
cfg: WHATSAPP_TEST_CFG,
347-
accountId: "work",
348-
}),
349-
).rejects.toThrow(/account: work/);
330+
const error = await sendMessageWhatsApp("+1555", "hi", {
331+
verbose: false,
332+
cfg: WHATSAPP_TEST_CFG,
333+
accountId: "work",
334+
}).catch((err: unknown) => err);
335+
336+
expect(error).toBeInstanceOf(PlatformMessageNotDispatchedError);
337+
expect(error).toMatchObject({
338+
code: "OPENCLAW_PLATFORM_MESSAGE_NOT_DISPATCHED",
339+
message: expect.stringMatching(
340+
/No active WhatsApp Web listener.*channels login.*account work/,
341+
),
342+
});
350343
});
351344

352345
it("maps audio to PTT with opus mime when ogg", async () => {

extensions/whatsapp/src/send.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime";
33
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
44
import { generateSecureUuid } from "openclaw/plugin-sdk/core";
5+
import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime";
56
import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
67
import {
78
convertMarkdownTables,
@@ -97,9 +98,10 @@ function requireOutboundActiveWebListener(params: { cfg: OpenClawConfig; account
9798
const listener =
9899
getRegisteredWhatsAppConnectionController(resolvedAccountId)?.getActiveListener() ?? null;
99100
if (!listener) {
100-
throw new Error(
101+
const cause = new Error(
101102
`No active WhatsApp Web listener (account: ${resolvedAccountId}). Start the gateway, then link WhatsApp with: ${formatCliCommand(`openclaw channels login --channel whatsapp --account ${resolvedAccountId}`)}.`,
102103
);
104+
throw new PlatformMessageNotDispatchedError(cause.message, { cause });
103105
}
104106
return { accountId: resolvedAccountId, listener };
105107
}

0 commit comments

Comments
 (0)