@openclaw/zalo 2026.1.29
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 +60 -0
- package/README.md +50 -0
- package/index.ts +20 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +33 -0
- package/src/accounts.ts +71 -0
- package/src/actions.ts +62 -0
- package/src/api.ts +206 -0
- package/src/channel.directory.test.ts +35 -0
- package/src/channel.ts +394 -0
- package/src/config-schema.ts +24 -0
- package/src/monitor.ts +760 -0
- package/src/monitor.webhook.test.ts +70 -0
- package/src/onboarding.ts +405 -0
- package/src/probe.ts +46 -0
- package/src/proxy.ts +18 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +117 -0
- package/src/status-issues.ts +50 -0
- package/src/token.ts +55 -0
- package/src/types.ts +42 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import type { AddressInfo } from "node:net";
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
7
|
+
import type { ResolvedZaloAccount } from "./types.js";
|
|
8
|
+
import { handleZaloWebhookRequest, registerZaloWebhookTarget } from "./monitor.js";
|
|
9
|
+
|
|
10
|
+
async function withServer(
|
|
11
|
+
handler: Parameters<typeof createServer>[0],
|
|
12
|
+
fn: (baseUrl: string) => Promise<void>,
|
|
13
|
+
) {
|
|
14
|
+
const server = createServer(handler);
|
|
15
|
+
await new Promise<void>((resolve) => {
|
|
16
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
17
|
+
});
|
|
18
|
+
const address = server.address() as AddressInfo | null;
|
|
19
|
+
if (!address) throw new Error("missing server address");
|
|
20
|
+
try {
|
|
21
|
+
await fn(`http://127.0.0.1:${address.port}`);
|
|
22
|
+
} finally {
|
|
23
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("handleZaloWebhookRequest", () => {
|
|
28
|
+
it("returns 400 for non-object payloads", async () => {
|
|
29
|
+
const core = {} as PluginRuntime;
|
|
30
|
+
const account: ResolvedZaloAccount = {
|
|
31
|
+
accountId: "default",
|
|
32
|
+
enabled: true,
|
|
33
|
+
token: "tok",
|
|
34
|
+
tokenSource: "config",
|
|
35
|
+
config: {},
|
|
36
|
+
};
|
|
37
|
+
const unregister = registerZaloWebhookTarget({
|
|
38
|
+
token: "tok",
|
|
39
|
+
account,
|
|
40
|
+
config: {} as OpenClawConfig,
|
|
41
|
+
runtime: {},
|
|
42
|
+
core,
|
|
43
|
+
secret: "secret",
|
|
44
|
+
path: "/hook",
|
|
45
|
+
mediaMaxMb: 5,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await withServer(async (req, res) => {
|
|
50
|
+
const handled = await handleZaloWebhookRequest(req, res);
|
|
51
|
+
if (!handled) {
|
|
52
|
+
res.statusCode = 404;
|
|
53
|
+
res.end("not found");
|
|
54
|
+
}
|
|
55
|
+
}, async (baseUrl) => {
|
|
56
|
+
const response = await fetch(`${baseUrl}/hook`, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: {
|
|
59
|
+
"x-bot-api-secret-token": "secret",
|
|
60
|
+
},
|
|
61
|
+
body: "null",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(response.status).toBe(400);
|
|
65
|
+
});
|
|
66
|
+
} finally {
|
|
67
|
+
unregister();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelOnboardingAdapter,
|
|
3
|
+
ChannelOnboardingDmPolicy,
|
|
4
|
+
OpenClawConfig,
|
|
5
|
+
WizardPrompter,
|
|
6
|
+
} from "openclaw/plugin-sdk";
|
|
7
|
+
import {
|
|
8
|
+
addWildcardAllowFrom,
|
|
9
|
+
DEFAULT_ACCOUNT_ID,
|
|
10
|
+
normalizeAccountId,
|
|
11
|
+
promptAccountId,
|
|
12
|
+
} from "openclaw/plugin-sdk";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
listZaloAccountIds,
|
|
16
|
+
resolveDefaultZaloAccountId,
|
|
17
|
+
resolveZaloAccount,
|
|
18
|
+
} from "./accounts.js";
|
|
19
|
+
|
|
20
|
+
const channel = "zalo" as const;
|
|
21
|
+
|
|
22
|
+
type UpdateMode = "polling" | "webhook";
|
|
23
|
+
|
|
24
|
+
function setZaloDmPolicy(
|
|
25
|
+
cfg: OpenClawConfig,
|
|
26
|
+
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
|
|
27
|
+
) {
|
|
28
|
+
const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.zalo?.allowFrom) : undefined;
|
|
29
|
+
return {
|
|
30
|
+
...cfg,
|
|
31
|
+
channels: {
|
|
32
|
+
...cfg.channels,
|
|
33
|
+
zalo: {
|
|
34
|
+
...cfg.channels?.zalo,
|
|
35
|
+
dmPolicy,
|
|
36
|
+
...(allowFrom ? { allowFrom } : {}),
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
} as OpenClawConfig;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function setZaloUpdateMode(
|
|
43
|
+
cfg: OpenClawConfig,
|
|
44
|
+
accountId: string,
|
|
45
|
+
mode: UpdateMode,
|
|
46
|
+
webhookUrl?: string,
|
|
47
|
+
webhookSecret?: string,
|
|
48
|
+
webhookPath?: string,
|
|
49
|
+
): OpenClawConfig {
|
|
50
|
+
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
|
51
|
+
if (mode === "polling") {
|
|
52
|
+
if (isDefault) {
|
|
53
|
+
const {
|
|
54
|
+
webhookUrl: _url,
|
|
55
|
+
webhookSecret: _secret,
|
|
56
|
+
webhookPath: _path,
|
|
57
|
+
...rest
|
|
58
|
+
} = cfg.channels?.zalo ?? {};
|
|
59
|
+
return {
|
|
60
|
+
...cfg,
|
|
61
|
+
channels: {
|
|
62
|
+
...cfg.channels,
|
|
63
|
+
zalo: rest,
|
|
64
|
+
},
|
|
65
|
+
} as OpenClawConfig;
|
|
66
|
+
}
|
|
67
|
+
const accounts = { ...(cfg.channels?.zalo?.accounts ?? {}) } as Record<
|
|
68
|
+
string,
|
|
69
|
+
Record<string, unknown>
|
|
70
|
+
>;
|
|
71
|
+
const existing = accounts[accountId] ?? {};
|
|
72
|
+
const {
|
|
73
|
+
webhookUrl: _url,
|
|
74
|
+
webhookSecret: _secret,
|
|
75
|
+
webhookPath: _path,
|
|
76
|
+
...rest
|
|
77
|
+
} = existing;
|
|
78
|
+
accounts[accountId] = rest;
|
|
79
|
+
return {
|
|
80
|
+
...cfg,
|
|
81
|
+
channels: {
|
|
82
|
+
...cfg.channels,
|
|
83
|
+
zalo: {
|
|
84
|
+
...cfg.channels?.zalo,
|
|
85
|
+
accounts,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
} as OpenClawConfig;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (isDefault) {
|
|
92
|
+
return {
|
|
93
|
+
...cfg,
|
|
94
|
+
channels: {
|
|
95
|
+
...cfg.channels,
|
|
96
|
+
zalo: {
|
|
97
|
+
...cfg.channels?.zalo,
|
|
98
|
+
webhookUrl,
|
|
99
|
+
webhookSecret,
|
|
100
|
+
webhookPath,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
} as OpenClawConfig;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const accounts = { ...(cfg.channels?.zalo?.accounts ?? {}) } as Record<
|
|
107
|
+
string,
|
|
108
|
+
Record<string, unknown>
|
|
109
|
+
>;
|
|
110
|
+
accounts[accountId] = {
|
|
111
|
+
...(accounts[accountId] ?? {}),
|
|
112
|
+
webhookUrl,
|
|
113
|
+
webhookSecret,
|
|
114
|
+
webhookPath,
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
...cfg,
|
|
118
|
+
channels: {
|
|
119
|
+
...cfg.channels,
|
|
120
|
+
zalo: {
|
|
121
|
+
...cfg.channels?.zalo,
|
|
122
|
+
accounts,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
} as OpenClawConfig;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function noteZaloTokenHelp(prompter: WizardPrompter): Promise<void> {
|
|
129
|
+
await prompter.note(
|
|
130
|
+
[
|
|
131
|
+
"1) Open Zalo Bot Platform: https://bot.zaloplatforms.com",
|
|
132
|
+
"2) Create a bot and get the token",
|
|
133
|
+
"3) Token looks like 12345689:abc-xyz",
|
|
134
|
+
"Tip: you can also set ZALO_BOT_TOKEN in your env.",
|
|
135
|
+
"Docs: https://docs.openclaw.ai/channels/zalo",
|
|
136
|
+
].join("\n"),
|
|
137
|
+
"Zalo bot token",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function promptZaloAllowFrom(params: {
|
|
142
|
+
cfg: OpenClawConfig;
|
|
143
|
+
prompter: WizardPrompter;
|
|
144
|
+
accountId: string;
|
|
145
|
+
}): Promise<OpenClawConfig> {
|
|
146
|
+
const { cfg, prompter, accountId } = params;
|
|
147
|
+
const resolved = resolveZaloAccount({ cfg, accountId });
|
|
148
|
+
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
|
149
|
+
const entry = await prompter.text({
|
|
150
|
+
message: "Zalo allowFrom (user id)",
|
|
151
|
+
placeholder: "123456789",
|
|
152
|
+
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
|
153
|
+
validate: (value) => {
|
|
154
|
+
const raw = String(value ?? "").trim();
|
|
155
|
+
if (!raw) return "Required";
|
|
156
|
+
if (!/^\d+$/.test(raw)) return "Use a numeric Zalo user id";
|
|
157
|
+
return undefined;
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
const normalized = String(entry).trim();
|
|
161
|
+
const merged = [
|
|
162
|
+
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
|
|
163
|
+
normalized,
|
|
164
|
+
];
|
|
165
|
+
const unique = [...new Set(merged)];
|
|
166
|
+
|
|
167
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
168
|
+
return {
|
|
169
|
+
...cfg,
|
|
170
|
+
channels: {
|
|
171
|
+
...cfg.channels,
|
|
172
|
+
zalo: {
|
|
173
|
+
...cfg.channels?.zalo,
|
|
174
|
+
enabled: true,
|
|
175
|
+
dmPolicy: "allowlist",
|
|
176
|
+
allowFrom: unique,
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
} as OpenClawConfig;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
...cfg,
|
|
184
|
+
channels: {
|
|
185
|
+
...cfg.channels,
|
|
186
|
+
zalo: {
|
|
187
|
+
...cfg.channels?.zalo,
|
|
188
|
+
enabled: true,
|
|
189
|
+
accounts: {
|
|
190
|
+
...(cfg.channels?.zalo?.accounts ?? {}),
|
|
191
|
+
[accountId]: {
|
|
192
|
+
...(cfg.channels?.zalo?.accounts?.[accountId] ?? {}),
|
|
193
|
+
enabled: cfg.channels?.zalo?.accounts?.[accountId]?.enabled ?? true,
|
|
194
|
+
dmPolicy: "allowlist",
|
|
195
|
+
allowFrom: unique,
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
} as OpenClawConfig;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const dmPolicy: ChannelOnboardingDmPolicy = {
|
|
204
|
+
label: "Zalo",
|
|
205
|
+
channel,
|
|
206
|
+
policyKey: "channels.zalo.dmPolicy",
|
|
207
|
+
allowFromKey: "channels.zalo.allowFrom",
|
|
208
|
+
getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing",
|
|
209
|
+
setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as OpenClawConfig, policy),
|
|
210
|
+
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
|
|
211
|
+
const id =
|
|
212
|
+
accountId && normalizeAccountId(accountId)
|
|
213
|
+
? normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID
|
|
214
|
+
: resolveDefaultZaloAccountId(cfg as OpenClawConfig);
|
|
215
|
+
return promptZaloAllowFrom({
|
|
216
|
+
cfg: cfg as OpenClawConfig,
|
|
217
|
+
prompter,
|
|
218
|
+
accountId: id,
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
224
|
+
channel,
|
|
225
|
+
dmPolicy,
|
|
226
|
+
getStatus: async ({ cfg }) => {
|
|
227
|
+
const configured = listZaloAccountIds(cfg as OpenClawConfig).some((accountId) =>
|
|
228
|
+
Boolean(resolveZaloAccount({ cfg: cfg as OpenClawConfig, accountId }).token),
|
|
229
|
+
);
|
|
230
|
+
return {
|
|
231
|
+
channel,
|
|
232
|
+
configured,
|
|
233
|
+
statusLines: [`Zalo: ${configured ? "configured" : "needs token"}`],
|
|
234
|
+
selectionHint: configured ? "recommended · configured" : "recommended · newcomer-friendly",
|
|
235
|
+
quickstartScore: configured ? 1 : 10,
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds, forceAllowFrom }) => {
|
|
239
|
+
const zaloOverride = accountOverrides.zalo?.trim();
|
|
240
|
+
const defaultZaloAccountId = resolveDefaultZaloAccountId(cfg as OpenClawConfig);
|
|
241
|
+
let zaloAccountId = zaloOverride
|
|
242
|
+
? normalizeAccountId(zaloOverride)
|
|
243
|
+
: defaultZaloAccountId;
|
|
244
|
+
if (shouldPromptAccountIds && !zaloOverride) {
|
|
245
|
+
zaloAccountId = await promptAccountId({
|
|
246
|
+
cfg: cfg as OpenClawConfig,
|
|
247
|
+
prompter,
|
|
248
|
+
label: "Zalo",
|
|
249
|
+
currentId: zaloAccountId,
|
|
250
|
+
listAccountIds: listZaloAccountIds,
|
|
251
|
+
defaultAccountId: defaultZaloAccountId,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let next = cfg as OpenClawConfig;
|
|
256
|
+
const resolvedAccount = resolveZaloAccount({ cfg: next, accountId: zaloAccountId });
|
|
257
|
+
const accountConfigured = Boolean(resolvedAccount.token);
|
|
258
|
+
const allowEnv = zaloAccountId === DEFAULT_ACCOUNT_ID;
|
|
259
|
+
const canUseEnv = allowEnv && Boolean(process.env.ZALO_BOT_TOKEN?.trim());
|
|
260
|
+
const hasConfigToken = Boolean(
|
|
261
|
+
resolvedAccount.config.botToken || resolvedAccount.config.tokenFile,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
let token: string | null = null;
|
|
265
|
+
if (!accountConfigured) {
|
|
266
|
+
await noteZaloTokenHelp(prompter);
|
|
267
|
+
}
|
|
268
|
+
if (canUseEnv && !resolvedAccount.config.botToken) {
|
|
269
|
+
const keepEnv = await prompter.confirm({
|
|
270
|
+
message: "ZALO_BOT_TOKEN detected. Use env var?",
|
|
271
|
+
initialValue: true,
|
|
272
|
+
});
|
|
273
|
+
if (keepEnv) {
|
|
274
|
+
next = {
|
|
275
|
+
...next,
|
|
276
|
+
channels: {
|
|
277
|
+
...next.channels,
|
|
278
|
+
zalo: {
|
|
279
|
+
...next.channels?.zalo,
|
|
280
|
+
enabled: true,
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
} as OpenClawConfig;
|
|
284
|
+
} else {
|
|
285
|
+
token = String(
|
|
286
|
+
await prompter.text({
|
|
287
|
+
message: "Enter Zalo bot token",
|
|
288
|
+
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
289
|
+
}),
|
|
290
|
+
).trim();
|
|
291
|
+
}
|
|
292
|
+
} else if (hasConfigToken) {
|
|
293
|
+
const keep = await prompter.confirm({
|
|
294
|
+
message: "Zalo token already configured. Keep it?",
|
|
295
|
+
initialValue: true,
|
|
296
|
+
});
|
|
297
|
+
if (!keep) {
|
|
298
|
+
token = String(
|
|
299
|
+
await prompter.text({
|
|
300
|
+
message: "Enter Zalo bot token",
|
|
301
|
+
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
302
|
+
}),
|
|
303
|
+
).trim();
|
|
304
|
+
}
|
|
305
|
+
} else {
|
|
306
|
+
token = String(
|
|
307
|
+
await prompter.text({
|
|
308
|
+
message: "Enter Zalo bot token",
|
|
309
|
+
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
310
|
+
}),
|
|
311
|
+
).trim();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (token) {
|
|
315
|
+
if (zaloAccountId === DEFAULT_ACCOUNT_ID) {
|
|
316
|
+
next = {
|
|
317
|
+
...next,
|
|
318
|
+
channels: {
|
|
319
|
+
...next.channels,
|
|
320
|
+
zalo: {
|
|
321
|
+
...next.channels?.zalo,
|
|
322
|
+
enabled: true,
|
|
323
|
+
botToken: token,
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
} as OpenClawConfig;
|
|
327
|
+
} else {
|
|
328
|
+
next = {
|
|
329
|
+
...next,
|
|
330
|
+
channels: {
|
|
331
|
+
...next.channels,
|
|
332
|
+
zalo: {
|
|
333
|
+
...next.channels?.zalo,
|
|
334
|
+
enabled: true,
|
|
335
|
+
accounts: {
|
|
336
|
+
...(next.channels?.zalo?.accounts ?? {}),
|
|
337
|
+
[zaloAccountId]: {
|
|
338
|
+
...(next.channels?.zalo?.accounts?.[zaloAccountId] ?? {}),
|
|
339
|
+
enabled: true,
|
|
340
|
+
botToken: token,
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
} as OpenClawConfig;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const wantsWebhook = await prompter.confirm({
|
|
350
|
+
message: "Use webhook mode for Zalo?",
|
|
351
|
+
initialValue: false,
|
|
352
|
+
});
|
|
353
|
+
if (wantsWebhook) {
|
|
354
|
+
const webhookUrl = String(
|
|
355
|
+
await prompter.text({
|
|
356
|
+
message: "Webhook URL (https://...) ",
|
|
357
|
+
validate: (value) => (value?.trim()?.startsWith("https://") ? undefined : "HTTPS URL required"),
|
|
358
|
+
}),
|
|
359
|
+
).trim();
|
|
360
|
+
const defaultPath = (() => {
|
|
361
|
+
try {
|
|
362
|
+
return new URL(webhookUrl).pathname || "/zalo-webhook";
|
|
363
|
+
} catch {
|
|
364
|
+
return "/zalo-webhook";
|
|
365
|
+
}
|
|
366
|
+
})();
|
|
367
|
+
const webhookSecret = String(
|
|
368
|
+
await prompter.text({
|
|
369
|
+
message: "Webhook secret (8-256 chars)",
|
|
370
|
+
validate: (value) => {
|
|
371
|
+
const raw = String(value ?? "");
|
|
372
|
+
if (raw.length < 8 || raw.length > 256) return "8-256 chars";
|
|
373
|
+
return undefined;
|
|
374
|
+
},
|
|
375
|
+
}),
|
|
376
|
+
).trim();
|
|
377
|
+
const webhookPath = String(
|
|
378
|
+
await prompter.text({
|
|
379
|
+
message: "Webhook path (optional)",
|
|
380
|
+
initialValue: defaultPath,
|
|
381
|
+
}),
|
|
382
|
+
).trim();
|
|
383
|
+
next = setZaloUpdateMode(
|
|
384
|
+
next,
|
|
385
|
+
zaloAccountId,
|
|
386
|
+
"webhook",
|
|
387
|
+
webhookUrl,
|
|
388
|
+
webhookSecret,
|
|
389
|
+
webhookPath || undefined,
|
|
390
|
+
);
|
|
391
|
+
} else {
|
|
392
|
+
next = setZaloUpdateMode(next, zaloAccountId, "polling");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (forceAllowFrom) {
|
|
396
|
+
next = await promptZaloAllowFrom({
|
|
397
|
+
cfg: next,
|
|
398
|
+
prompter,
|
|
399
|
+
accountId: zaloAccountId,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return { cfg: next, accountId: zaloAccountId };
|
|
404
|
+
},
|
|
405
|
+
};
|
package/src/probe.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { getMe, ZaloApiError, type ZaloBotInfo, type ZaloFetch } from "./api.js";
|
|
2
|
+
|
|
3
|
+
export type ZaloProbeResult = {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
bot?: ZaloBotInfo;
|
|
6
|
+
error?: string;
|
|
7
|
+
elapsedMs: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export async function probeZalo(
|
|
11
|
+
token: string,
|
|
12
|
+
timeoutMs = 5000,
|
|
13
|
+
fetcher?: ZaloFetch,
|
|
14
|
+
): Promise<ZaloProbeResult> {
|
|
15
|
+
if (!token?.trim()) {
|
|
16
|
+
return { ok: false, error: "No token provided", elapsedMs: 0 };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const startTime = Date.now();
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const response = await getMe(token.trim(), timeoutMs, fetcher);
|
|
23
|
+
const elapsedMs = Date.now() - startTime;
|
|
24
|
+
|
|
25
|
+
if (response.ok && response.result) {
|
|
26
|
+
return { ok: true, bot: response.result, elapsedMs };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { ok: false, error: "Invalid response from Zalo API", elapsedMs };
|
|
30
|
+
} catch (err) {
|
|
31
|
+
const elapsedMs = Date.now() - startTime;
|
|
32
|
+
|
|
33
|
+
if (err instanceof ZaloApiError) {
|
|
34
|
+
return { ok: false, error: err.description ?? err.message, elapsedMs };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (err instanceof Error) {
|
|
38
|
+
if (err.name === "AbortError") {
|
|
39
|
+
return { ok: false, error: `Request timed out after ${timeoutMs}ms`, elapsedMs };
|
|
40
|
+
}
|
|
41
|
+
return { ok: false, error: err.message, elapsedMs };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { ok: false, error: String(err), elapsedMs };
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/proxy.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ProxyAgent, fetch as undiciFetch } from "undici";
|
|
2
|
+
import type { Dispatcher } from "undici";
|
|
3
|
+
|
|
4
|
+
import type { ZaloFetch } from "./api.js";
|
|
5
|
+
|
|
6
|
+
const proxyCache = new Map<string, ZaloFetch>();
|
|
7
|
+
|
|
8
|
+
export function resolveZaloProxyFetch(proxyUrl?: string | null): ZaloFetch | undefined {
|
|
9
|
+
const trimmed = proxyUrl?.trim();
|
|
10
|
+
if (!trimmed) return undefined;
|
|
11
|
+
const cached = proxyCache.get(trimmed);
|
|
12
|
+
if (cached) return cached;
|
|
13
|
+
const agent = new ProxyAgent(trimmed);
|
|
14
|
+
const fetcher: ZaloFetch = (input, init) =>
|
|
15
|
+
undiciFetch(input, { ...(init ?? {}), dispatcher: agent as Dispatcher });
|
|
16
|
+
proxyCache.set(trimmed, fetcher);
|
|
17
|
+
return fetcher;
|
|
18
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
let runtime: PluginRuntime | null = null;
|
|
4
|
+
|
|
5
|
+
export function setZaloRuntime(next: PluginRuntime): void {
|
|
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
|
+
}
|
package/src/send.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
import type { ZaloFetch } from "./api.js";
|
|
4
|
+
import { sendMessage, sendPhoto } from "./api.js";
|
|
5
|
+
import { resolveZaloAccount } from "./accounts.js";
|
|
6
|
+
import { resolveZaloProxyFetch } from "./proxy.js";
|
|
7
|
+
import { resolveZaloToken } from "./token.js";
|
|
8
|
+
|
|
9
|
+
export type ZaloSendOptions = {
|
|
10
|
+
token?: string;
|
|
11
|
+
accountId?: string;
|
|
12
|
+
cfg?: OpenClawConfig;
|
|
13
|
+
mediaUrl?: string;
|
|
14
|
+
caption?: string;
|
|
15
|
+
verbose?: boolean;
|
|
16
|
+
proxy?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ZaloSendResult = {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
messageId?: string;
|
|
22
|
+
error?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function resolveSendContext(options: ZaloSendOptions): {
|
|
26
|
+
token: string;
|
|
27
|
+
fetcher?: ZaloFetch;
|
|
28
|
+
} {
|
|
29
|
+
if (options.cfg) {
|
|
30
|
+
const account = resolveZaloAccount({
|
|
31
|
+
cfg: options.cfg,
|
|
32
|
+
accountId: options.accountId,
|
|
33
|
+
});
|
|
34
|
+
const token = options.token || account.token;
|
|
35
|
+
const proxy = options.proxy ?? account.config.proxy;
|
|
36
|
+
return { token, fetcher: resolveZaloProxyFetch(proxy) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const token = options.token ?? resolveZaloToken(undefined, options.accountId).token;
|
|
40
|
+
const proxy = options.proxy;
|
|
41
|
+
return { token, fetcher: resolveZaloProxyFetch(proxy) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function sendMessageZalo(
|
|
45
|
+
chatId: string,
|
|
46
|
+
text: string,
|
|
47
|
+
options: ZaloSendOptions = {},
|
|
48
|
+
): Promise<ZaloSendResult> {
|
|
49
|
+
const { token, fetcher } = resolveSendContext(options);
|
|
50
|
+
|
|
51
|
+
if (!token) {
|
|
52
|
+
return { ok: false, error: "No Zalo bot token configured" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!chatId?.trim()) {
|
|
56
|
+
return { ok: false, error: "No chat_id provided" };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (options.mediaUrl) {
|
|
60
|
+
return sendPhotoZalo(chatId, options.mediaUrl, {
|
|
61
|
+
...options,
|
|
62
|
+
token,
|
|
63
|
+
caption: text || options.caption,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const response = await sendMessage(token, {
|
|
69
|
+
chat_id: chatId.trim(),
|
|
70
|
+
text: text.slice(0, 2000),
|
|
71
|
+
}, fetcher);
|
|
72
|
+
|
|
73
|
+
if (response.ok && response.result) {
|
|
74
|
+
return { ok: true, messageId: response.result.message_id };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { ok: false, error: "Failed to send message" };
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function sendPhotoZalo(
|
|
84
|
+
chatId: string,
|
|
85
|
+
photoUrl: string,
|
|
86
|
+
options: ZaloSendOptions = {},
|
|
87
|
+
): Promise<ZaloSendResult> {
|
|
88
|
+
const { token, fetcher } = resolveSendContext(options);
|
|
89
|
+
|
|
90
|
+
if (!token) {
|
|
91
|
+
return { ok: false, error: "No Zalo bot token configured" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!chatId?.trim()) {
|
|
95
|
+
return { ok: false, error: "No chat_id provided" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!photoUrl?.trim()) {
|
|
99
|
+
return { ok: false, error: "No photo URL provided" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const response = await sendPhoto(token, {
|
|
104
|
+
chat_id: chatId.trim(),
|
|
105
|
+
photo: photoUrl.trim(),
|
|
106
|
+
caption: options.caption?.slice(0, 2000),
|
|
107
|
+
}, fetcher);
|
|
108
|
+
|
|
109
|
+
if (response.ok && response.result) {
|
|
110
|
+
return { ok: true, messageId: response.result.message_id };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { ok: false, error: "Failed to send photo" };
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
116
|
+
}
|
|
117
|
+
}
|