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