@kodelyth/zalo 2026.5.39 → 2026.5.42

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.
Files changed (97) hide show
  1. package/README.md +50 -0
  2. package/api.ts +8 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +5 -0
  5. package/dist/actions.runtime-C61oPfyd.js +5 -0
  6. package/dist/api.js +5 -0
  7. package/dist/channel-D8ylaEdN.js +367 -0
  8. package/dist/channel-plugin-api.js +2 -0
  9. package/dist/channel.runtime-sf-rx5n-.js +105 -0
  10. package/dist/contract-api.js +3 -0
  11. package/dist/group-access-DTQVR6Nd.js +15 -0
  12. package/dist/index.js +22 -0
  13. package/dist/monitor-CQ1bjGih.js +825 -0
  14. package/dist/monitor.webhook-CDxUxa9l.js +175 -0
  15. package/dist/runtime-api-CxXTp1Q2.js +23 -0
  16. package/dist/runtime-api.js +2 -0
  17. package/dist/secret-contract-CRFukr2n.js +87 -0
  18. package/dist/secret-contract-api.js +2 -0
  19. package/dist/send-CGAqdfSA.js +270 -0
  20. package/dist/setup-api.js +30 -0
  21. package/dist/setup-core-Dr75wK6l.js +287 -0
  22. package/dist/setup-entry.js +15 -0
  23. package/dist/setup-surface-C8zxrnzG.js +216 -0
  24. package/dist/test-api.js +2 -0
  25. package/index.test.ts +15 -0
  26. package/index.ts +20 -0
  27. package/klaw.plugin.json +2 -509
  28. package/package.json +4 -4
  29. package/runtime-api.test.ts +10 -0
  30. package/runtime-api.ts +71 -0
  31. package/secret-contract-api.ts +5 -0
  32. package/setup-api.ts +34 -0
  33. package/setup-entry.ts +13 -0
  34. package/src/accounts.test.ts +95 -0
  35. package/src/accounts.ts +65 -0
  36. package/src/actions.runtime.ts +5 -0
  37. package/src/actions.test.ts +32 -0
  38. package/src/actions.ts +62 -0
  39. package/src/api.test.ts +166 -0
  40. package/src/api.ts +265 -0
  41. package/src/approval-auth.test.ts +17 -0
  42. package/src/approval-auth.ts +25 -0
  43. package/src/channel.directory.test.ts +56 -0
  44. package/src/channel.runtime.ts +89 -0
  45. package/src/channel.startup.test.ts +121 -0
  46. package/src/channel.ts +309 -0
  47. package/src/config-schema.test.ts +30 -0
  48. package/src/config-schema.ts +29 -0
  49. package/src/group-access.ts +23 -0
  50. package/src/monitor-durable.test.ts +49 -0
  51. package/src/monitor-durable.ts +38 -0
  52. package/src/monitor.group-policy.test.ts +213 -0
  53. package/src/monitor.image.polling.test.ts +113 -0
  54. package/src/monitor.lifecycle.test.ts +194 -0
  55. package/src/monitor.pairing.lifecycle.test.ts +139 -0
  56. package/src/monitor.polling.media-reply.test.ts +433 -0
  57. package/src/monitor.reply-once.lifecycle.test.ts +178 -0
  58. package/src/monitor.ts +1009 -0
  59. package/src/monitor.types.ts +4 -0
  60. package/src/monitor.webhook.test.ts +808 -0
  61. package/src/monitor.webhook.ts +278 -0
  62. package/src/outbound-media.test.ts +186 -0
  63. package/src/outbound-media.ts +236 -0
  64. package/src/outbound-payload.contract.test.ts +143 -0
  65. package/src/probe.ts +45 -0
  66. package/src/proxy.ts +18 -0
  67. package/src/runtime-api.ts +71 -0
  68. package/src/runtime-support.ts +82 -0
  69. package/src/runtime.ts +9 -0
  70. package/src/secret-contract.ts +109 -0
  71. package/src/secret-input.ts +5 -0
  72. package/src/send.test.ts +150 -0
  73. package/src/send.ts +207 -0
  74. package/src/session-route.ts +32 -0
  75. package/src/setup-allow-from.ts +97 -0
  76. package/src/setup-core.ts +152 -0
  77. package/src/setup-status.test.ts +33 -0
  78. package/src/setup-surface.test.ts +193 -0
  79. package/src/setup-surface.ts +294 -0
  80. package/src/status-issues.test.ts +17 -0
  81. package/src/status-issues.ts +34 -0
  82. package/src/test-support/lifecycle-test-support.ts +456 -0
  83. package/src/test-support/monitor-mocks-test-support.ts +209 -0
  84. package/src/token.test.ts +92 -0
  85. package/src/token.ts +79 -0
  86. package/src/types.ts +50 -0
  87. package/test-api.ts +1 -0
  88. package/tsconfig.json +16 -0
  89. package/api.js +0 -7
  90. package/channel-plugin-api.js +0 -7
  91. package/contract-api.js +0 -7
  92. package/index.js +0 -7
  93. package/runtime-api.js +0 -7
  94. package/secret-contract-api.js +0 -7
  95. package/setup-api.js +0 -7
  96. package/setup-entry.js +0 -7
  97. package/test-api.js +0 -7
