@kodelyth/zalo 2026.5.42 → 2026.6.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.
Files changed (67) hide show
  1. package/klaw.plugin.json +509 -2
  2. package/package.json +19 -6
  3. package/api.ts +0 -8
  4. package/channel-plugin-api.ts +0 -1
  5. package/contract-api.ts +0 -5
  6. package/index.test.ts +0 -15
  7. package/index.ts +0 -20
  8. package/runtime-api.test.ts +0 -10
  9. package/runtime-api.ts +0 -71
  10. package/secret-contract-api.ts +0 -5
  11. package/setup-api.ts +0 -34
  12. package/setup-entry.ts +0 -13
  13. package/src/accounts.test.ts +0 -95
  14. package/src/accounts.ts +0 -65
  15. package/src/actions.runtime.ts +0 -5
  16. package/src/actions.test.ts +0 -32
  17. package/src/actions.ts +0 -62
  18. package/src/api.test.ts +0 -166
  19. package/src/api.ts +0 -265
  20. package/src/approval-auth.test.ts +0 -17
  21. package/src/approval-auth.ts +0 -25
  22. package/src/channel.directory.test.ts +0 -56
  23. package/src/channel.runtime.ts +0 -89
  24. package/src/channel.startup.test.ts +0 -121
  25. package/src/channel.ts +0 -309
  26. package/src/config-schema.test.ts +0 -30
  27. package/src/config-schema.ts +0 -29
  28. package/src/group-access.ts +0 -23
  29. package/src/monitor-durable.test.ts +0 -49
  30. package/src/monitor-durable.ts +0 -38
  31. package/src/monitor.group-policy.test.ts +0 -213
  32. package/src/monitor.image.polling.test.ts +0 -113
  33. package/src/monitor.lifecycle.test.ts +0 -194
  34. package/src/monitor.pairing.lifecycle.test.ts +0 -139
  35. package/src/monitor.polling.media-reply.test.ts +0 -433
  36. package/src/monitor.reply-once.lifecycle.test.ts +0 -178
  37. package/src/monitor.ts +0 -1009
  38. package/src/monitor.types.ts +0 -4
  39. package/src/monitor.webhook.test.ts +0 -808
  40. package/src/monitor.webhook.ts +0 -278
  41. package/src/outbound-media.test.ts +0 -186
  42. package/src/outbound-media.ts +0 -236
  43. package/src/outbound-payload.contract.test.ts +0 -143
  44. package/src/probe.ts +0 -45
  45. package/src/proxy.ts +0 -18
  46. package/src/runtime-api.ts +0 -71
  47. package/src/runtime-support.ts +0 -82
  48. package/src/runtime.ts +0 -9
  49. package/src/secret-contract.ts +0 -109
  50. package/src/secret-input.ts +0 -5
  51. package/src/send.test.ts +0 -150
  52. package/src/send.ts +0 -207
  53. package/src/session-route.ts +0 -32
  54. package/src/setup-allow-from.ts +0 -97
  55. package/src/setup-core.ts +0 -152
  56. package/src/setup-status.test.ts +0 -33
  57. package/src/setup-surface.test.ts +0 -193
  58. package/src/setup-surface.ts +0 -294
  59. package/src/status-issues.test.ts +0 -17
  60. package/src/status-issues.ts +0 -34
  61. package/src/test-support/lifecycle-test-support.ts +0 -456
  62. package/src/test-support/monitor-mocks-test-support.ts +0 -209
  63. package/src/token.test.ts +0 -92
  64. package/src/token.ts +0 -79
  65. package/src/types.ts +0 -50
  66. package/test-api.ts +0 -1
  67. package/tsconfig.json +0 -16
