@openclaw/zalo 2026.3.7 → 2026.3.8-beta.1
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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/api.test.ts +63 -0
- package/src/api.ts +36 -6
- package/src/channel.startup.test.ts +100 -0
- package/src/channel.ts +11 -3
- package/src/config-schema.ts +7 -8
- package/src/monitor.lifecycle.test.ts +213 -0
- package/src/monitor.ts +164 -54
- package/src/runtime.ts +4 -12
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
package/src/api.test.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { deleteWebhook, getWebhookInfo, sendChatAction, type ZaloFetch } from "./api.js";
|
|
3
|
+
|
|
4
|
+
describe("Zalo API request methods", () => {
|
|
5
|
+
it("uses POST for getWebhookInfo", async () => {
|
|
6
|
+
const fetcher = vi.fn<ZaloFetch>(
|
|
7
|
+
async () => new Response(JSON.stringify({ ok: true, result: {} })),
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
await getWebhookInfo("test-token", fetcher);
|
|
11
|
+
|
|
12
|
+
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
13
|
+
const [, init] = fetcher.mock.calls[0] ?? [];
|
|
14
|
+
expect(init?.method).toBe("POST");
|
|
15
|
+
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("keeps POST for deleteWebhook", async () => {
|
|
19
|
+
const fetcher = vi.fn<ZaloFetch>(
|
|
20
|
+
async () => new Response(JSON.stringify({ ok: true, result: {} })),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
await deleteWebhook("test-token", fetcher);
|
|
24
|
+
|
|
25
|
+
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
26
|
+
const [, init] = fetcher.mock.calls[0] ?? [];
|
|
27
|
+
expect(init?.method).toBe("POST");
|
|
28
|
+
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("aborts sendChatAction when the typing timeout elapses", async () => {
|
|
32
|
+
vi.useFakeTimers();
|
|
33
|
+
try {
|
|
34
|
+
const fetcher = vi.fn<ZaloFetch>(
|
|
35
|
+
(_, init) =>
|
|
36
|
+
new Promise<Response>((_, reject) => {
|
|
37
|
+
init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), {
|
|
38
|
+
once: true,
|
|
39
|
+
});
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const promise = sendChatAction(
|
|
44
|
+
"test-token",
|
|
45
|
+
{
|
|
46
|
+
chat_id: "chat-123",
|
|
47
|
+
action: "typing",
|
|
48
|
+
},
|
|
49
|
+
fetcher,
|
|
50
|
+
25,
|
|
51
|
+
);
|
|
52
|
+
const rejected = expect(promise).rejects.toThrow("aborted");
|
|
53
|
+
|
|
54
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
55
|
+
|
|
56
|
+
await rejected;
|
|
57
|
+
const [, init] = fetcher.mock.calls[0] ?? [];
|
|
58
|
+
expect(init?.signal?.aborted).toBe(true);
|
|
59
|
+
} finally {
|
|
60
|
+
vi.useRealTimers();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
package/src/api.ts
CHANGED
|
@@ -58,11 +58,22 @@ export type ZaloSendPhotoParams = {
|
|
|
58
58
|
caption?: string;
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
export type ZaloSendChatActionParams = {
|
|
62
|
+
chat_id: string;
|
|
63
|
+
action: "typing" | "upload_photo";
|
|
64
|
+
};
|
|
65
|
+
|
|
61
66
|
export type ZaloSetWebhookParams = {
|
|
62
67
|
url: string;
|
|
63
68
|
secret_token: string;
|
|
64
69
|
};
|
|
65
70
|
|
|
71
|
+
export type ZaloWebhookInfo = {
|
|
72
|
+
url?: string;
|
|
73
|
+
updated_at?: number;
|
|
74
|
+
has_custom_certificate?: boolean;
|
|
75
|
+
};
|
|
76
|
+
|
|
66
77
|
export type ZaloGetUpdatesParams = {
|
|
67
78
|
/** Timeout in seconds (passed as string to API) */
|
|
68
79
|
timeout?: number;
|
|
@@ -161,6 +172,21 @@ export async function sendPhoto(
|
|
|
161
172
|
return callZaloApi<ZaloMessage>("sendPhoto", token, params, { fetch: fetcher });
|
|
162
173
|
}
|
|
163
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Send a temporary chat action such as typing.
|
|
177
|
+
*/
|
|
178
|
+
export async function sendChatAction(
|
|
179
|
+
token: string,
|
|
180
|
+
params: ZaloSendChatActionParams,
|
|
181
|
+
fetcher?: ZaloFetch,
|
|
182
|
+
timeoutMs?: number,
|
|
183
|
+
): Promise<ZaloApiResponse<boolean>> {
|
|
184
|
+
return callZaloApi<boolean>("sendChatAction", token, params, {
|
|
185
|
+
timeoutMs,
|
|
186
|
+
fetch: fetcher,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
164
190
|
/**
|
|
165
191
|
* Get updates using long polling (dev/testing only)
|
|
166
192
|
* Note: Zalo returns a single update per call, not an array like Telegram
|
|
@@ -183,8 +209,8 @@ export async function setWebhook(
|
|
|
183
209
|
token: string,
|
|
184
210
|
params: ZaloSetWebhookParams,
|
|
185
211
|
fetcher?: ZaloFetch,
|
|
186
|
-
): Promise<ZaloApiResponse<
|
|
187
|
-
return callZaloApi<
|
|
212
|
+
): Promise<ZaloApiResponse<ZaloWebhookInfo>> {
|
|
213
|
+
return callZaloApi<ZaloWebhookInfo>("setWebhook", token, params, { fetch: fetcher });
|
|
188
214
|
}
|
|
189
215
|
|
|
190
216
|
/**
|
|
@@ -193,8 +219,12 @@ export async function setWebhook(
|
|
|
193
219
|
export async function deleteWebhook(
|
|
194
220
|
token: string,
|
|
195
221
|
fetcher?: ZaloFetch,
|
|
196
|
-
|
|
197
|
-
|
|
222
|
+
timeoutMs?: number,
|
|
223
|
+
): Promise<ZaloApiResponse<ZaloWebhookInfo>> {
|
|
224
|
+
return callZaloApi<ZaloWebhookInfo>("deleteWebhook", token, undefined, {
|
|
225
|
+
timeoutMs,
|
|
226
|
+
fetch: fetcher,
|
|
227
|
+
});
|
|
198
228
|
}
|
|
199
229
|
|
|
200
230
|
/**
|
|
@@ -203,6 +233,6 @@ export async function deleteWebhook(
|
|
|
203
233
|
export async function getWebhookInfo(
|
|
204
234
|
token: string,
|
|
205
235
|
fetcher?: ZaloFetch,
|
|
206
|
-
): Promise<ZaloApiResponse<
|
|
207
|
-
return callZaloApi("getWebhookInfo", token, undefined, { fetch: fetcher });
|
|
236
|
+
): Promise<ZaloApiResponse<ZaloWebhookInfo>> {
|
|
237
|
+
return callZaloApi<ZaloWebhookInfo>("getWebhookInfo", token, undefined, { fetch: fetcher });
|
|
208
238
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/zalo";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createStartAccountContext } from "../../test-utils/start-account-context.js";
|
|
4
|
+
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
5
|
+
|
|
6
|
+
const hoisted = vi.hoisted(() => ({
|
|
7
|
+
monitorZaloProvider: vi.fn(),
|
|
8
|
+
probeZalo: vi.fn(async () => ({
|
|
9
|
+
ok: false as const,
|
|
10
|
+
error: "probe failed",
|
|
11
|
+
elapsedMs: 1,
|
|
12
|
+
})),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("./monitor.js", async () => {
|
|
16
|
+
const actual = await vi.importActual<typeof import("./monitor.js")>("./monitor.js");
|
|
17
|
+
return {
|
|
18
|
+
...actual,
|
|
19
|
+
monitorZaloProvider: hoisted.monitorZaloProvider,
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
vi.mock("./probe.js", async () => {
|
|
24
|
+
const actual = await vi.importActual<typeof import("./probe.js")>("./probe.js");
|
|
25
|
+
return {
|
|
26
|
+
...actual,
|
|
27
|
+
probeZalo: hoisted.probeZalo,
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
import { zaloPlugin } from "./channel.js";
|
|
32
|
+
|
|
33
|
+
function buildAccount(): ResolvedZaloAccount {
|
|
34
|
+
return {
|
|
35
|
+
accountId: "default",
|
|
36
|
+
enabled: true,
|
|
37
|
+
token: "test-token",
|
|
38
|
+
tokenSource: "config",
|
|
39
|
+
config: {},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("zaloPlugin gateway.startAccount", () => {
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
vi.clearAllMocks();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("keeps startAccount pending until abort", async () => {
|
|
49
|
+
hoisted.monitorZaloProvider.mockImplementationOnce(
|
|
50
|
+
async ({ abortSignal }: { abortSignal: AbortSignal }) =>
|
|
51
|
+
await new Promise<void>((resolve) => {
|
|
52
|
+
if (abortSignal.aborted) {
|
|
53
|
+
resolve();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const patches: ChannelAccountSnapshot[] = [];
|
|
61
|
+
const abort = new AbortController();
|
|
62
|
+
const task = zaloPlugin.gateway!.startAccount!(
|
|
63
|
+
createStartAccountContext({
|
|
64
|
+
account: buildAccount(),
|
|
65
|
+
abortSignal: abort.signal,
|
|
66
|
+
statusPatchSink: (next) => patches.push({ ...next }),
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
let settled = false;
|
|
71
|
+
void task.then(() => {
|
|
72
|
+
settled = true;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await vi.waitFor(() => {
|
|
76
|
+
expect(hoisted.probeZalo).toHaveBeenCalledOnce();
|
|
77
|
+
expect(hoisted.monitorZaloProvider).toHaveBeenCalledOnce();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(settled).toBe(false);
|
|
81
|
+
expect(patches).toContainEqual(
|
|
82
|
+
expect.objectContaining({
|
|
83
|
+
accountId: "default",
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
abort.abort();
|
|
88
|
+
await task;
|
|
89
|
+
|
|
90
|
+
expect(settled).toBe(true);
|
|
91
|
+
expect(hoisted.monitorZaloProvider).toHaveBeenCalledWith(
|
|
92
|
+
expect.objectContaining({
|
|
93
|
+
token: "test-token",
|
|
94
|
+
account: expect.objectContaining({ accountId: "default" }),
|
|
95
|
+
abortSignal: abort.signal,
|
|
96
|
+
useWebhook: false,
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
});
|
package/src/channel.ts
CHANGED
|
@@ -334,6 +334,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
|
334
334
|
startAccount: async (ctx) => {
|
|
335
335
|
const account = ctx.account;
|
|
336
336
|
const token = account.token.trim();
|
|
337
|
+
const mode = account.config.webhookUrl ? "webhook" : "polling";
|
|
337
338
|
let zaloBotLabel = "";
|
|
338
339
|
const fetcher = resolveZaloProxyFetch(account.config.proxy);
|
|
339
340
|
try {
|
|
@@ -342,14 +343,21 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
|
342
343
|
if (name) {
|
|
343
344
|
zaloBotLabel = ` (${name})`;
|
|
344
345
|
}
|
|
346
|
+
if (!probe.ok) {
|
|
347
|
+
ctx.log?.warn?.(
|
|
348
|
+
`[${account.accountId}] Zalo probe failed before provider start (${String(probe.elapsedMs)}ms): ${probe.error}`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
345
351
|
ctx.setStatus({
|
|
346
352
|
accountId: account.accountId,
|
|
347
353
|
bot: probe.bot,
|
|
348
354
|
});
|
|
349
|
-
} catch {
|
|
350
|
-
|
|
355
|
+
} catch (err) {
|
|
356
|
+
ctx.log?.warn?.(
|
|
357
|
+
`[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
|
|
358
|
+
);
|
|
351
359
|
}
|
|
352
|
-
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel}`);
|
|
360
|
+
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`);
|
|
353
361
|
const { monitorZaloProvider } = await import("./monitor.js");
|
|
354
362
|
return monitorZaloProvider({
|
|
355
363
|
token,
|
package/src/config-schema.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AllowFromEntrySchema,
|
|
3
|
+
buildCatchallMultiAccountChannelSchema,
|
|
4
|
+
} from "openclaw/plugin-sdk/compat";
|
|
1
5
|
import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
|
|
2
6
|
import { z } from "zod";
|
|
3
7
|
import { buildSecretInputSchema } from "./secret-input.js";
|
|
4
8
|
|
|
5
|
-
const allowFromEntry = z.union([z.string(), z.number()]);
|
|
6
|
-
|
|
7
9
|
const zaloAccountSchema = z.object({
|
|
8
10
|
name: z.string().optional(),
|
|
9
11
|
enabled: z.boolean().optional(),
|
|
@@ -14,15 +16,12 @@ const zaloAccountSchema = z.object({
|
|
|
14
16
|
webhookSecret: buildSecretInputSchema().optional(),
|
|
15
17
|
webhookPath: z.string().optional(),
|
|
16
18
|
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
|
17
|
-
allowFrom: z.array(
|
|
19
|
+
allowFrom: z.array(AllowFromEntrySchema).optional(),
|
|
18
20
|
groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
|
|
19
|
-
groupAllowFrom: z.array(
|
|
21
|
+
groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
|
|
20
22
|
mediaMaxMb: z.number().optional(),
|
|
21
23
|
proxy: z.string().optional(),
|
|
22
24
|
responsePrefix: z.string().optional(),
|
|
23
25
|
});
|
|
24
26
|
|
|
25
|
-
export const ZaloConfigSchema = zaloAccountSchema
|
|
26
|
-
accounts: z.object({}).catchall(zaloAccountSchema).optional(),
|
|
27
|
-
defaultAccount: z.string().optional(),
|
|
28
|
-
});
|
|
27
|
+
export const ZaloConfigSchema = buildCatchallMultiAccountChannelSchema(zaloAccountSchema);
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
|
|
4
|
+
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
|
|
5
|
+
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
6
|
+
|
|
7
|
+
const getWebhookInfoMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
|
|
8
|
+
const deleteWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
|
|
9
|
+
const getUpdatesMock = vi.fn(() => new Promise(() => {}));
|
|
10
|
+
const setWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
|
|
11
|
+
|
|
12
|
+
vi.mock("./api.js", async (importOriginal) => {
|
|
13
|
+
const actual = await importOriginal<typeof import("./api.js")>();
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
deleteWebhook: deleteWebhookMock,
|
|
17
|
+
getWebhookInfo: getWebhookInfoMock,
|
|
18
|
+
getUpdates: getUpdatesMock,
|
|
19
|
+
setWebhook: setWebhookMock,
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
vi.mock("./runtime.js", () => ({
|
|
24
|
+
getZaloRuntime: () => ({
|
|
25
|
+
logging: {
|
|
26
|
+
shouldLogVerbose: () => false,
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
async function waitForPollingLoopStart(): Promise<void> {
|
|
32
|
+
await vi.waitFor(() => expect(getUpdatesMock).toHaveBeenCalledTimes(1));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("monitorZaloProvider lifecycle", () => {
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
setActivePluginRegistry(createEmptyPluginRegistry());
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("stays alive in polling mode until abort", async () => {
|
|
42
|
+
const { monitorZaloProvider } = await import("./monitor.js");
|
|
43
|
+
const abort = new AbortController();
|
|
44
|
+
const runtime = {
|
|
45
|
+
log: vi.fn<(message: string) => void>(),
|
|
46
|
+
error: vi.fn<(message: string) => void>(),
|
|
47
|
+
};
|
|
48
|
+
const account = {
|
|
49
|
+
accountId: "default",
|
|
50
|
+
config: {},
|
|
51
|
+
} as unknown as ResolvedZaloAccount;
|
|
52
|
+
const config = {} as OpenClawConfig;
|
|
53
|
+
|
|
54
|
+
let settled = false;
|
|
55
|
+
const run = monitorZaloProvider({
|
|
56
|
+
token: "test-token",
|
|
57
|
+
account,
|
|
58
|
+
config,
|
|
59
|
+
runtime,
|
|
60
|
+
abortSignal: abort.signal,
|
|
61
|
+
}).then(() => {
|
|
62
|
+
settled = true;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await waitForPollingLoopStart();
|
|
66
|
+
|
|
67
|
+
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(deleteWebhookMock).not.toHaveBeenCalled();
|
|
69
|
+
expect(getUpdatesMock).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(settled).toBe(false);
|
|
71
|
+
|
|
72
|
+
abort.abort();
|
|
73
|
+
await run;
|
|
74
|
+
|
|
75
|
+
expect(settled).toBe(true);
|
|
76
|
+
expect(runtime.log).toHaveBeenCalledWith(
|
|
77
|
+
expect.stringContaining("Zalo provider stopped mode=polling"),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("deletes an existing webhook before polling", async () => {
|
|
82
|
+
getWebhookInfoMock.mockResolvedValueOnce({
|
|
83
|
+
ok: true,
|
|
84
|
+
result: { url: "https://example.com/hooks/zalo" },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const { monitorZaloProvider } = await import("./monitor.js");
|
|
88
|
+
const abort = new AbortController();
|
|
89
|
+
const runtime = {
|
|
90
|
+
log: vi.fn<(message: string) => void>(),
|
|
91
|
+
error: vi.fn<(message: string) => void>(),
|
|
92
|
+
};
|
|
93
|
+
const account = {
|
|
94
|
+
accountId: "default",
|
|
95
|
+
config: {},
|
|
96
|
+
} as unknown as ResolvedZaloAccount;
|
|
97
|
+
const config = {} as OpenClawConfig;
|
|
98
|
+
|
|
99
|
+
const run = monitorZaloProvider({
|
|
100
|
+
token: "test-token",
|
|
101
|
+
account,
|
|
102
|
+
config,
|
|
103
|
+
runtime,
|
|
104
|
+
abortSignal: abort.signal,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await waitForPollingLoopStart();
|
|
108
|
+
|
|
109
|
+
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
|
|
110
|
+
expect(deleteWebhookMock).toHaveBeenCalledTimes(1);
|
|
111
|
+
expect(runtime.log).toHaveBeenCalledWith(
|
|
112
|
+
expect.stringContaining("Zalo polling mode ready (webhook disabled)"),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
abort.abort();
|
|
116
|
+
await run;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("continues polling when webhook inspection returns 404", async () => {
|
|
120
|
+
const { ZaloApiError } = await import("./api.js");
|
|
121
|
+
getWebhookInfoMock.mockRejectedValueOnce(new ZaloApiError("Not Found", 404, "Not Found"));
|
|
122
|
+
|
|
123
|
+
const { monitorZaloProvider } = await import("./monitor.js");
|
|
124
|
+
const abort = new AbortController();
|
|
125
|
+
const runtime = {
|
|
126
|
+
log: vi.fn<(message: string) => void>(),
|
|
127
|
+
error: vi.fn<(message: string) => void>(),
|
|
128
|
+
};
|
|
129
|
+
const account = {
|
|
130
|
+
accountId: "default",
|
|
131
|
+
config: {},
|
|
132
|
+
} as unknown as ResolvedZaloAccount;
|
|
133
|
+
const config = {} as OpenClawConfig;
|
|
134
|
+
|
|
135
|
+
const run = monitorZaloProvider({
|
|
136
|
+
token: "test-token",
|
|
137
|
+
account,
|
|
138
|
+
config,
|
|
139
|
+
runtime,
|
|
140
|
+
abortSignal: abort.signal,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await waitForPollingLoopStart();
|
|
144
|
+
|
|
145
|
+
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
|
|
146
|
+
expect(deleteWebhookMock).not.toHaveBeenCalled();
|
|
147
|
+
expect(runtime.log).toHaveBeenCalledWith(
|
|
148
|
+
expect.stringContaining("webhook inspection unavailable; continuing without webhook cleanup"),
|
|
149
|
+
);
|
|
150
|
+
expect(runtime.error).not.toHaveBeenCalled();
|
|
151
|
+
|
|
152
|
+
abort.abort();
|
|
153
|
+
await run;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("waits for webhook deletion before finishing webhook shutdown", async () => {
|
|
157
|
+
const registry = createEmptyPluginRegistry();
|
|
158
|
+
setActivePluginRegistry(registry);
|
|
159
|
+
|
|
160
|
+
let resolveDeleteWebhook: (() => void) | undefined;
|
|
161
|
+
deleteWebhookMock.mockImplementationOnce(
|
|
162
|
+
() =>
|
|
163
|
+
new Promise((resolve) => {
|
|
164
|
+
resolveDeleteWebhook = () => resolve({ ok: true, result: { url: "" } });
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const { monitorZaloProvider } = await import("./monitor.js");
|
|
169
|
+
const abort = new AbortController();
|
|
170
|
+
const runtime = {
|
|
171
|
+
log: vi.fn<(message: string) => void>(),
|
|
172
|
+
error: vi.fn<(message: string) => void>(),
|
|
173
|
+
};
|
|
174
|
+
const account = {
|
|
175
|
+
accountId: "default",
|
|
176
|
+
config: {},
|
|
177
|
+
} as unknown as ResolvedZaloAccount;
|
|
178
|
+
const config = {} as OpenClawConfig;
|
|
179
|
+
|
|
180
|
+
let settled = false;
|
|
181
|
+
const run = monitorZaloProvider({
|
|
182
|
+
token: "test-token",
|
|
183
|
+
account,
|
|
184
|
+
config,
|
|
185
|
+
runtime,
|
|
186
|
+
abortSignal: abort.signal,
|
|
187
|
+
useWebhook: true,
|
|
188
|
+
webhookUrl: "https://example.com/hooks/zalo",
|
|
189
|
+
webhookSecret: "supersecret", // pragma: allowlist secret
|
|
190
|
+
}).then(() => {
|
|
191
|
+
settled = true;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await vi.waitFor(() => expect(setWebhookMock).toHaveBeenCalledTimes(1));
|
|
195
|
+
expect(registry.httpRoutes).toHaveLength(1);
|
|
196
|
+
|
|
197
|
+
abort.abort();
|
|
198
|
+
|
|
199
|
+
await vi.waitFor(() => expect(deleteWebhookMock).toHaveBeenCalledTimes(1));
|
|
200
|
+
expect(deleteWebhookMock).toHaveBeenCalledWith("test-token", undefined, 5000);
|
|
201
|
+
expect(settled).toBe(false);
|
|
202
|
+
expect(registry.httpRoutes).toHaveLength(1);
|
|
203
|
+
|
|
204
|
+
resolveDeleteWebhook?.();
|
|
205
|
+
await run;
|
|
206
|
+
|
|
207
|
+
expect(settled).toBe(true);
|
|
208
|
+
expect(registry.httpRoutes).toHaveLength(0);
|
|
209
|
+
expect(runtime.log).toHaveBeenCalledWith(
|
|
210
|
+
expect.stringContaining("Zalo provider stopped mode=webhook"),
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
});
|
package/src/monitor.ts
CHANGED
|
@@ -5,9 +5,11 @@ import type {
|
|
|
5
5
|
OutboundReplyPayload,
|
|
6
6
|
} from "openclaw/plugin-sdk/zalo";
|
|
7
7
|
import {
|
|
8
|
+
createTypingCallbacks,
|
|
8
9
|
createScopedPairingAccess,
|
|
9
10
|
createReplyPrefixOptions,
|
|
10
11
|
issuePairingChallenge,
|
|
12
|
+
logTypingFailure,
|
|
11
13
|
resolveDirectDmAuthorizationOutcome,
|
|
12
14
|
resolveSenderCommandAuthorizationWithRuntime,
|
|
13
15
|
resolveOutboundMediaUrls,
|
|
@@ -15,13 +17,16 @@ import {
|
|
|
15
17
|
resolveInboundRouteEnvelopeBuilderWithRuntime,
|
|
16
18
|
sendMediaWithLeadingCaption,
|
|
17
19
|
resolveWebhookPath,
|
|
20
|
+
waitForAbortSignal,
|
|
18
21
|
warnMissingProviderGroupPolicyFallbackOnce,
|
|
19
22
|
} from "openclaw/plugin-sdk/zalo";
|
|
20
23
|
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
21
24
|
import {
|
|
22
25
|
ZaloApiError,
|
|
23
26
|
deleteWebhook,
|
|
27
|
+
getWebhookInfo,
|
|
24
28
|
getUpdates,
|
|
29
|
+
sendChatAction,
|
|
25
30
|
sendMessage,
|
|
26
31
|
sendPhoto,
|
|
27
32
|
setWebhook,
|
|
@@ -64,15 +69,34 @@ export type ZaloMonitorOptions = {
|
|
|
64
69
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
65
70
|
};
|
|
66
71
|
|
|
67
|
-
export type ZaloMonitorResult = {
|
|
68
|
-
stop: () => void;
|
|
69
|
-
};
|
|
70
|
-
|
|
71
72
|
const ZALO_TEXT_LIMIT = 2000;
|
|
72
73
|
const DEFAULT_MEDIA_MAX_MB = 5;
|
|
74
|
+
const WEBHOOK_CLEANUP_TIMEOUT_MS = 5_000;
|
|
75
|
+
const ZALO_TYPING_TIMEOUT_MS = 5_000;
|
|
73
76
|
|
|
74
77
|
type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
|
|
75
78
|
|
|
79
|
+
function formatZaloError(error: unknown): string {
|
|
80
|
+
if (error instanceof Error) {
|
|
81
|
+
return error.stack ?? `${error.name}: ${error.message}`;
|
|
82
|
+
}
|
|
83
|
+
return String(error);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function describeWebhookTarget(rawUrl: string): string {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = new URL(rawUrl);
|
|
89
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
90
|
+
} catch {
|
|
91
|
+
return rawUrl;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeWebhookUrl(url: string | undefined): string | undefined {
|
|
96
|
+
const trimmed = url?.trim();
|
|
97
|
+
return trimmed ? trimmed : undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
76
100
|
function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
|
|
77
101
|
if (core.logging.shouldLogVerbose()) {
|
|
78
102
|
runtime.log?.(`[zalo] ${message}`);
|
|
@@ -151,6 +175,8 @@ function startPollingLoop(params: {
|
|
|
151
175
|
} = params;
|
|
152
176
|
const pollTimeout = 30;
|
|
153
177
|
|
|
178
|
+
runtime.log?.(`[${account.accountId}] Zalo polling loop started timeout=${String(pollTimeout)}s`);
|
|
179
|
+
|
|
154
180
|
const poll = async () => {
|
|
155
181
|
if (isStopped() || abortSignal.aborted) {
|
|
156
182
|
return;
|
|
@@ -176,7 +202,7 @@ function startPollingLoop(params: {
|
|
|
176
202
|
if (err instanceof ZaloApiError && err.isPollingTimeout) {
|
|
177
203
|
// no updates
|
|
178
204
|
} else if (!isStopped() && !abortSignal.aborted) {
|
|
179
|
-
runtime.error?.(`[${account.accountId}] Zalo polling error: ${
|
|
205
|
+
runtime.error?.(`[${account.accountId}] Zalo polling error: ${formatZaloError(err)}`);
|
|
180
206
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
181
207
|
}
|
|
182
208
|
}
|
|
@@ -522,12 +548,35 @@ async function processMessageWithPipeline(params: {
|
|
|
522
548
|
channel: "zalo",
|
|
523
549
|
accountId: account.accountId,
|
|
524
550
|
});
|
|
551
|
+
const typingCallbacks = createTypingCallbacks({
|
|
552
|
+
start: async () => {
|
|
553
|
+
await sendChatAction(
|
|
554
|
+
token,
|
|
555
|
+
{
|
|
556
|
+
chat_id: chatId,
|
|
557
|
+
action: "typing",
|
|
558
|
+
},
|
|
559
|
+
fetcher,
|
|
560
|
+
ZALO_TYPING_TIMEOUT_MS,
|
|
561
|
+
);
|
|
562
|
+
},
|
|
563
|
+
onStartError: (err) => {
|
|
564
|
+
logTypingFailure({
|
|
565
|
+
log: (message) => logVerbose(core, runtime, message),
|
|
566
|
+
channel: "zalo",
|
|
567
|
+
action: "start",
|
|
568
|
+
target: chatId,
|
|
569
|
+
error: err,
|
|
570
|
+
});
|
|
571
|
+
},
|
|
572
|
+
});
|
|
525
573
|
|
|
526
574
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
527
575
|
ctx: ctxPayload,
|
|
528
576
|
cfg: config,
|
|
529
577
|
dispatcherOptions: {
|
|
530
578
|
...prefixOptions,
|
|
579
|
+
typingCallbacks,
|
|
531
580
|
deliver: async (payload) => {
|
|
532
581
|
await deliverZaloReply({
|
|
533
582
|
payload,
|
|
@@ -567,7 +616,6 @@ async function deliverZaloReply(params: {
|
|
|
567
616
|
const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
|
|
568
617
|
const tableMode = params.tableMode ?? "code";
|
|
569
618
|
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
|
570
|
-
|
|
571
619
|
const sentMedia = await sendMediaWithLeadingCaption({
|
|
572
620
|
mediaUrls: resolveOutboundMediaUrls(payload),
|
|
573
621
|
caption: text,
|
|
@@ -597,7 +645,7 @@ async function deliverZaloReply(params: {
|
|
|
597
645
|
}
|
|
598
646
|
}
|
|
599
647
|
|
|
600
|
-
export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
|
|
648
|
+
export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<void> {
|
|
601
649
|
const {
|
|
602
650
|
token,
|
|
603
651
|
account,
|
|
@@ -615,78 +663,140 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
|
|
|
615
663
|
const core = getZaloRuntime();
|
|
616
664
|
const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
|
617
665
|
const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
|
|
666
|
+
const mode = useWebhook ? "webhook" : "polling";
|
|
618
667
|
|
|
619
668
|
let stopped = false;
|
|
620
669
|
const stopHandlers: Array<() => void> = [];
|
|
670
|
+
let cleanupWebhook: (() => Promise<void>) | undefined;
|
|
621
671
|
|
|
622
672
|
const stop = () => {
|
|
673
|
+
if (stopped) {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
623
676
|
stopped = true;
|
|
624
677
|
for (const handler of stopHandlers) {
|
|
625
678
|
handler();
|
|
626
679
|
}
|
|
627
680
|
};
|
|
628
681
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
682
|
+
runtime.log?.(
|
|
683
|
+
`[${account.accountId}] Zalo provider init mode=${mode} mediaMaxMb=${String(effectiveMediaMaxMb)}`,
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
try {
|
|
687
|
+
if (useWebhook) {
|
|
688
|
+
if (!webhookUrl || !webhookSecret) {
|
|
689
|
+
throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
|
|
690
|
+
}
|
|
691
|
+
if (!webhookUrl.startsWith("https://")) {
|
|
692
|
+
throw new Error("Zalo webhook URL must use HTTPS");
|
|
693
|
+
}
|
|
694
|
+
if (webhookSecret.length < 8 || webhookSecret.length > 256) {
|
|
695
|
+
throw new Error("Zalo webhook secret must be 8-256 characters");
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null });
|
|
699
|
+
if (!path) {
|
|
700
|
+
throw new Error("Zalo webhookPath could not be derived");
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
runtime.log?.(
|
|
704
|
+
`[${account.accountId}] Zalo configuring webhook path=${path} target=${describeWebhookTarget(webhookUrl)}`,
|
|
705
|
+
);
|
|
706
|
+
await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
|
|
707
|
+
let webhookCleanupPromise: Promise<void> | undefined;
|
|
708
|
+
cleanupWebhook = async () => {
|
|
709
|
+
if (!webhookCleanupPromise) {
|
|
710
|
+
webhookCleanupPromise = (async () => {
|
|
711
|
+
runtime.log?.(`[${account.accountId}] Zalo stopping; deleting webhook`);
|
|
712
|
+
try {
|
|
713
|
+
await deleteWebhook(token, fetcher, WEBHOOK_CLEANUP_TIMEOUT_MS);
|
|
714
|
+
runtime.log?.(`[${account.accountId}] Zalo webhook deleted`);
|
|
715
|
+
} catch (err) {
|
|
716
|
+
const detail =
|
|
717
|
+
err instanceof Error && err.name === "AbortError"
|
|
718
|
+
? `timed out after ${String(WEBHOOK_CLEANUP_TIMEOUT_MS)}ms`
|
|
719
|
+
: formatZaloError(err);
|
|
720
|
+
runtime.error?.(`[${account.accountId}] Zalo webhook delete failed: ${detail}`);
|
|
721
|
+
}
|
|
722
|
+
})();
|
|
723
|
+
}
|
|
724
|
+
await webhookCleanupPromise;
|
|
725
|
+
};
|
|
726
|
+
runtime.log?.(`[${account.accountId}] Zalo webhook registered path=${path}`);
|
|
639
727
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
728
|
+
const unregister = registerZaloWebhookTarget({
|
|
729
|
+
token,
|
|
730
|
+
account,
|
|
731
|
+
config,
|
|
732
|
+
runtime,
|
|
733
|
+
core,
|
|
734
|
+
path,
|
|
735
|
+
secret: webhookSecret,
|
|
736
|
+
statusSink: (patch) => statusSink?.(patch),
|
|
737
|
+
mediaMaxMb: effectiveMediaMaxMb,
|
|
738
|
+
fetcher,
|
|
739
|
+
});
|
|
740
|
+
stopHandlers.push(unregister);
|
|
741
|
+
await waitForAbortSignal(abortSignal);
|
|
742
|
+
return;
|
|
643
743
|
}
|
|
644
744
|
|
|
645
|
-
|
|
745
|
+
runtime.log?.(`[${account.accountId}] Zalo polling mode: clearing webhook before startup`);
|
|
746
|
+
try {
|
|
747
|
+
try {
|
|
748
|
+
const currentWebhookUrl = normalizeWebhookUrl(
|
|
749
|
+
(await getWebhookInfo(token, fetcher)).result?.url,
|
|
750
|
+
);
|
|
751
|
+
if (!currentWebhookUrl) {
|
|
752
|
+
runtime.log?.(`[${account.accountId}] Zalo polling mode ready (no webhook configured)`);
|
|
753
|
+
} else {
|
|
754
|
+
runtime.log?.(
|
|
755
|
+
`[${account.accountId}] Zalo polling mode disabling existing webhook ${describeWebhookTarget(currentWebhookUrl)}`,
|
|
756
|
+
);
|
|
757
|
+
await deleteWebhook(token, fetcher);
|
|
758
|
+
runtime.log?.(`[${account.accountId}] Zalo polling mode ready (webhook disabled)`);
|
|
759
|
+
}
|
|
760
|
+
} catch (err) {
|
|
761
|
+
if (err instanceof ZaloApiError && err.errorCode === 404) {
|
|
762
|
+
// Some Zalo environments do not expose webhook inspection for polling bots.
|
|
763
|
+
runtime.log?.(
|
|
764
|
+
`[${account.accountId}] Zalo polling mode webhook inspection unavailable; continuing without webhook cleanup`,
|
|
765
|
+
);
|
|
766
|
+
} else {
|
|
767
|
+
throw err;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
} catch (err) {
|
|
771
|
+
runtime.error?.(
|
|
772
|
+
`[${account.accountId}] Zalo polling startup could not clear webhook: ${formatZaloError(err)}`,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
646
775
|
|
|
647
|
-
|
|
776
|
+
startPollingLoop({
|
|
648
777
|
token,
|
|
649
778
|
account,
|
|
650
779
|
config,
|
|
651
780
|
runtime,
|
|
652
781
|
core,
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
statusSink: (patch) => statusSink?.(patch),
|
|
782
|
+
abortSignal,
|
|
783
|
+
isStopped: () => stopped,
|
|
656
784
|
mediaMaxMb: effectiveMediaMaxMb,
|
|
785
|
+
statusSink,
|
|
657
786
|
fetcher,
|
|
658
787
|
});
|
|
659
|
-
stopHandlers.push(unregister);
|
|
660
|
-
abortSignal.addEventListener(
|
|
661
|
-
"abort",
|
|
662
|
-
() => {
|
|
663
|
-
void deleteWebhook(token, fetcher).catch(() => {});
|
|
664
|
-
},
|
|
665
|
-
{ once: true },
|
|
666
|
-
);
|
|
667
|
-
return { stop };
|
|
668
|
-
}
|
|
669
788
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
789
|
+
await waitForAbortSignal(abortSignal);
|
|
790
|
+
} catch (err) {
|
|
791
|
+
runtime.error?.(
|
|
792
|
+
`[${account.accountId}] Zalo provider startup failed mode=${mode}: ${formatZaloError(err)}`,
|
|
793
|
+
);
|
|
794
|
+
throw err;
|
|
795
|
+
} finally {
|
|
796
|
+
await cleanupWebhook?.();
|
|
797
|
+
stop();
|
|
798
|
+
runtime.log?.(`[${account.accountId}] Zalo provider stopped mode=${mode}`);
|
|
674
799
|
}
|
|
675
|
-
|
|
676
|
-
startPollingLoop({
|
|
677
|
-
token,
|
|
678
|
-
account,
|
|
679
|
-
config,
|
|
680
|
-
runtime,
|
|
681
|
-
core,
|
|
682
|
-
abortSignal,
|
|
683
|
-
isStopped: () => stopped,
|
|
684
|
-
mediaMaxMb: effectiveMediaMaxMb,
|
|
685
|
-
statusSink,
|
|
686
|
-
fetcher,
|
|
687
|
-
});
|
|
688
|
-
|
|
689
|
-
return { stop };
|
|
690
800
|
}
|
|
691
801
|
|
|
692
802
|
export const __testing = {
|
package/src/runtime.ts
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
|
+
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
|
1
2
|
import type { PluginRuntime } from "openclaw/plugin-sdk/zalo";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export
|
|
6
|
-
runtime = next;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function getZaloRuntime(): PluginRuntime {
|
|
10
|
-
if (!runtime) {
|
|
11
|
-
throw new Error("Zalo runtime not initialized");
|
|
12
|
-
}
|
|
13
|
-
return runtime;
|
|
14
|
-
}
|
|
4
|
+
const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } =
|
|
5
|
+
createPluginRuntimeStore<PluginRuntime>("Zalo runtime not initialized");
|
|
6
|
+
export { getZaloRuntime, setZaloRuntime };
|