@@ -0,0 +1,23 @@
1
+ import type { GroupPolicy } from "klaw/plugin-sdk/config-contracts";
2
+ import { resolveOpenProviderRuntimeGroupPolicy } from "klaw/plugin-sdk/runtime-group-policy";
3
+
4
+ const ZALO_ALLOW_FROM_PREFIX_RE = /^(zalo|zl):/i;
5
+
6
+ export function normalizeZaloAllowEntry(value: string): string {
7
+ return value.trim().replace(ZALO_ALLOW_FROM_PREFIX_RE, "").trim().toLowerCase();
8
+ }
9
+
10
+ export function resolveZaloRuntimeGroupPolicy(params: {
11
+ providerConfigPresent: boolean;
12
+ groupPolicy?: GroupPolicy;
13
+ defaultGroupPolicy?: GroupPolicy;
14
+ }): {
15
+ groupPolicy: GroupPolicy;
16
+ providerMissingFallbackApplied: boolean;
17
+ } {
18
+ return resolveOpenProviderRuntimeGroupPolicy({
19
+ providerConfigPresent: params.providerConfigPresent,
20
+ groupPolicy: params.groupPolicy,
21
+ defaultGroupPolicy: params.defaultGroupPolicy,
22
+ });
23
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ prepareZaloDurableReplyPayload,
4
+ resolveZaloDurableReplyOptions,
5
+ } from "./monitor-durable.js";
6
+
7
+ describe("Zalo durable reply helpers", () => {
8
+ it("normalizes markdown tables before durable or legacy delivery", () => {
9
+ const convertMarkdownTables = vi.fn(() => "converted table");
10
+
11
+ expect(
12
+ prepareZaloDurableReplyPayload({
13
+ payload: { text: "| a |\n| - |" },
14
+ tableMode: "code",
15
+ convertMarkdownTables,
16
+ }),
17
+ ).toEqual({ text: "converted table" });
18
+ expect(convertMarkdownTables).toHaveBeenCalledWith("| a |\n| - |", "code");
19
+ });
20
+
21
+ it("uses durable final delivery for text-only final replies", () => {
22
+ expect(
23
+ resolveZaloDurableReplyOptions({
24
+ payload: { text: "hello" },
25
+ infoKind: "final",
26
+ chatId: "123456789",
27
+ }),
28
+ ).toEqual({
29
+ to: "123456789",
30
+ });
31
+ });
32
+
33
+ it("keeps media and non-final replies on the legacy path", () => {
34
+ expect(
35
+ resolveZaloDurableReplyOptions({
36
+ payload: { text: "photo", mediaUrl: "https://example.com/photo.jpg" },
37
+ infoKind: "final",
38
+ chatId: "123456789",
39
+ }),
40
+ ).toBe(false);
41
+ expect(
42
+ resolveZaloDurableReplyOptions({
43
+ payload: { text: "hello" },
44
+ infoKind: "block",
45
+ chatId: "123456789",
46
+ }),
47
+ ).toBe(false);
48
+ });
49
+ });
@@ -0,0 +1,38 @@
1
+ import type { MarkdownTableMode } from "klaw/plugin-sdk/config-contracts";
2
+ import { resolveSendableOutboundReplyParts } from "klaw/plugin-sdk/reply-payload";
3
+ import type { OutboundReplyPayload } from "klaw/plugin-sdk/reply-payload";
4
+
5
+ export type ZaloDurableReplyOptions = {
6
+ to: string;
7
+ };
8
+
9
+ export function prepareZaloDurableReplyPayload(params: {
10
+ payload: OutboundReplyPayload;
11
+ tableMode: MarkdownTableMode;
12
+ convertMarkdownTables: (text: string, tableMode: MarkdownTableMode) => string;
13
+ }): OutboundReplyPayload {
14
+ if (!params.payload.text) {
15
+ return params.payload;
16
+ }
17
+ return {
18
+ ...params.payload,
19
+ text: params.convertMarkdownTables(params.payload.text, params.tableMode),
20
+ };
21
+ }
22
+
23
+ export function resolveZaloDurableReplyOptions(params: {
24
+ payload: OutboundReplyPayload;
25
+ infoKind: string;
26
+ chatId: string;
27
+ }): ZaloDurableReplyOptions | false {
28
+ if (params.infoKind !== "final") {
29
+ return false;
30
+ }
31
+ const reply = resolveSendableOutboundReplyParts(params.payload);
32
+ if (reply.hasMedia || !reply.hasText) {
33
+ return false;
34
+ }
35
+ return {
36
+ to: params.chatId,
37
+ };
38
+ }
@@ -0,0 +1,213 @@
1
+ import { resolveStableChannelMessageIngress } from "klaw/plugin-sdk/channel-ingress-runtime";
2
+ import type { GroupPolicy, KlawConfig } from "klaw/plugin-sdk/config-contracts";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { normalizeZaloAllowEntry, resolveZaloRuntimeGroupPolicy } from "./group-access.js";
5
+ import type { ZaloAccountConfig } from "./types.js";
6
+
7
+ function stringEntries(entries: Array<string | number> | undefined): string[] {
8
+ return (entries ?? []).map((entry) => String(entry));
9
+ }
10
+
11
+ const groupPolicyCases: Array<[string, ZaloAccountConfig, string, boolean, string]> = [
12
+ [
13
+ "disabled policy",
14
+ { groupPolicy: "disabled", groupAllowFrom: ["zalo:123"] },
15
+ "123",
16
+ false,
17
+ "group_policy_disabled",
18
+ ],
19
+ [
20
+ "empty allowlist",
21
+ { groupPolicy: "allowlist", groupAllowFrom: [] },
22
+ "attacker",
23
+ false,
24
+ "group_policy_empty_allowlist",
25
+ ],
26
+ [
27
+ "allowlist mismatch",
28
+ { groupPolicy: "allowlist", groupAllowFrom: ["zalo:victim-user-001"] },
29
+ "attacker-user-999",
30
+ false,
31
+ "group_policy_not_allowlisted",
32
+ ],
33
+ [
34
+ "Zalo prefix match",
35
+ { groupPolicy: "allowlist", groupAllowFrom: ["zl:12345"] },
36
+ "12345",
37
+ true,
38
+ "group_policy_allowed",
39
+ ],
40
+ [
41
+ "allowFrom fallback",
42
+ { groupPolicy: "allowlist", allowFrom: ["zl:12345"], groupAllowFrom: [] },
43
+ "12345",
44
+ true,
45
+ "group_policy_allowed",
46
+ ],
47
+ [
48
+ "open policy",
49
+ { groupPolicy: "open", groupAllowFrom: [] },
50
+ "attacker-user-999",
51
+ true,
52
+ "group_policy_open",
53
+ ],
54
+ ];
55
+
56
+ async function resolveAccess(
57
+ params: {
58
+ cfg?: KlawConfig;
59
+ accountConfig?: ZaloAccountConfig;
60
+ providerConfigPresent?: boolean;
61
+ defaultGroupPolicy?: GroupPolicy;
62
+ isGroup?: boolean;
63
+ senderId?: string;
64
+ rawBody?: string;
65
+ storeAllowFrom?: string[];
66
+ shouldComputeCommandAuthorized?: boolean;
67
+ } = {},
68
+ ) {
69
+ const readAllowFromStore = vi.fn(async () => params.storeAllowFrom ?? []);
70
+ const accountConfig = {
71
+ dmPolicy: "pairing",
72
+ groupPolicy: "allowlist",
73
+ allowFrom: [],
74
+ groupAllowFrom: [],
75
+ ...params.accountConfig,
76
+ } satisfies ZaloAccountConfig;
77
+ const { groupPolicy, providerMissingFallbackApplied } = resolveZaloRuntimeGroupPolicy({
78
+ providerConfigPresent: params.providerConfigPresent ?? true,
79
+ groupPolicy: accountConfig.groupPolicy,
80
+ defaultGroupPolicy: params.defaultGroupPolicy ?? "open",
81
+ });
82
+ const shouldComputeAuth = params.shouldComputeCommandAuthorized ?? false;
83
+ const isGroup = params.isGroup ?? true;
84
+ const result = await resolveStableChannelMessageIngress({
85
+ channelId: "zalo",
86
+ accountId: "default",
87
+ identity: {
88
+ key: "zalo-user-id",
89
+ normalize: normalizeZaloAllowEntry,
90
+ sensitivity: "pii",
91
+ entryIdPrefix: "zalo-entry",
92
+ },
93
+ accessGroups: params.cfg?.accessGroups,
94
+ readStoreAllowFrom: async () => await readAllowFromStore(),
95
+ useAccessGroups: params.cfg?.commands?.useAccessGroups !== false,
96
+ subject: { stableId: params.senderId ?? "123" },
97
+ conversation: {
98
+ kind: isGroup ? "group" : "direct",
99
+ id: "chat-1",
100
+ },
101
+ providerMissingFallbackApplied,
102
+ dmPolicy: accountConfig.dmPolicy ?? "pairing",
103
+ groupPolicy,
104
+ policy: { groupAllowFromFallbackToAllowFrom: true },
105
+ allowFrom: stringEntries(accountConfig.allowFrom),
106
+ groupAllowFrom: stringEntries(accountConfig.groupAllowFrom),
107
+ command: shouldComputeAuth ? {} : undefined,
108
+ });
109
+ return { result, readAllowFromStore };
110
+ }
111
+
112
+ function stableSenderAccess(access: { allowed: boolean; decision: string; reasonCode: string }) {
113
+ return {
114
+ allowed: access.allowed,
115
+ decision: access.decision,
116
+ reasonCode: access.reasonCode,
117
+ };
118
+ }
119
+
120
+ describe("zalo shared ingress access policy", () => {
121
+ it.each(groupPolicyCases)(
122
+ "maps %s through shared ingress",
123
+ async (_name, accountConfig, senderId, allowed, reasonCode) => {
124
+ const { result } = await resolveAccess({ accountConfig, senderId });
125
+ expect(stableSenderAccess(result.senderAccess)).toEqual({
126
+ allowed,
127
+ decision: allowed ? "allow" : "block",
128
+ reasonCode,
129
+ });
130
+ },
131
+ );
132
+
133
+ it("keeps group control-command authorization separate from group sender access", async () => {
134
+ const { result } = await resolveAccess({
135
+ accountConfig: {
136
+ groupPolicy: "open",
137
+ allowFrom: [],
138
+ groupAllowFrom: [],
139
+ },
140
+ rawBody: "/reset",
141
+ shouldComputeCommandAuthorized: true,
142
+ });
143
+
144
+ expect(result.senderAccess.decision).toBe("allow");
145
+ expect(result.commandAccess.authorized).toBe(false);
146
+ });
147
+
148
+ it("authorizes direct commands from the pairing store", async () => {
149
+ const { result, readAllowFromStore } = await resolveAccess({
150
+ isGroup: false,
151
+ accountConfig: {
152
+ dmPolicy: "pairing",
153
+ allowFrom: [],
154
+ },
155
+ senderId: "12345",
156
+ storeAllowFrom: ["zl:12345"],
157
+ rawBody: "/status",
158
+ shouldComputeCommandAuthorized: true,
159
+ });
160
+
161
+ expect(readAllowFromStore).toHaveBeenCalledTimes(1);
162
+ expect(stableSenderAccess(result.senderAccess)).toEqual({
163
+ allowed: true,
164
+ decision: "allow",
165
+ reasonCode: "dm_policy_allowlisted",
166
+ });
167
+ expect(result.commandAccess.authorized).toBe(true);
168
+ });
169
+
170
+ it("requires an explicit wildcard or allowlist match for open DMs", async () => {
171
+ const { result, readAllowFromStore } = await resolveAccess({
172
+ isGroup: false,
173
+ accountConfig: {
174
+ dmPolicy: "open",
175
+ allowFrom: [],
176
+ },
177
+ senderId: "12345",
178
+ });
179
+
180
+ expect(readAllowFromStore).not.toHaveBeenCalled();
181
+ expect(stableSenderAccess(result.senderAccess)).toEqual({
182
+ allowed: false,
183
+ decision: "block",
184
+ reasonCode: "dm_policy_not_allowlisted",
185
+ });
186
+ });
187
+
188
+ it("matches static access-group entries through the shared ingress resolver", async () => {
189
+ const { result } = await resolveAccess({
190
+ cfg: {
191
+ accessGroups: {
192
+ operators: {
193
+ type: "message.senders",
194
+ members: {
195
+ zalo: ["zl:12345"],
196
+ },
197
+ },
198
+ },
199
+ },
200
+ accountConfig: {
201
+ groupPolicy: "allowlist",
202
+ groupAllowFrom: ["accessGroup:operators"],
203
+ },
204
+ senderId: "12345",
205
+ });
206
+
207
+ expect(stableSenderAccess(result.senderAccess)).toEqual({
208
+ allowed: true,
209
+ decision: "allow",
210
+ reasonCode: "group_policy_allowed",
211
+ });
212
+ });
213
+ });
@@ -0,0 +1,113 @@
1
+ import { createRuntimeEnv } from "klaw/plugin-sdk/plugin-test-runtime";
2
+ import { afterAll, beforeEach, describe, expect, it } from "vitest";
3
+ import {
4
+ createImageLifecycleCore,
5
+ createImageUpdate,
6
+ createLifecycleMonitorSetup,
7
+ expectImageLifecycleDelivery,
8
+ settleAsyncWork,
9
+ } from "./test-support/lifecycle-test-support.js";
10
+ import {
11
+ getUpdatesMock,
12
+ getZaloRuntimeMock,
13
+ loadCachedLifecycleMonitorModule,
14
+ resetLifecycleTestState,
15
+ sendMessageMock,
16
+ } from "./test-support/monitor-mocks-test-support.js";
17
+
18
+ describe("Zalo polling image handling", () => {
19
+ const {
20
+ core,
21
+ finalizeInboundContextMock,
22
+ recordInboundSessionMock,
23
+ readRemoteMediaBufferMock,
24
+ saveRemoteMediaMock,
25
+ saveMediaBufferMock,
26
+ } = createImageLifecycleCore();
27
+
28
+ beforeEach(async () => {
29
+ await resetLifecycleTestState();
30
+ getZaloRuntimeMock.mockReturnValue(core);
31
+ });
32
+
33
+ afterAll(async () => {
34
+ await resetLifecycleTestState();
35
+ });
36
+
37
+ it("downloads inbound image media from photo_url and preserves display_name", async () => {
38
+ getUpdatesMock
39
+ .mockResolvedValueOnce({
40
+ ok: true,
41
+ result: createImageUpdate({ date: 1774084566880 }),
42
+ })
43
+ .mockImplementation(() => new Promise(() => {}));
44
+
45
+ const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule("zalo-image-polling");
46
+ const abort = new AbortController();
47
+ const runtime = createRuntimeEnv();
48
+ const { account, config } = createLifecycleMonitorSetup({
49
+ accountId: "default",
50
+ dmPolicy: "open",
51
+ });
52
+ const run = monitorZaloProvider({
53
+ token: "zalo-token", // pragma: allowlist secret
54
+ account,
55
+ config,
56
+ runtime,
57
+ abortSignal: abort.signal,
58
+ });
59
+
60
+ await settleAsyncWork();
61
+ expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1);
62
+ expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
63
+ expectImageLifecycleDelivery({
64
+ readRemoteMediaBufferMock,
65
+ saveRemoteMediaMock,
66
+ saveMediaBufferMock,
67
+ finalizeInboundContextMock,
68
+ recordInboundSessionMock,
69
+ });
70
+
71
+ abort.abort();
72
+ await run;
73
+ });
74
+
75
+ it("rejects unauthorized DM images before downloading media", async () => {
76
+ getUpdatesMock
77
+ .mockResolvedValueOnce({
78
+ ok: true,
79
+ result: createImageUpdate({
80
+ messageId: "msg-unauthorized-1",
81
+ userId: "user-unauthorized-1",
82
+ chatId: "chat-unauthorized-1",
83
+ }),
84
+ })
85
+ .mockImplementation(() => new Promise(() => {}));
86
+
87
+ const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule("zalo-image-polling");
88
+ const abort = new AbortController();
89
+ const runtime = createRuntimeEnv();
90
+ const { account, config } = createLifecycleMonitorSetup({
91
+ accountId: "default",
92
+ dmPolicy: "pairing",
93
+ allowFrom: ["allowed-user"],
94
+ });
95
+ const run = monitorZaloProvider({
96
+ token: "zalo-token", // pragma: allowlist secret
97
+ account,
98
+ config,
99
+ runtime,
100
+ abortSignal: abort.signal,
101
+ });
102
+
103
+ await settleAsyncWork();
104
+ expect(sendMessageMock).toHaveBeenCalledTimes(1);
105
+ expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
106
+ expect(saveMediaBufferMock).not.toHaveBeenCalled();
107
+ expect(finalizeInboundContextMock).not.toHaveBeenCalled();
108
+ expect(recordInboundSessionMock).not.toHaveBeenCalled();
109
+
110
+ abort.abort();
111
+ await run;
112
+ });
113
+ });
@@ -0,0 +1,194 @@
1
+ import {
2
+ createEmptyPluginRegistry,
3
+ createRuntimeEnv,
4
+ setActivePluginRegistry,
5
+ } from "klaw/plugin-sdk/plugin-test-runtime";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import type { KlawConfig } from "../runtime-api.js";
8
+ import type { ResolvedZaloAccount } from "./accounts.js";
9
+
10
+ const getWebhookInfoMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
11
+ const deleteWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
12
+ const getUpdatesMock = vi.fn(() => new Promise(() => {}));
13
+ const setWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
14
+
15
+ vi.mock("./api.js", async () => {
16
+ const actual = await vi.importActual<typeof import("./api.js")>("./api.js");
17
+ return {
18
+ ...actual,
19
+ deleteWebhook: deleteWebhookMock,
20
+ getWebhookInfo: getWebhookInfoMock,
21
+ getUpdates: getUpdatesMock,
22
+ setWebhook: setWebhookMock,
23
+ };
24
+ });
25
+
26
+ vi.mock("./runtime.js", () => ({
27
+ getZaloRuntime: () => ({
28
+ logging: {
29
+ shouldLogVerbose: () => false,
30
+ },
31
+ }),
32
+ }));
33
+
34
+ const TEST_ACCOUNT = {
35
+ accountId: "default",
36
+ config: {},
37
+ } as unknown as ResolvedZaloAccount;
38
+
39
+ const TEST_CONFIG = {} as KlawConfig;
40
+
41
+ async function settleLifecycleWork(): Promise<void> {
42
+ for (let i = 0; i < 6; i += 1) {
43
+ await Promise.resolve();
44
+ await new Promise((resolve) => setImmediate(resolve));
45
+ }
46
+ }
47
+
48
+ async function startLifecycleMonitor(
49
+ options: {
50
+ useWebhook?: boolean;
51
+ webhookSecret?: string;
52
+ webhookUrl?: string;
53
+ } = {},
54
+ ) {
55
+ const { monitorZaloProvider } = await import("./monitor.js");
56
+ const abort = new AbortController();
57
+ const runtime = createRuntimeEnv();
58
+ const run = monitorZaloProvider({
59
+ token: "test-token",
60
+ account: TEST_ACCOUNT,
61
+ config: TEST_CONFIG,
62
+ runtime,
63
+ abortSignal: abort.signal,
64
+ ...options,
65
+ });
66
+ return { abort, runtime, run };
67
+ }
68
+
69
+ describe("monitorZaloProvider lifecycle", () => {
70
+ afterEach(() => {
71
+ vi.clearAllMocks();
72
+ setActivePluginRegistry(createEmptyPluginRegistry());
73
+ });
74
+
75
+ it("stays alive in polling mode until abort", async () => {
76
+ let settled = false;
77
+ const { abort, runtime, run } = await startLifecycleMonitor();
78
+ const monitoredRun = run.then(() => {
79
+ settled = true;
80
+ });
81
+
82
+ await settleLifecycleWork();
83
+ expect(getUpdatesMock).toHaveBeenCalledTimes(1);
84
+
85
+ expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
86
+ expect(deleteWebhookMock).not.toHaveBeenCalled();
87
+ expect(getUpdatesMock).toHaveBeenCalledTimes(1);
88
+ expect(settled).toBe(false);
89
+
90
+ abort.abort();
91
+ await monitoredRun;
92
+
93
+ expect(settled).toBe(true);
94
+ expect(runtime.log).toHaveBeenCalledWith("[default] Zalo provider stopped mode=polling");
95
+ });
96
+
97
+ it("deletes an existing webhook before polling", async () => {
98
+ getWebhookInfoMock.mockResolvedValueOnce({
99
+ ok: true,
100
+ result: { url: "https://example.com/hooks/zalo" },
101
+ });
102
+
103
+ const { abort, runtime, run } = await startLifecycleMonitor();
104
+
105
+ await settleLifecycleWork();
106
+ expect(getUpdatesMock).toHaveBeenCalledTimes(1);
107
+
108
+ expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
109
+ expect(deleteWebhookMock).toHaveBeenCalledTimes(1);
110
+ expect(runtime.log).toHaveBeenCalledWith(
111
+ "[default] Zalo polling mode ready (webhook disabled)",
112
+ );
113
+
114
+ abort.abort();
115
+ await run;
116
+ });
117
+
118
+ it("continues polling when webhook inspection returns 404", async () => {
119
+ const { ZaloApiError } = await import("./api.js");
120
+ getWebhookInfoMock.mockRejectedValueOnce(new ZaloApiError("Not Found", 404, "Not Found"));
121
+
122
+ const { abort, runtime, run } = await startLifecycleMonitor();
123
+
124
+ await settleLifecycleWork();
125
+ expect(getUpdatesMock).toHaveBeenCalledTimes(1);
126
+
127
+ expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
128
+ expect(deleteWebhookMock).not.toHaveBeenCalled();
129
+ expect(runtime.log).toHaveBeenCalledWith(
130
+ "[default] Zalo polling mode webhook inspection unavailable; continuing without webhook cleanup",
131
+ );
132
+ expect(runtime.error).not.toHaveBeenCalled();
133
+
134
+ abort.abort();
135
+ await run;
136
+ });
137
+
138
+ it("waits for webhook deletion before finishing webhook shutdown", async () => {
139
+ const registry = createEmptyPluginRegistry();
140
+ setActivePluginRegistry(registry);
141
+
142
+ let resolveSetWebhookCalled: (() => void) | undefined;
143
+ const setWebhookCalled = new Promise<void>((resolve) => {
144
+ resolveSetWebhookCalled = resolve;
145
+ });
146
+ setWebhookMock.mockImplementationOnce(async () => {
147
+ resolveSetWebhookCalled?.();
148
+ return { ok: true, result: { url: "" } };
149
+ });
150
+
151
+ let resolveDeleteWebhookCalled: (() => void) | undefined;
152
+ const deleteWebhookCalled = new Promise<void>((resolve) => {
153
+ resolveDeleteWebhookCalled = resolve;
154
+ });
155
+ let resolveDeleteWebhook: (() => void) | undefined;
156
+ deleteWebhookMock.mockImplementationOnce(
157
+ () =>
158
+ new Promise((resolve) => {
159
+ resolveDeleteWebhookCalled?.();
160
+ resolveDeleteWebhook = () => resolve({ ok: true, result: { url: "" } });
161
+ }),
162
+ );
163
+
164
+ let settled = false;
165
+ const { abort, runtime, run } = await startLifecycleMonitor({
166
+ useWebhook: true,
167
+ webhookUrl: "https://example.com/hooks/zalo",
168
+ webhookSecret: "supersecret", // pragma: allowlist secret
169
+ });
170
+ const monitoredRun = run.then(() => {
171
+ settled = true;
172
+ });
173
+
174
+ await setWebhookCalled;
175
+ await settleLifecycleWork();
176
+ expect(setWebhookMock).toHaveBeenCalledTimes(1);
177
+ expect(registry.httpRoutes).toHaveLength(2);
178
+
179
+ abort.abort();
180
+
181
+ await deleteWebhookCalled;
182
+ expect(deleteWebhookMock).toHaveBeenCalledTimes(1);
183
+ expect(deleteWebhookMock).toHaveBeenCalledWith("test-token", undefined, 5000);
184
+ expect(settled).toBe(false);
185
+ expect(registry.httpRoutes).toHaveLength(2);
186
+
187
+ resolveDeleteWebhook?.();
188
+ await monitoredRun;
189
+
190
+ expect(settled).toBe(true);
191
+ expect(registry.httpRoutes).toHaveLength(0);
192
+ expect(runtime.log).toHaveBeenCalledWith("[default] Zalo provider stopped mode=webhook");
193
+ });
194
+ });