@@ -1,143 +0,0 @@
1
- import {
2
- installChannelOutboundPayloadContractSuite,
3
- primeChannelOutboundSendMock,
4
- type OutboundPayloadHarnessParams,
5
- } from "klaw/plugin-sdk/channel-contract-testing";
6
- import {
7
- createMessageReceiptFromOutboundResults,
8
- verifyChannelMessageAdapterCapabilityProofs,
9
- } from "klaw/plugin-sdk/channel-message";
10
- import { describe, expect, it, vi } from "vitest";
11
- import { zaloMessageAdapter, zaloPlugin } from "./channel.js";
12
-
13
- const { sendZaloTextMock } = vi.hoisted(() => ({
14
- sendZaloTextMock: vi.fn(),
15
- }));
16
-
17
- vi.mock("./channel.runtime.js", () => ({
18
- sendZaloText: sendZaloTextMock,
19
- }));
20
-
21
- type ZaloOutbound = NonNullable<typeof zaloPlugin.outbound>;
22
- type ZaloSendPayload = NonNullable<ZaloOutbound["sendPayload"]>;
23
- type ZaloMessageSender = NonNullable<typeof zaloMessageAdapter.send>;
24
-
25
- function requireZaloSendPayload(): ZaloSendPayload {
26
- const sendPayload = zaloPlugin.outbound?.sendPayload;
27
- if (!sendPayload) {
28
- throw new Error("Expected Zalo outbound sendPayload");
29
- }
30
- return sendPayload;
31
- }
32
-
33
- function requireZaloTextSender(): NonNullable<ZaloMessageSender["text"]> {
34
- const text = zaloMessageAdapter.send?.text;
35
- if (!text) {
36
- throw new Error("Expected Zalo message adapter text sender");
37
- }
38
- return text;
39
- }
40
-
41
- function requireZaloMediaSender(): NonNullable<ZaloMessageSender["media"]> {
42
- const media = zaloMessageAdapter.send?.media;
43
- if (!media) {
44
- throw new Error("Expected Zalo message adapter media sender");
45
- }
46
- return media;
47
- }
48
-
49
- function createZaloHarness(params: OutboundPayloadHarnessParams) {
50
- const sendZalo = vi.fn();
51
- primeChannelOutboundSendMock(sendZalo, { ok: true, messageId: "zl-1" }, params.sendResults);
52
- sendZaloTextMock.mockReset().mockImplementation(
53
- async (nextCtx: { to: string; text: string; mediaUrl?: string }) =>
54
- await sendZalo(nextCtx.to, nextCtx.text, {
55
- mediaUrl: nextCtx.mediaUrl,
56
- }),
57
- );
58
- const ctx = {
59
- cfg: {},
60
- to: "123456789",
61
- text: "",
62
- payload: params.payload,
63
- };
64
- const sendPayload = requireZaloSendPayload();
65
- return {
66
- run: async () => await sendPayload(ctx),
67
- sendMock: sendZalo,
68
- to: ctx.to,
69
- };
70
- }
71
-
72
- describe("Zalo outbound payload contract", () => {
73
- installChannelOutboundPayloadContractSuite({
74
- channel: "zalo",
75
- chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
76
- createHarness: createZaloHarness,
77
- });
78
-
79
- it("declares message adapter durable text and media with receipt proofs", async () => {
80
- sendZaloTextMock.mockReset().mockImplementation(async (ctx: { mediaUrl?: string }) =>
81
- ctx.mediaUrl
82
- ? {
83
- ok: true,
84
- messageId: "zl-media-1",
85
- receipt: createMessageReceiptFromOutboundResults({
86
- results: [{ channel: "zalo", messageId: "zl-media-1" }],
87
- kind: "media",
88
- }),
89
- }
90
- : {
91
- ok: true,
92
- messageId: "zl-text-1",
93
- receipt: createMessageReceiptFromOutboundResults({
94
- results: [{ channel: "zalo", messageId: "zl-text-1" }],
95
- kind: "text",
96
- }),
97
- },
98
- );
99
- const sendText = requireZaloTextSender();
100
- const sendMedia = requireZaloMediaSender();
101
-
102
- const proofs = await verifyChannelMessageAdapterCapabilityProofs({
103
- adapterName: "zalo",
104
- adapter: zaloMessageAdapter,
105
- proofs: {
106
- text: async () => {
107
- const result = await sendText({
108
- cfg: {},
109
- to: "123456789",
110
- text: "hello",
111
- });
112
- expect(result.receipt.platformMessageIds).toEqual(["zl-text-1"]);
113
- },
114
- media: async () => {
115
- const result = await sendMedia({
116
- cfg: {},
117
- to: "123456789",
118
- text: "image",
119
- mediaUrl: "https://example.com/image.png",
120
- });
121
- expect(result.receipt.platformMessageIds).toEqual(["zl-media-1"]);
122
- },
123
- messageSendingHooks: () => {
124
- expect(sendText).toBeTypeOf("function");
125
- },
126
- },
127
- });
128
- expect(proofs).toStrictEqual([
129
- { capability: "text", status: "verified" },
130
- { capability: "media", status: "verified" },
131
- { capability: "payload", status: "not_declared" },
132
- { capability: "silent", status: "not_declared" },
133
- { capability: "replyTo", status: "not_declared" },
134
- { capability: "thread", status: "not_declared" },
135
- { capability: "nativeQuote", status: "not_declared" },
136
- { capability: "messageSendingHooks", status: "verified" },
137
- { capability: "batch", status: "not_declared" },
138
- { capability: "reconcileUnknownSend", status: "not_declared" },
139
- { capability: "afterSendSuccess", status: "not_declared" },
140
- { capability: "afterCommit", status: "not_declared" },
141
- ]);
142
- });
143
- });
package/src/probe.ts DELETED
@@ -1,45 +0,0 @@
1
- import type { BaseProbeResult } from "klaw/plugin-sdk/channel-contract";
2
- import { getMe, ZaloApiError, type ZaloBotInfo, type ZaloFetch } from "./api.js";
3
-
4
- export type ZaloProbeResult = BaseProbeResult<string> & {
5
- bot?: ZaloBotInfo;
6
- elapsedMs: number;
7
- };
8
-
9
- export async function probeZalo(
10
- token: string,
11
- timeoutMs = 5000,
12
- fetcher?: ZaloFetch,
13
- ): Promise<ZaloProbeResult> {
14
- if (!token?.trim()) {
15
- return { ok: false, error: "No token provided", elapsedMs: 0 };
16
- }
17
-
18
- const startTime = Date.now();
19
-
20
- try {
21
- const response = await getMe(token.trim(), timeoutMs, fetcher);
22
- const elapsedMs = Date.now() - startTime;
23
-
24
- if (response.ok && response.result) {
25
- return { ok: true, bot: response.result, elapsedMs };
26
- }
27
-
28
- return { ok: false, error: "Invalid response from Zalo API", elapsedMs };
29
- } catch (err) {
30
- const elapsedMs = Date.now() - startTime;
31
-
32
- if (err instanceof ZaloApiError) {
33
- return { ok: false, error: err.description ?? err.message, elapsedMs };
34
- }
35
-
36
- if (err instanceof Error) {
37
- if (err.name === "AbortError") {
38
- return { ok: false, error: `Request timed out after ${timeoutMs}ms`, elapsedMs };
39
- }
40
- return { ok: false, error: err.message, elapsedMs };
41
- }
42
-
43
- return { ok: false, error: String(err), elapsedMs };
44
- }
45
- }
package/src/proxy.ts DELETED
@@ -1,18 +0,0 @@
1
- import { makeProxyFetch } from "klaw/plugin-sdk/fetch-runtime";
2
- import type { ZaloFetch } from "./api.js";
3
-
4
- const proxyCache = new Map<string, ZaloFetch>();
5
-
6
- export function resolveZaloProxyFetch(proxyUrl?: string | null): ZaloFetch | undefined {
7
- const trimmed = proxyUrl?.trim();
8
- if (!trimmed) {
9
- return undefined;
10
- }
11
- const cached = proxyCache.get(trimmed);
12
- if (cached) {
13
- return cached;
14
- }
15
- const fetcher = makeProxyFetch(trimmed) as ZaloFetch;
16
- proxyCache.set(trimmed, fetcher);
17
- return fetcher;
18
- }
@@ -1,71 +0,0 @@
1
- export {
2
- addWildcardAllowFrom,
3
- applyAccountNameToChannelSection,
4
- applyBasicWebhookRequestGuards,
5
- applySetupAccountConfigPatch,
6
- type BaseProbeResult,
7
- type BaseTokenResolution,
8
- buildBaseAccountStatusSnapshot,
9
- buildChannelConfigSchema,
10
- buildSecretInputSchema,
11
- buildSingleChannelSecretPromptState,
12
- buildTokenChannelStatusSummary,
13
- type ChannelAccountSnapshot,
14
- type ChannelMessageActionAdapter,
15
- type ChannelMessageActionName,
16
- type ChannelPlugin,
17
- type ChannelStatusIssue,
18
- chunkTextForOutbound,
19
- createChannelPairingController,
20
- createChannelMessageReplyPipeline,
21
- createDedupeCache,
22
- createFixedWindowRateLimiter,
23
- createWebhookAnomalyTracker,
24
- DEFAULT_ACCOUNT_ID,
25
- deliverTextOrMediaReply,
26
- formatAllowFromLowercase,
27
- formatPairingApproveHint,
28
- type GroupPolicy,
29
- hasConfiguredSecretInput,
30
- isNormalizedSenderAllowed,
31
- isNumericTargetId,
32
- jsonResult,
33
- logTypingFailure,
34
- type MarkdownTableMode,
35
- mergeAllowFromEntries,
36
- migrateBaseNameToDefaultAccount,
37
- normalizeAccountId,
38
- normalizeResolvedSecretInputString,
39
- normalizeSecretInputString,
40
- type KlawConfig,
41
- type OutboundReplyPayload,
42
- PAIRING_APPROVED_MESSAGE,
43
- type PluginRuntime,
44
- promptSingleChannelSecretInput,
45
- readJsonWebhookBodyOrReject,
46
- readStringParam,
47
- registerPluginHttpRoute,
48
- type RegisterWebhookPluginRouteOptions,
49
- registerWebhookTarget,
50
- type RegisterWebhookTargetOptions,
51
- registerWebhookTargetWithPluginRoute,
52
- type ReplyPayload,
53
- resolveClientIp,
54
- resolveDefaultGroupPolicy,
55
- resolveInboundRouteEnvelopeBuilderWithRuntime,
56
- resolveOpenProviderRuntimeGroupPolicy,
57
- resolveWebhookPath,
58
- resolveWebhookTargetWithAuthOrRejectSync,
59
- runSingleChannelSecretStep,
60
- type RuntimeEnv,
61
- type SecretInput,
62
- sendPayloadWithChunkedTextAndMedia,
63
- setTopLevelChannelDmPolicyWithAllowFrom,
64
- waitForAbortSignal,
65
- warnMissingProviderGroupPolicyFallbackOnce,
66
- WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
67
- WEBHOOK_RATE_LIMIT_DEFAULTS,
68
- withResolvedWebhookRequestPipeline,
69
- type WizardPrompter,
70
- } from "./runtime-support.js";
71
- export { setZaloRuntime } from "./runtime.js";
@@ -1,82 +0,0 @@
1
- export type { ReplyPayload } from "klaw/plugin-sdk/reply-runtime";
2
- export type { KlawConfig, GroupPolicy } from "klaw/plugin-sdk/config-contracts";
3
- export type { MarkdownTableMode } from "klaw/plugin-sdk/config-contracts";
4
- export type { BaseTokenResolution } from "klaw/plugin-sdk/channel-contract";
5
- export type {
6
- BaseProbeResult,
7
- ChannelAccountSnapshot,
8
- ChannelMessageActionAdapter,
9
- ChannelMessageActionName,
10
- ChannelStatusIssue,
11
- } from "klaw/plugin-sdk/channel-contract";
12
- export type { SecretInput } from "klaw/plugin-sdk/secret-input";
13
- export type { ChannelPlugin, PluginRuntime, WizardPrompter } from "klaw/plugin-sdk/core";
14
- export type { RuntimeEnv } from "klaw/plugin-sdk/runtime";
15
- export type { OutboundReplyPayload } from "klaw/plugin-sdk/reply-payload";
16
- export {
17
- DEFAULT_ACCOUNT_ID,
18
- buildChannelConfigSchema,
19
- createDedupeCache,
20
- formatPairingApproveHint,
21
- jsonResult,
22
- normalizeAccountId,
23
- readStringParam,
24
- resolveClientIp,
25
- } from "klaw/plugin-sdk/core";
26
- export {
27
- applyAccountNameToChannelSection,
28
- applySetupAccountConfigPatch,
29
- buildSingleChannelSecretPromptState,
30
- mergeAllowFromEntries,
31
- migrateBaseNameToDefaultAccount,
32
- promptSingleChannelSecretInput,
33
- runSingleChannelSecretStep,
34
- setTopLevelChannelDmPolicyWithAllowFrom,
35
- } from "klaw/plugin-sdk/setup";
36
- export {
37
- buildSecretInputSchema,
38
- hasConfiguredSecretInput,
39
- normalizeResolvedSecretInputString,
40
- normalizeSecretInputString,
41
- } from "klaw/plugin-sdk/secret-input";
42
- export {
43
- buildTokenChannelStatusSummary,
44
- PAIRING_APPROVED_MESSAGE,
45
- } from "klaw/plugin-sdk/channel-status";
46
- export { buildBaseAccountStatusSnapshot } from "klaw/plugin-sdk/status-helpers";
47
- export { chunkTextForOutbound } from "klaw/plugin-sdk/text-chunking";
48
- export { formatAllowFromLowercase, isNormalizedSenderAllowed } from "klaw/plugin-sdk/allow-from";
49
- export { addWildcardAllowFrom } from "klaw/plugin-sdk/setup";
50
- export { resolveOpenProviderRuntimeGroupPolicy } from "klaw/plugin-sdk/runtime-group-policy";
51
- export {
52
- warnMissingProviderGroupPolicyFallbackOnce,
53
- resolveDefaultGroupPolicy,
54
- } from "klaw/plugin-sdk/runtime-group-policy";
55
- export { createChannelPairingController } from "klaw/plugin-sdk/channel-pairing";
56
- export { createChannelMessageReplyPipeline } from "klaw/plugin-sdk/channel-message";
57
- export { logTypingFailure } from "klaw/plugin-sdk/channel-feedback";
58
- export {
59
- deliverTextOrMediaReply,
60
- isNumericTargetId,
61
- sendPayloadWithChunkedTextAndMedia,
62
- } from "klaw/plugin-sdk/reply-payload";
63
- export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "klaw/plugin-sdk/inbound-envelope";
64
- export { waitForAbortSignal } from "klaw/plugin-sdk/runtime";
65
- export {
66
- applyBasicWebhookRequestGuards,
67
- createFixedWindowRateLimiter,
68
- createWebhookAnomalyTracker,
69
- readJsonWebhookBodyOrReject,
70
- registerPluginHttpRoute,
71
- registerWebhookTarget,
72
- registerWebhookTargetWithPluginRoute,
73
- resolveWebhookPath,
74
- resolveWebhookTargetWithAuthOrRejectSync,
75
- WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
76
- WEBHOOK_RATE_LIMIT_DEFAULTS,
77
- withResolvedWebhookRequestPipeline,
78
- } from "klaw/plugin-sdk/webhook-ingress";
79
- export type {
80
- RegisterWebhookPluginRouteOptions,
81
- RegisterWebhookTargetOptions,
82
- } from "klaw/plugin-sdk/webhook-ingress";
package/src/runtime.ts DELETED
@@ -1,9 +0,0 @@
1
- import { createPluginRuntimeStore } from "klaw/plugin-sdk/runtime-store";
2
- import type { PluginRuntime } from "./runtime-support.js";
3
-
4
- const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } =
5
- createPluginRuntimeStore<PluginRuntime>({
6
- pluginId: "zalo",
7
- errorMessage: "Zalo runtime not initialized",
8
- });
9
- export { getZaloRuntime, setZaloRuntime };
@@ -1,109 +0,0 @@
1
- import {
2
- collectConditionalChannelFieldAssignments,
3
- getChannelSurface,
4
- hasOwnProperty,
5
- type ResolverContext,
6
- type SecretDefaults,
7
- type SecretTargetRegistryEntry,
8
- } from "klaw/plugin-sdk/channel-secret-basic-runtime";
9
-
10
- export const secretTargetRegistryEntries: SecretTargetRegistryEntry[] = [
11
- {
12
- id: "channels.zalo.accounts.*.botToken",
13
- targetType: "channels.zalo.accounts.*.botToken",
14
- configFile: "klaw.json",
15
- pathPattern: "channels.zalo.accounts.*.botToken",
16
- secretShape: "secret_input",
17
- expectedResolvedValue: "string",
18
- includeInPlan: true,
19
- includeInConfigure: true,
20
- includeInAudit: true,
21
- },
22
- {
23
- id: "channels.zalo.accounts.*.webhookSecret",
24
- targetType: "channels.zalo.accounts.*.webhookSecret",
25
- configFile: "klaw.json",
26
- pathPattern: "channels.zalo.accounts.*.webhookSecret",
27
- secretShape: "secret_input",
28
- expectedResolvedValue: "string",
29
- includeInPlan: true,
30
- includeInConfigure: true,
31
- includeInAudit: true,
32
- },
33
- {
34
- id: "channels.zalo.botToken",
35
- targetType: "channels.zalo.botToken",
36
- configFile: "klaw.json",
37
- pathPattern: "channels.zalo.botToken",
38
- secretShape: "secret_input",
39
- expectedResolvedValue: "string",
40
- includeInPlan: true,
41
- includeInConfigure: true,
42
- includeInAudit: true,
43
- },
44
- {
45
- id: "channels.zalo.webhookSecret",
46
- targetType: "channels.zalo.webhookSecret",
47
- configFile: "klaw.json",
48
- pathPattern: "channels.zalo.webhookSecret",
49
- secretShape: "secret_input",
50
- expectedResolvedValue: "string",
51
- includeInPlan: true,
52
- includeInConfigure: true,
53
- includeInAudit: true,
54
- },
55
- ];
56
-
57
- export function collectRuntimeConfigAssignments(params: {
58
- config: { channels?: Record<string, unknown> };
59
- defaults?: SecretDefaults;
60
- context: ResolverContext;
61
- }): void {
62
- const resolved = getChannelSurface(params.config, "zalo");
63
- if (!resolved) {
64
- return;
65
- }
66
- const { channel: zalo, surface } = resolved;
67
- collectConditionalChannelFieldAssignments({
68
- channelKey: "zalo",
69
- field: "botToken",
70
- channel: zalo,
71
- surface,
72
- defaults: params.defaults,
73
- context: params.context,
74
- topLevelActiveWithoutAccounts: true,
75
- topLevelInheritedAccountActive: ({ account, enabled }) =>
76
- enabled && !hasOwnProperty(account, "botToken"),
77
- accountActive: ({ enabled }) => enabled,
78
- topInactiveReason: "no enabled Zalo surface inherits this top-level botToken.",
79
- accountInactiveReason: "Zalo account is disabled.",
80
- });
81
- const baseWebhookUrl = typeof zalo.webhookUrl === "string" ? zalo.webhookUrl.trim() : "";
82
- const accountWebhookUrl = (account: Record<string, unknown>) =>
83
- hasOwnProperty(account, "webhookUrl")
84
- ? typeof account.webhookUrl === "string"
85
- ? account.webhookUrl.trim()
86
- : ""
87
- : baseWebhookUrl;
88
- collectConditionalChannelFieldAssignments({
89
- channelKey: "zalo",
90
- field: "webhookSecret",
91
- channel: zalo,
92
- surface,
93
- defaults: params.defaults,
94
- context: params.context,
95
- topLevelActiveWithoutAccounts: baseWebhookUrl.length > 0,
96
- topLevelInheritedAccountActive: ({ account, enabled }) =>
97
- enabled && !hasOwnProperty(account, "webhookSecret") && accountWebhookUrl(account).length > 0,
98
- accountActive: ({ account, enabled }) => enabled && accountWebhookUrl(account).length > 0,
99
- topInactiveReason:
100
- "no enabled Zalo webhook surface inherits this top-level webhookSecret (webhook mode is not active).",
101
- accountInactiveReason:
102
- "Zalo account is disabled or webhook mode is not active for this account.",
103
- });
104
- }
105
-
106
- export const channelSecrets = {
107
- secretTargetRegistryEntries,
108
- collectRuntimeConfigAssignments,
109
- };
@@ -1,5 +0,0 @@
1
- export {
2
- buildSecretInputSchema,
3
- normalizeResolvedSecretInputString,
4
- normalizeSecretInputString,
5
- } from "klaw/plugin-sdk/secret-input";
package/src/send.test.ts DELETED
@@ -1,150 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
-
3
- const sendMessageMock = vi.fn();
4
- const sendPhotoMock = vi.fn();
5
- const resolveZaloProxyFetchMock = vi.fn();
6
-
7
- vi.mock("./api.js", () => ({
8
- sendMessage: (...args: unknown[]) => sendMessageMock(...args),
9
- sendPhoto: (...args: unknown[]) => sendPhotoMock(...args),
10
- }));
11
-
12
- vi.mock("./proxy.js", () => ({
13
- resolveZaloProxyFetch: (...args: unknown[]) => resolveZaloProxyFetchMock(...args),
14
- }));
15
-
16
- import { sendMessageZalo, sendPhotoZalo } from "./send.js";
17
-
18
- type ZaloSendResult = Awaited<ReturnType<typeof sendMessageZalo>>;
19
-
20
- function requireSuccessfulSend(result: ZaloSendResult, expectedMessageId: string) {
21
- expect(result.ok).toBe(true);
22
- if (!result.ok) {
23
- throw new Error(`expected successful Zalo send: ${result.error}`);
24
- }
25
- expect(result.messageId).toBe(expectedMessageId);
26
- return result;
27
- }
28
-
29
- function expectFailedSend(result: ZaloSendResult, expectedError: string) {
30
- expect(result.ok).toBe(false);
31
- if (result.ok) {
32
- throw new Error("expected failed Zalo send");
33
- }
34
- expect(result.error).toBe(expectedError);
35
- expect(result.receipt.platformMessageIds).toStrictEqual([]);
36
- }
37
-
38
- describe("zalo send", () => {
39
- beforeEach(() => {
40
- sendMessageMock.mockReset();
41
- sendPhotoMock.mockReset();
42
- resolveZaloProxyFetchMock.mockReset();
43
- resolveZaloProxyFetchMock.mockReturnValue(undefined);
44
- });
45
-
46
- it("sends text messages through the message API", async () => {
47
- sendMessageMock.mockResolvedValueOnce({
48
- ok: true,
49
- result: { message_id: "z-msg-1" },
50
- });
51
-
52
- const result = await sendMessageZalo("dm-chat-1", "hello there", {
53
- token: "zalo-token",
54
- });
55
-
56
- expect(sendMessageMock).toHaveBeenCalledWith(
57
- "zalo-token",
58
- {
59
- chat_id: "dm-chat-1",
60
- text: "hello there",
61
- },
62
- undefined,
63
- );
64
- expect(sendPhotoMock).not.toHaveBeenCalled();
65
- const successful = requireSuccessfulSend(result, "z-msg-1");
66
- expect(successful.receipt.primaryPlatformMessageId).toBe("z-msg-1");
67
- expect(successful.receipt.platformMessageIds).toEqual(["z-msg-1"]);
68
- expect(successful.receipt.parts).toHaveLength(1);
69
- expect(successful.receipt.parts[0]?.platformMessageId).toBe("z-msg-1");
70
- expect(successful.receipt.parts[0]?.kind).toBe("text");
71
- expect(successful.receipt.parts[0]?.raw).toEqual({
72
- channel: "zalo",
73
- chatId: "dm-chat-1",
74
- messageId: "z-msg-1",
75
- });
76
- });
77
-
78
- it("routes media-bearing sends through the photo API and uses text as caption", async () => {
79
- sendPhotoMock.mockResolvedValueOnce({
80
- ok: true,
81
- result: { message_id: "z-photo-1" },
82
- });
83
-
84
- const result = await sendMessageZalo("dm-chat-2", "caption text", {
85
- token: "zalo-token",
86
- mediaUrl: "https://example.com/photo.jpg",
87
- caption: "ignored fallback caption",
88
- });
89
-
90
- expect(sendPhotoMock).toHaveBeenCalledWith(
91
- "zalo-token",
92
- {
93
- chat_id: "dm-chat-2",
94
- photo: "https://example.com/photo.jpg",
95
- caption: "caption text",
96
- },
97
- undefined,
98
- );
99
- expect(sendMessageMock).not.toHaveBeenCalled();
100
- const successful = requireSuccessfulSend(result, "z-photo-1");
101
- expect(successful.receipt.primaryPlatformMessageId).toBe("z-photo-1");
102
- expect(successful.receipt.platformMessageIds).toEqual(["z-photo-1"]);
103
- expect(successful.receipt.parts).toHaveLength(1);
104
- expect(successful.receipt.parts[0]?.platformMessageId).toBe("z-photo-1");
105
- expect(successful.receipt.parts[0]?.kind).toBe("media");
106
- });
107
-
108
- it("fails fast for missing token or blank photo URLs", async () => {
109
- const missingToken = await sendMessageZalo("dm-chat-3", "hello", {});
110
- expectFailedSend(missingToken, "No Zalo bot token configured");
111
-
112
- const blankPhoto = await sendPhotoZalo("dm-chat-4", " ", {
113
- token: "zalo-token",
114
- });
115
- expectFailedSend(blankPhoto, "No photo URL provided");
116
-
117
- expect(sendMessageMock).not.toHaveBeenCalled();
118
- expect(sendPhotoMock).not.toHaveBeenCalled();
119
- });
120
-
121
- it("sends cfg-backed media directly without hosted-media rewrites", async () => {
122
- sendPhotoMock.mockResolvedValueOnce({
123
- ok: true,
124
- result: { message_id: "z-photo-2" },
125
- });
126
-
127
- const result = await sendPhotoZalo("dm-chat-5", "https://example.com/photo.jpg", {
128
- cfg: {
129
- channels: {
130
- zalo: {
131
- botToken: "zalo-token",
132
- webhookUrl: "https://gateway.example.com/zalo-webhook",
133
- },
134
- },
135
- } as never,
136
- });
137
-
138
- expect(sendPhotoMock).toHaveBeenCalledWith(
139
- "zalo-token",
140
- {
141
- chat_id: "dm-chat-5",
142
- photo: "https://example.com/photo.jpg",
143
- caption: undefined,
144
- },
145
- undefined,
146
- );
147
- const successful = requireSuccessfulSend(result, "z-photo-2");
148
- expect(successful.receipt.platformMessageIds).toEqual(["z-photo-2"]);
149
- });
150
- });