@openclaw/zalo 2026.3.13 → 2026.5.1-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/api.ts +9 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +5 -0
- package/index.test.ts +15 -0
- package/index.ts +16 -13
- package/openclaw.plugin.json +514 -1
- package/package.json +31 -5
- package/runtime-api.test.ts +17 -0
- package/runtime-api.ts +75 -0
- package/secret-contract-api.ts +5 -0
- package/setup-api.ts +34 -0
- package/setup-entry.ts +13 -0
- package/src/accounts.test.ts +70 -0
- package/src/accounts.ts +19 -19
- package/src/actions.runtime.ts +5 -0
- package/src/actions.test.ts +32 -0
- package/src/actions.ts +20 -14
- package/src/api.test.ts +93 -2
- package/src/api.ts +29 -2
- package/src/approval-auth.test.ts +17 -0
- package/src/approval-auth.ts +25 -0
- package/src/channel.directory.test.ts +19 -6
- package/src/channel.runtime.ts +93 -0
- package/src/channel.startup.test.ts +26 -19
- package/src/channel.ts +228 -336
- package/src/config-schema.ts +3 -3
- package/src/group-access.ts +4 -3
- package/src/monitor.group-policy.test.ts +0 -12
- package/src/monitor.image.polling.test.ts +110 -0
- package/src/monitor.lifecycle.test.ts +41 -22
- package/src/monitor.pairing.lifecycle.test.ts +141 -0
- package/src/monitor.polling.media-reply.test.ts +425 -0
- package/src/monitor.reply-once.lifecycle.test.ts +171 -0
- package/src/monitor.ts +460 -206
- package/src/monitor.types.ts +4 -0
- package/src/monitor.webhook.test.ts +392 -62
- package/src/monitor.webhook.ts +73 -36
- package/src/outbound-media.test.ts +182 -0
- package/src/outbound-media.ts +241 -0
- package/src/outbound-payload.contract.test.ts +45 -0
- package/src/probe.ts +1 -1
- package/src/proxy.ts +1 -1
- package/src/runtime-api.ts +75 -0
- package/src/runtime-support.ts +91 -0
- package/src/runtime.ts +6 -3
- package/src/secret-contract.ts +109 -0
- package/src/secret-input.ts +1 -9
- package/src/send.test.ts +120 -0
- package/src/send.ts +15 -13
- package/src/session-route.ts +32 -0
- package/src/setup-allow-from.ts +94 -0
- package/src/setup-core.ts +149 -0
- package/src/{onboarding.status.test.ts → setup-status.test.ts} +13 -4
- package/src/setup-surface.test.ts +175 -0
- package/src/{onboarding.ts → setup-surface.ts} +59 -177
- package/src/status-issues.test.ts +2 -14
- package/src/status-issues.ts +8 -2
- package/src/test-support/lifecycle-test-support.ts +413 -0
- package/src/test-support/monitor-mocks-test-support.ts +209 -0
- package/src/token.test.ts +15 -0
- package/src/token.ts +8 -17
- package/src/types.ts +2 -2
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -101
- package/src/channel.sendpayload.test.ts +0 -44
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import type { ServerResponse } from "node:http";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
createEmptyPluginRegistry,
|
|
6
|
+
createRuntimeEnv,
|
|
7
|
+
setActivePluginRegistry,
|
|
8
|
+
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
9
|
+
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
|
10
|
+
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import type { PluginRuntime } from "../runtime-api.js";
|
|
12
|
+
import {
|
|
13
|
+
createLifecycleMonitorSetup,
|
|
14
|
+
createTextUpdate,
|
|
15
|
+
settleAsyncWork,
|
|
16
|
+
} from "./test-support/lifecycle-test-support.js";
|
|
17
|
+
import {
|
|
18
|
+
getUpdatesMock,
|
|
19
|
+
loadCachedLifecycleMonitorModule,
|
|
20
|
+
resetLifecycleTestState,
|
|
21
|
+
sendPhotoMock,
|
|
22
|
+
setLifecycleRuntimeCore,
|
|
23
|
+
} from "./test-support/monitor-mocks-test-support.js";
|
|
24
|
+
|
|
25
|
+
const prepareHostedZaloMediaUrlMock = vi.fn();
|
|
26
|
+
|
|
27
|
+
vi.mock("./outbound-media.js", async () => {
|
|
28
|
+
const actual = await vi.importActual<typeof import("./outbound-media.js")>("./outbound-media.js");
|
|
29
|
+
return {
|
|
30
|
+
...actual,
|
|
31
|
+
prepareHostedZaloMediaUrl: (...args: unknown[]) => prepareHostedZaloMediaUrlMock(...args),
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
import { clearHostedZaloMediaForTest } from "./outbound-media.js";
|
|
36
|
+
|
|
37
|
+
const ZALO_OUTBOUND_MEDIA_DIR = join(
|
|
38
|
+
resolvePreferredOpenClawTmpDir(),
|
|
39
|
+
"openclaw-zalo-outbound-media",
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
async function writeHostedZaloMediaFixture(params: {
|
|
43
|
+
id: string;
|
|
44
|
+
routePath: string;
|
|
45
|
+
token: string;
|
|
46
|
+
buffer: Buffer;
|
|
47
|
+
contentType?: string;
|
|
48
|
+
}): Promise<void> {
|
|
49
|
+
await mkdir(ZALO_OUTBOUND_MEDIA_DIR, { recursive: true, mode: 0o700 });
|
|
50
|
+
await chmod(ZALO_OUTBOUND_MEDIA_DIR, 0o700).catch(() => undefined);
|
|
51
|
+
await Promise.all([
|
|
52
|
+
writeFile(
|
|
53
|
+
join(ZALO_OUTBOUND_MEDIA_DIR, `${params.id}.json`),
|
|
54
|
+
JSON.stringify({
|
|
55
|
+
routePath: params.routePath,
|
|
56
|
+
token: params.token,
|
|
57
|
+
contentType: params.contentType,
|
|
58
|
+
expiresAt: Date.now() + 60_000,
|
|
59
|
+
}),
|
|
60
|
+
{ encoding: "utf8", mode: 0o600 },
|
|
61
|
+
),
|
|
62
|
+
writeFile(join(ZALO_OUTBOUND_MEDIA_DIR, `${params.id}.bin`), params.buffer, { mode: 0o600 }),
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function createHostedMediaResponse() {
|
|
67
|
+
const headers = new Map<string, string>();
|
|
68
|
+
const res = {
|
|
69
|
+
statusCode: 200,
|
|
70
|
+
headersSent: false,
|
|
71
|
+
setHeader(name: string, value: string) {
|
|
72
|
+
headers.set(name, value);
|
|
73
|
+
},
|
|
74
|
+
end: vi.fn((body?: unknown) => {
|
|
75
|
+
res.headersSent = true;
|
|
76
|
+
return body;
|
|
77
|
+
}),
|
|
78
|
+
};
|
|
79
|
+
return { headers, res: res as unknown as ServerResponse & { end: ReturnType<typeof vi.fn> } };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("Zalo polling media replies", () => {
|
|
83
|
+
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
|
|
84
|
+
const recordInboundSessionMock = vi.fn(async () => undefined);
|
|
85
|
+
const resolveAgentRouteMock = vi.fn(() => ({
|
|
86
|
+
agentId: "main",
|
|
87
|
+
channel: "zalo",
|
|
88
|
+
accountId: "acct-zalo-polling-media",
|
|
89
|
+
sessionKey: "agent:main:zalo:direct:dm-chat-1",
|
|
90
|
+
mainSessionKey: "agent:main:main",
|
|
91
|
+
matchedBy: "default",
|
|
92
|
+
}));
|
|
93
|
+
const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn();
|
|
94
|
+
|
|
95
|
+
beforeEach(async () => {
|
|
96
|
+
await resetLifecycleTestState();
|
|
97
|
+
clearHostedZaloMediaForTest();
|
|
98
|
+
prepareHostedZaloMediaUrlMock.mockReset();
|
|
99
|
+
prepareHostedZaloMediaUrlMock.mockResolvedValue(
|
|
100
|
+
"https://example.com/hooks/zalo/media/abc123abc123abc123abc123?token=secret",
|
|
101
|
+
);
|
|
102
|
+
dispatchReplyWithBufferedBlockDispatcherMock.mockReset();
|
|
103
|
+
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementation(
|
|
104
|
+
async (params: {
|
|
105
|
+
dispatcherOptions: {
|
|
106
|
+
deliver: (payload: { text: string; mediaUrl: string }) => Promise<void>;
|
|
107
|
+
};
|
|
108
|
+
}) => {
|
|
109
|
+
await params.dispatcherOptions.deliver({
|
|
110
|
+
text: "caption text",
|
|
111
|
+
mediaUrl: "https://example.com/reply-image.png",
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
setLifecycleRuntimeCore({
|
|
116
|
+
routing: {
|
|
117
|
+
resolveAgentRoute:
|
|
118
|
+
resolveAgentRouteMock as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
|
119
|
+
},
|
|
120
|
+
reply: {
|
|
121
|
+
finalizeInboundContext:
|
|
122
|
+
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
|
123
|
+
dispatchReplyWithBufferedBlockDispatcher:
|
|
124
|
+
dispatchReplyWithBufferedBlockDispatcherMock as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
|
125
|
+
},
|
|
126
|
+
session: {
|
|
127
|
+
recordInboundSession:
|
|
128
|
+
recordInboundSessionMock as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
afterAll(async () => {
|
|
134
|
+
clearHostedZaloMediaForTest();
|
|
135
|
+
await resetLifecycleTestState();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("hosts and sends media replies while polling when a webhook URL is configured", async () => {
|
|
139
|
+
const registry = createEmptyPluginRegistry();
|
|
140
|
+
setActivePluginRegistry(registry);
|
|
141
|
+
getUpdatesMock
|
|
142
|
+
.mockResolvedValueOnce({
|
|
143
|
+
ok: true,
|
|
144
|
+
result: createTextUpdate({
|
|
145
|
+
messageId: "polling-media-1",
|
|
146
|
+
userId: "user-1",
|
|
147
|
+
userName: "User One",
|
|
148
|
+
chatId: "dm-chat-1",
|
|
149
|
+
text: "send media",
|
|
150
|
+
}),
|
|
151
|
+
})
|
|
152
|
+
.mockImplementation(() => new Promise(() => {}));
|
|
153
|
+
|
|
154
|
+
const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule(
|
|
155
|
+
"zalo-polling-media-reply",
|
|
156
|
+
);
|
|
157
|
+
const abort = new AbortController();
|
|
158
|
+
const runtime = createRuntimeEnv();
|
|
159
|
+
const { account, config } = createLifecycleMonitorSetup({
|
|
160
|
+
accountId: "acct-zalo-polling-media",
|
|
161
|
+
dmPolicy: "open",
|
|
162
|
+
webhookUrl: "https://example.com/hooks/zalo",
|
|
163
|
+
});
|
|
164
|
+
const run = monitorZaloProvider({
|
|
165
|
+
token: "zalo-token",
|
|
166
|
+
account,
|
|
167
|
+
config,
|
|
168
|
+
runtime,
|
|
169
|
+
abortSignal: abort.signal,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
await settleAsyncWork();
|
|
174
|
+
expect(sendPhotoMock).toHaveBeenCalledTimes(1);
|
|
175
|
+
|
|
176
|
+
expect(registry.httpRoutes).toHaveLength(1);
|
|
177
|
+
expect(prepareHostedZaloMediaUrlMock).toHaveBeenCalledWith({
|
|
178
|
+
mediaUrl: "https://example.com/reply-image.png",
|
|
179
|
+
webhookUrl: "https://example.com/hooks/zalo",
|
|
180
|
+
webhookPath: "/hooks/zalo",
|
|
181
|
+
maxBytes: 5 * 1024 * 1024,
|
|
182
|
+
proxyUrl: undefined,
|
|
183
|
+
});
|
|
184
|
+
expect(sendPhotoMock).toHaveBeenCalledWith(
|
|
185
|
+
"zalo-token",
|
|
186
|
+
{
|
|
187
|
+
chat_id: "dm-chat-1",
|
|
188
|
+
photo: "https://example.com/hooks/zalo/media/abc123abc123abc123abc123?token=secret",
|
|
189
|
+
caption: "caption text",
|
|
190
|
+
},
|
|
191
|
+
undefined,
|
|
192
|
+
);
|
|
193
|
+
} finally {
|
|
194
|
+
abort.abort();
|
|
195
|
+
await run;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
expect(registry.httpRoutes).toHaveLength(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("sends media replies directly when webhook hosting is not configured", async () => {
|
|
202
|
+
const registry = createEmptyPluginRegistry();
|
|
203
|
+
setActivePluginRegistry(registry);
|
|
204
|
+
getUpdatesMock
|
|
205
|
+
.mockResolvedValueOnce({
|
|
206
|
+
ok: true,
|
|
207
|
+
result: createTextUpdate({
|
|
208
|
+
messageId: "polling-media-2",
|
|
209
|
+
userId: "user-2",
|
|
210
|
+
userName: "User Two",
|
|
211
|
+
chatId: "dm-chat-2",
|
|
212
|
+
text: "send media directly",
|
|
213
|
+
}),
|
|
214
|
+
})
|
|
215
|
+
.mockImplementation(() => new Promise(() => {}));
|
|
216
|
+
|
|
217
|
+
const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule(
|
|
218
|
+
"zalo-polling-media-reply",
|
|
219
|
+
);
|
|
220
|
+
const abort = new AbortController();
|
|
221
|
+
const runtime = createRuntimeEnv();
|
|
222
|
+
const { account, config } = createLifecycleMonitorSetup({
|
|
223
|
+
accountId: "acct-zalo-polling-direct-media",
|
|
224
|
+
dmPolicy: "open",
|
|
225
|
+
webhookUrl: "",
|
|
226
|
+
});
|
|
227
|
+
const run = monitorZaloProvider({
|
|
228
|
+
token: "zalo-token",
|
|
229
|
+
account,
|
|
230
|
+
config,
|
|
231
|
+
runtime,
|
|
232
|
+
abortSignal: abort.signal,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
await settleAsyncWork();
|
|
237
|
+
expect(sendPhotoMock).toHaveBeenCalledTimes(1);
|
|
238
|
+
|
|
239
|
+
expect(prepareHostedZaloMediaUrlMock).not.toHaveBeenCalled();
|
|
240
|
+
expect(sendPhotoMock).toHaveBeenCalledWith(
|
|
241
|
+
"zalo-token",
|
|
242
|
+
{
|
|
243
|
+
chat_id: "dm-chat-2",
|
|
244
|
+
photo: "https://example.com/reply-image.png",
|
|
245
|
+
caption: "caption text",
|
|
246
|
+
},
|
|
247
|
+
undefined,
|
|
248
|
+
);
|
|
249
|
+
} finally {
|
|
250
|
+
abort.abort();
|
|
251
|
+
await run;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("shares one hosted media route across accounts on the same path", async () => {
|
|
256
|
+
const registry = createEmptyPluginRegistry();
|
|
257
|
+
setActivePluginRegistry(registry);
|
|
258
|
+
getUpdatesMock.mockImplementation(() => new Promise(() => {}));
|
|
259
|
+
|
|
260
|
+
const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule(
|
|
261
|
+
"zalo-polling-media-reply",
|
|
262
|
+
);
|
|
263
|
+
const firstAbort = new AbortController();
|
|
264
|
+
const firstRuntime = createRuntimeEnv();
|
|
265
|
+
const firstSetup = createLifecycleMonitorSetup({
|
|
266
|
+
accountId: "acct-zalo-polling-media-one",
|
|
267
|
+
dmPolicy: "open",
|
|
268
|
+
webhookUrl: "https://example.com/hooks/zalo",
|
|
269
|
+
});
|
|
270
|
+
const firstRun = monitorZaloProvider({
|
|
271
|
+
token: "zalo-token-one",
|
|
272
|
+
account: firstSetup.account,
|
|
273
|
+
config: firstSetup.config,
|
|
274
|
+
runtime: firstRuntime,
|
|
275
|
+
abortSignal: firstAbort.signal,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const secondAbort = new AbortController();
|
|
279
|
+
let secondRun: Promise<void> | undefined;
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
await settleAsyncWork();
|
|
283
|
+
const firstHostedMediaRoutes = registry.httpRoutes.filter(
|
|
284
|
+
(route) => route.source === "zalo-hosted-media",
|
|
285
|
+
);
|
|
286
|
+
expect(firstHostedMediaRoutes).toHaveLength(1);
|
|
287
|
+
const hostedMediaRoute = firstHostedMediaRoutes[0];
|
|
288
|
+
expect(hostedMediaRoute).toEqual(
|
|
289
|
+
expect.objectContaining({
|
|
290
|
+
path: "/hooks/zalo/media",
|
|
291
|
+
pluginId: "zalo",
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const secondRuntime = createRuntimeEnv();
|
|
296
|
+
const secondSetup = createLifecycleMonitorSetup({
|
|
297
|
+
accountId: "acct-zalo-polling-media-two",
|
|
298
|
+
dmPolicy: "open",
|
|
299
|
+
webhookUrl: "https://example.com/hooks/zalo",
|
|
300
|
+
});
|
|
301
|
+
secondRun = monitorZaloProvider({
|
|
302
|
+
token: "zalo-token-two",
|
|
303
|
+
account: secondSetup.account,
|
|
304
|
+
config: secondSetup.config,
|
|
305
|
+
runtime: secondRuntime,
|
|
306
|
+
abortSignal: secondAbort.signal,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await settleAsyncWork();
|
|
310
|
+
const hostedMediaRoutes = registry.httpRoutes.filter(
|
|
311
|
+
(route) => route.source === "zalo-hosted-media",
|
|
312
|
+
);
|
|
313
|
+
expect(hostedMediaRoutes).toHaveLength(1);
|
|
314
|
+
expect(hostedMediaRoutes[0]).toBe(hostedMediaRoute);
|
|
315
|
+
|
|
316
|
+
await writeHostedZaloMediaFixture({
|
|
317
|
+
id: "abc123abc123abc123abc123",
|
|
318
|
+
routePath: "/hooks/zalo/media/",
|
|
319
|
+
token: "route-token-one",
|
|
320
|
+
buffer: Buffer.from("first-image-bytes"),
|
|
321
|
+
contentType: "image/png",
|
|
322
|
+
});
|
|
323
|
+
const firstFetch = createHostedMediaResponse();
|
|
324
|
+
await hostedMediaRoute.handler(
|
|
325
|
+
{
|
|
326
|
+
method: "GET",
|
|
327
|
+
url: "/hooks/zalo/media/abc123abc123abc123abc123?token=route-token-one",
|
|
328
|
+
} as never,
|
|
329
|
+
firstFetch.res as never,
|
|
330
|
+
);
|
|
331
|
+
expect(firstFetch.res.statusCode).toBe(200);
|
|
332
|
+
expect(firstFetch.headers.get("Content-Type")).toBe("image/png");
|
|
333
|
+
expect(firstFetch.headers.get("Cache-Control")).toBe("no-store");
|
|
334
|
+
expect(firstFetch.res.end).toHaveBeenCalledWith(Buffer.from("first-image-bytes"));
|
|
335
|
+
|
|
336
|
+
firstAbort.abort();
|
|
337
|
+
await firstRun;
|
|
338
|
+
expect(registry.httpRoutes.filter((route) => route.source === "zalo-hosted-media")).toEqual([
|
|
339
|
+
hostedMediaRoute,
|
|
340
|
+
]);
|
|
341
|
+
|
|
342
|
+
await writeHostedZaloMediaFixture({
|
|
343
|
+
id: "def456def456def456def456",
|
|
344
|
+
routePath: "/hooks/zalo/media/",
|
|
345
|
+
token: "route-token-two",
|
|
346
|
+
buffer: Buffer.from("second-image-bytes"),
|
|
347
|
+
contentType: "image/jpeg",
|
|
348
|
+
});
|
|
349
|
+
const secondFetch = createHostedMediaResponse();
|
|
350
|
+
await hostedMediaRoute.handler(
|
|
351
|
+
{
|
|
352
|
+
method: "GET",
|
|
353
|
+
url: "/hooks/zalo/media/def456def456def456def456?token=route-token-two",
|
|
354
|
+
} as never,
|
|
355
|
+
secondFetch.res as never,
|
|
356
|
+
);
|
|
357
|
+
expect(secondFetch.res.statusCode).toBe(200);
|
|
358
|
+
expect(secondFetch.headers.get("Content-Type")).toBe("image/jpeg");
|
|
359
|
+
expect(secondFetch.res.end).toHaveBeenCalledWith(Buffer.from("second-image-bytes"));
|
|
360
|
+
} finally {
|
|
361
|
+
firstAbort.abort();
|
|
362
|
+
secondAbort.abort();
|
|
363
|
+
await firstRun;
|
|
364
|
+
await secondRun;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
expect(
|
|
368
|
+
registry.httpRoutes.filter((route) => route.source === "zalo-hosted-media"),
|
|
369
|
+
).toHaveLength(0);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("re-registers the hosted media route after the active registry swaps", async () => {
|
|
373
|
+
const firstRegistry = createEmptyPluginRegistry();
|
|
374
|
+
setActivePluginRegistry(firstRegistry);
|
|
375
|
+
getUpdatesMock.mockImplementation(() => new Promise(() => {}));
|
|
376
|
+
|
|
377
|
+
const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule(
|
|
378
|
+
"zalo-polling-media-reply",
|
|
379
|
+
);
|
|
380
|
+
const firstAbort = new AbortController();
|
|
381
|
+
const firstRuntime = createRuntimeEnv();
|
|
382
|
+
const { account, config } = createLifecycleMonitorSetup({
|
|
383
|
+
accountId: "acct-zalo-polling-media",
|
|
384
|
+
dmPolicy: "open",
|
|
385
|
+
webhookUrl: "https://example.com/hooks/zalo",
|
|
386
|
+
});
|
|
387
|
+
const firstRun = monitorZaloProvider({
|
|
388
|
+
token: "zalo-token",
|
|
389
|
+
account,
|
|
390
|
+
config,
|
|
391
|
+
runtime: firstRuntime,
|
|
392
|
+
abortSignal: firstAbort.signal,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const secondRegistry = createEmptyPluginRegistry();
|
|
396
|
+
const secondAbort = new AbortController();
|
|
397
|
+
const secondRuntime = createRuntimeEnv();
|
|
398
|
+
let secondRun: Promise<void> | undefined;
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
await settleAsyncWork();
|
|
402
|
+
expect(firstRegistry.httpRoutes).toHaveLength(1);
|
|
403
|
+
|
|
404
|
+
setActivePluginRegistry(secondRegistry);
|
|
405
|
+
secondRun = monitorZaloProvider({
|
|
406
|
+
token: "zalo-token",
|
|
407
|
+
account,
|
|
408
|
+
config,
|
|
409
|
+
runtime: secondRuntime,
|
|
410
|
+
abortSignal: secondAbort.signal,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
await settleAsyncWork();
|
|
414
|
+
expect(secondRegistry.httpRoutes).toHaveLength(1);
|
|
415
|
+
} finally {
|
|
416
|
+
firstAbort.abort();
|
|
417
|
+
secondAbort.abort();
|
|
418
|
+
await firstRun;
|
|
419
|
+
await secondRun;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
expect(firstRegistry.httpRoutes).toHaveLength(0);
|
|
423
|
+
expect(secondRegistry.httpRoutes).toHaveLength(0);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { withServer } from "openclaw/plugin-sdk/test-env";
|
|
2
|
+
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { PluginRuntime } from "../runtime-api.js";
|
|
4
|
+
import {
|
|
5
|
+
createLifecycleMonitorSetup,
|
|
6
|
+
createTextUpdate,
|
|
7
|
+
postWebhookReplay,
|
|
8
|
+
settleAsyncWork,
|
|
9
|
+
} from "./test-support/lifecycle-test-support.js";
|
|
10
|
+
import {
|
|
11
|
+
resetLifecycleTestState,
|
|
12
|
+
sendMessageMock,
|
|
13
|
+
setLifecycleRuntimeCore,
|
|
14
|
+
startWebhookLifecycleMonitor,
|
|
15
|
+
} from "./test-support/monitor-mocks-test-support.js";
|
|
16
|
+
|
|
17
|
+
describe("Zalo reply-once lifecycle", () => {
|
|
18
|
+
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
|
|
19
|
+
const recordInboundSessionMock = vi.fn(async () => undefined);
|
|
20
|
+
const resolveAgentRouteMock = vi.fn(() => ({
|
|
21
|
+
agentId: "main",
|
|
22
|
+
channel: "zalo",
|
|
23
|
+
accountId: "acct-zalo-lifecycle",
|
|
24
|
+
sessionKey: "agent:main:zalo:direct:dm-chat-1",
|
|
25
|
+
mainSessionKey: "agent:main:main",
|
|
26
|
+
matchedBy: "default",
|
|
27
|
+
}));
|
|
28
|
+
const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn();
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
await resetLifecycleTestState();
|
|
32
|
+
setLifecycleRuntimeCore({
|
|
33
|
+
routing: {
|
|
34
|
+
resolveAgentRoute:
|
|
35
|
+
resolveAgentRouteMock as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
|
36
|
+
},
|
|
37
|
+
reply: {
|
|
38
|
+
finalizeInboundContext:
|
|
39
|
+
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
|
40
|
+
dispatchReplyWithBufferedBlockDispatcher:
|
|
41
|
+
dispatchReplyWithBufferedBlockDispatcherMock as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
|
42
|
+
},
|
|
43
|
+
session: {
|
|
44
|
+
recordInboundSession:
|
|
45
|
+
recordInboundSessionMock as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(async () => {
|
|
51
|
+
await resetLifecycleTestState();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function createReplyOnceMonitorSetup() {
|
|
55
|
+
return createLifecycleMonitorSetup({
|
|
56
|
+
accountId: "acct-zalo-lifecycle",
|
|
57
|
+
dmPolicy: "open",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
it("routes one accepted webhook event to one visible reply across duplicate replay", async () => {
|
|
62
|
+
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementation(
|
|
63
|
+
async ({ dispatcherOptions }) => {
|
|
64
|
+
await dispatcherOptions.deliver({ text: "zalo reply once" });
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const monitor = await startWebhookLifecycleMonitor({
|
|
69
|
+
...createReplyOnceMonitorSetup(),
|
|
70
|
+
cacheKey: "zalo-reply-once-lifecycle",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
await withServer(
|
|
75
|
+
(req, res) => monitor.route.handler(req, res),
|
|
76
|
+
async (baseUrl) => {
|
|
77
|
+
const { first, replay } = await postWebhookReplay({
|
|
78
|
+
baseUrl,
|
|
79
|
+
path: "/hooks/zalo",
|
|
80
|
+
secret: "supersecret",
|
|
81
|
+
payload: createTextUpdate({
|
|
82
|
+
messageId: `zalo-replay-${Date.now()}`,
|
|
83
|
+
userId: "user-1",
|
|
84
|
+
userName: "User One",
|
|
85
|
+
chatId: "dm-chat-1",
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(first.status).toBe(200);
|
|
90
|
+
expect(replay.status).toBe(200);
|
|
91
|
+
await settleAsyncWork();
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(recordInboundSessionMock).toHaveBeenCalledTimes(1);
|
|
96
|
+
expect(recordInboundSessionMock).toHaveBeenCalledWith(
|
|
97
|
+
expect.objectContaining({
|
|
98
|
+
sessionKey: "agent:main:zalo:direct:dm-chat-1",
|
|
99
|
+
ctx: expect.objectContaining({
|
|
100
|
+
AccountId: "acct-zalo-lifecycle",
|
|
101
|
+
SessionKey: "agent:main:zalo:direct:dm-chat-1",
|
|
102
|
+
MessageSid: expect.stringContaining("zalo-replay-"),
|
|
103
|
+
From: "zalo:user-1",
|
|
104
|
+
To: "zalo:dm-chat-1",
|
|
105
|
+
}),
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
expect(sendMessageMock).toHaveBeenCalledTimes(1);
|
|
109
|
+
expect(sendMessageMock).toHaveBeenCalledWith(
|
|
110
|
+
"zalo-token",
|
|
111
|
+
expect.objectContaining({
|
|
112
|
+
chat_id: "dm-chat-1",
|
|
113
|
+
text: "zalo reply once",
|
|
114
|
+
}),
|
|
115
|
+
undefined,
|
|
116
|
+
);
|
|
117
|
+
} finally {
|
|
118
|
+
await monitor.stop();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("does not emit a second visible reply when replay arrives after a post-send failure", async () => {
|
|
123
|
+
let dispatchAttempts = 0;
|
|
124
|
+
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementation(
|
|
125
|
+
async ({ dispatcherOptions }) => {
|
|
126
|
+
dispatchAttempts += 1;
|
|
127
|
+
await dispatcherOptions.deliver({ text: "zalo reply after failure" });
|
|
128
|
+
if (dispatchAttempts === 1) {
|
|
129
|
+
throw new Error("post-send failure");
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const monitor = await startWebhookLifecycleMonitor({
|
|
135
|
+
...createReplyOnceMonitorSetup(),
|
|
136
|
+
cacheKey: "zalo-reply-once-lifecycle",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await withServer(
|
|
141
|
+
(req, res) => monitor.route.handler(req, res),
|
|
142
|
+
async (baseUrl) => {
|
|
143
|
+
const { first, replay } = await postWebhookReplay({
|
|
144
|
+
baseUrl,
|
|
145
|
+
path: "/hooks/zalo",
|
|
146
|
+
secret: "supersecret",
|
|
147
|
+
payload: createTextUpdate({
|
|
148
|
+
messageId: `zalo-retry-${Date.now()}`,
|
|
149
|
+
userId: "user-1",
|
|
150
|
+
userName: "User One",
|
|
151
|
+
chatId: "dm-chat-1",
|
|
152
|
+
}),
|
|
153
|
+
settleBeforeReplay: true,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(first.status).toBe(200);
|
|
157
|
+
expect(replay.status).toBe(200);
|
|
158
|
+
await settleAsyncWork();
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
expect(dispatchReplyWithBufferedBlockDispatcherMock).toHaveBeenCalledTimes(1);
|
|
163
|
+
expect(sendMessageMock).toHaveBeenCalledTimes(1);
|
|
164
|
+
expect(monitor.runtime.error).toHaveBeenCalledWith(
|
|
165
|
+
expect.stringContaining("Zalo webhook failed: Error: post-send failure"),
|
|
166
|
+
);
|
|
167
|
+
} finally {
|
|
168
|
+
await monitor.stop();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|