@openclaw/zalouser 2026.3.1 → 2026.3.7
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 +22 -0
- package/README.md +41 -147
- package/index.ts +3 -5
- package/package.json +5 -3
- package/src/accounts.test.ts +214 -0
- package/src/accounts.ts +24 -51
- package/src/channel.sendpayload.test.ts +117 -0
- package/src/channel.test.ts +123 -1
- package/src/channel.ts +232 -272
- package/src/config-schema.ts +2 -1
- package/src/group-policy.test.ts +49 -0
- package/src/group-policy.ts +78 -0
- package/src/message-sid.test.ts +66 -0
- package/src/message-sid.ts +80 -0
- package/src/monitor.account-scope.test.ts +113 -0
- package/src/monitor.group-gating.test.ts +211 -0
- package/src/monitor.send-mocks.ts +20 -0
- package/src/monitor.ts +321 -260
- package/src/onboarding.ts +106 -171
- package/src/probe.test.ts +60 -0
- package/src/probe.ts +20 -13
- package/src/qr-temp-file.ts +22 -0
- package/src/reaction.test.ts +19 -0
- package/src/reaction.ts +29 -0
- package/src/runtime.ts +1 -1
- package/src/send.test.ts +116 -115
- package/src/send.ts +63 -117
- package/src/status-issues.test.ts +1 -15
- package/src/status-issues.ts +8 -27
- package/src/tool.test.ts +149 -0
- package/src/tool.ts +36 -54
- package/src/types.ts +52 -42
- package/src/zalo-js.ts +1401 -0
- package/src/zca-client.ts +216 -0
- package/src/zca-js-exports.d.ts +22 -0
- package/src/zca.ts +0 -198
package/src/onboarding.ts
CHANGED
|
@@ -3,23 +3,30 @@ import type {
|
|
|
3
3
|
ChannelOnboardingDmPolicy,
|
|
4
4
|
OpenClawConfig,
|
|
5
5
|
WizardPrompter,
|
|
6
|
-
} from "openclaw/plugin-sdk";
|
|
6
|
+
} from "openclaw/plugin-sdk/zalouser";
|
|
7
7
|
import {
|
|
8
|
-
addWildcardAllowFrom,
|
|
9
8
|
DEFAULT_ACCOUNT_ID,
|
|
9
|
+
formatResolvedUnresolvedNote,
|
|
10
10
|
mergeAllowFromEntries,
|
|
11
11
|
normalizeAccountId,
|
|
12
|
-
promptAccountId,
|
|
13
12
|
promptChannelAccessConfig,
|
|
14
|
-
|
|
13
|
+
resolveAccountIdForConfigure,
|
|
14
|
+
setTopLevelChannelDmPolicyWithAllowFrom,
|
|
15
|
+
} from "openclaw/plugin-sdk/zalouser";
|
|
15
16
|
import {
|
|
16
17
|
listZalouserAccountIds,
|
|
17
18
|
resolveDefaultZalouserAccountId,
|
|
18
19
|
resolveZalouserAccountSync,
|
|
19
20
|
checkZcaAuthenticated,
|
|
20
21
|
} from "./accounts.js";
|
|
21
|
-
import
|
|
22
|
-
import {
|
|
22
|
+
import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
|
|
23
|
+
import {
|
|
24
|
+
logoutZaloProfile,
|
|
25
|
+
resolveZaloAllowFromEntries,
|
|
26
|
+
resolveZaloGroupsByEntries,
|
|
27
|
+
startZaloQrLogin,
|
|
28
|
+
waitForZaloQrLogin,
|
|
29
|
+
} from "./zalo-js.js";
|
|
23
30
|
|
|
24
31
|
const channel = "zalouser" as const;
|
|
25
32
|
|
|
@@ -66,19 +73,11 @@ function setZalouserDmPolicy(
|
|
|
66
73
|
cfg: OpenClawConfig,
|
|
67
74
|
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
|
|
68
75
|
): OpenClawConfig {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
...cfg.channels,
|
|
75
|
-
zalouser: {
|
|
76
|
-
...cfg.channels?.zalouser,
|
|
77
|
-
dmPolicy,
|
|
78
|
-
...(allowFrom ? { allowFrom } : {}),
|
|
79
|
-
},
|
|
80
|
-
},
|
|
81
|
-
} as OpenClawConfig;
|
|
76
|
+
return setTopLevelChannelDmPolicyWithAllowFrom({
|
|
77
|
+
cfg,
|
|
78
|
+
channel: "zalouser",
|
|
79
|
+
dmPolicy,
|
|
80
|
+
}) as OpenClawConfig;
|
|
82
81
|
}
|
|
83
82
|
|
|
84
83
|
async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
|
|
@@ -86,9 +85,7 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
|
|
|
86
85
|
[
|
|
87
86
|
"Zalo Personal Account login via QR code.",
|
|
88
87
|
"",
|
|
89
|
-
"
|
|
90
|
-
"1) Install zca-cli",
|
|
91
|
-
"2) You'll scan a QR code with your Zalo app",
|
|
88
|
+
"This plugin uses zca-js directly (no external CLI dependency).",
|
|
92
89
|
"",
|
|
93
90
|
"Docs: https://docs.openclaw.ai/channels/zalouser",
|
|
94
91
|
].join("\n"),
|
|
@@ -110,58 +107,40 @@ async function promptZalouserAllowFrom(params: {
|
|
|
110
107
|
.map((entry) => entry.trim())
|
|
111
108
|
.filter(Boolean);
|
|
112
109
|
|
|
113
|
-
const resolveUserId = async (input: string): Promise<string | null> => {
|
|
114
|
-
const trimmed = input.trim();
|
|
115
|
-
if (!trimmed) {
|
|
116
|
-
return null;
|
|
117
|
-
}
|
|
118
|
-
if (/^\d+$/.test(trimmed)) {
|
|
119
|
-
return trimmed;
|
|
120
|
-
}
|
|
121
|
-
const ok = await checkZcaInstalled();
|
|
122
|
-
if (!ok) {
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
const result = await runZca(["friend", "find", trimmed], {
|
|
126
|
-
profile: resolved.profile,
|
|
127
|
-
timeout: 15000,
|
|
128
|
-
});
|
|
129
|
-
if (!result.ok) {
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout);
|
|
133
|
-
const rows = Array.isArray(parsed) ? parsed : [];
|
|
134
|
-
const match = rows[0];
|
|
135
|
-
if (!match?.userId) {
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
if (rows.length > 1) {
|
|
139
|
-
await prompter.note(
|
|
140
|
-
`Multiple matches for "${trimmed}", using ${match.displayName ?? match.userId}.`,
|
|
141
|
-
"Zalo Personal allowlist",
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
return String(match.userId);
|
|
145
|
-
};
|
|
146
|
-
|
|
147
110
|
while (true) {
|
|
148
111
|
const entry = await prompter.text({
|
|
149
|
-
message: "Zalouser allowFrom (
|
|
112
|
+
message: "Zalouser allowFrom (name or user id)",
|
|
150
113
|
placeholder: "Alice, 123456789",
|
|
151
114
|
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
|
152
115
|
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
153
116
|
});
|
|
154
117
|
const parts = parseInput(String(entry));
|
|
155
|
-
const
|
|
156
|
-
|
|
118
|
+
const resolvedEntries = await resolveZaloAllowFromEntries({
|
|
119
|
+
profile: resolved.profile,
|
|
120
|
+
entries: parts,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const unresolved = resolvedEntries.filter((item) => !item.resolved).map((item) => item.input);
|
|
157
124
|
if (unresolved.length > 0) {
|
|
158
125
|
await prompter.note(
|
|
159
|
-
`Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or
|
|
126
|
+
`Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or exact friend names.`,
|
|
160
127
|
"Zalo Personal allowlist",
|
|
161
128
|
);
|
|
162
129
|
continue;
|
|
163
130
|
}
|
|
164
|
-
|
|
131
|
+
|
|
132
|
+
const resolvedIds = resolvedEntries
|
|
133
|
+
.filter((item) => item.resolved && item.id)
|
|
134
|
+
.map((item) => item.id as string);
|
|
135
|
+
const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds);
|
|
136
|
+
|
|
137
|
+
const notes = resolvedEntries
|
|
138
|
+
.filter((item) => item.note)
|
|
139
|
+
.map((item) => `${item.input} -> ${item.id} (${item.note})`);
|
|
140
|
+
if (notes.length > 0) {
|
|
141
|
+
await prompter.note(notes.join("\n"), "Zalo Personal allowlist");
|
|
142
|
+
}
|
|
143
|
+
|
|
165
144
|
return setZalouserAccountScopedConfig(cfg, accountId, {
|
|
166
145
|
dmPolicy: "allowlist",
|
|
167
146
|
allowFrom: unique,
|
|
@@ -190,49 +169,6 @@ function setZalouserGroupAllowlist(
|
|
|
190
169
|
});
|
|
191
170
|
}
|
|
192
171
|
|
|
193
|
-
async function resolveZalouserGroups(params: {
|
|
194
|
-
cfg: OpenClawConfig;
|
|
195
|
-
accountId: string;
|
|
196
|
-
entries: string[];
|
|
197
|
-
}): Promise<Array<{ input: string; resolved: boolean; id?: string }>> {
|
|
198
|
-
const account = resolveZalouserAccountSync({ cfg: params.cfg, accountId: params.accountId });
|
|
199
|
-
const result = await runZca(["group", "list", "-j"], {
|
|
200
|
-
profile: account.profile,
|
|
201
|
-
timeout: 15000,
|
|
202
|
-
});
|
|
203
|
-
if (!result.ok) {
|
|
204
|
-
throw new Error(result.stderr || "Failed to list groups");
|
|
205
|
-
}
|
|
206
|
-
const groups = (parseJsonOutput<ZcaGroup[]>(result.stdout) ?? []).filter((group) =>
|
|
207
|
-
Boolean(group.groupId),
|
|
208
|
-
);
|
|
209
|
-
const byName = new Map<string, ZcaGroup[]>();
|
|
210
|
-
for (const group of groups) {
|
|
211
|
-
const name = group.name?.trim().toLowerCase();
|
|
212
|
-
if (!name) {
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
const list = byName.get(name) ?? [];
|
|
216
|
-
list.push(group);
|
|
217
|
-
byName.set(name, list);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
return params.entries.map((input) => {
|
|
221
|
-
const trimmed = input.trim();
|
|
222
|
-
if (!trimmed) {
|
|
223
|
-
return { input, resolved: false };
|
|
224
|
-
}
|
|
225
|
-
if (/^\d+$/.test(trimmed)) {
|
|
226
|
-
return { input, resolved: true, id: trimmed };
|
|
227
|
-
}
|
|
228
|
-
const matches = byName.get(trimmed.toLowerCase()) ?? [];
|
|
229
|
-
const match = matches[0];
|
|
230
|
-
return match?.groupId
|
|
231
|
-
? { input, resolved: true, id: String(match.groupId) }
|
|
232
|
-
: { input, resolved: false };
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
|
|
236
172
|
const dmPolicy: ChannelOnboardingDmPolicy = {
|
|
237
173
|
label: "Zalo Personal",
|
|
238
174
|
channel,
|
|
@@ -246,7 +182,7 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
|
|
246
182
|
? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID)
|
|
247
183
|
: resolveDefaultZalouserAccountId(cfg);
|
|
248
184
|
return promptZalouserAllowFrom({
|
|
249
|
-
cfg
|
|
185
|
+
cfg,
|
|
250
186
|
prompter,
|
|
251
187
|
accountId: id,
|
|
252
188
|
});
|
|
@@ -260,7 +196,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
260
196
|
const ids = listZalouserAccountIds(cfg);
|
|
261
197
|
let configured = false;
|
|
262
198
|
for (const accountId of ids) {
|
|
263
|
-
const account = resolveZalouserAccountSync({ cfg
|
|
199
|
+
const account = resolveZalouserAccountSync({ cfg, accountId });
|
|
264
200
|
const isAuth = await checkZcaAuthenticated(account.profile);
|
|
265
201
|
if (isAuth) {
|
|
266
202
|
configured = true;
|
|
@@ -282,35 +218,16 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
282
218
|
shouldPromptAccountIds,
|
|
283
219
|
forceAllowFrom,
|
|
284
220
|
}) => {
|
|
285
|
-
// Check zca is installed
|
|
286
|
-
const zcaInstalled = await checkZcaInstalled();
|
|
287
|
-
if (!zcaInstalled) {
|
|
288
|
-
await prompter.note(
|
|
289
|
-
[
|
|
290
|
-
"The `zca` binary was not found in PATH.",
|
|
291
|
-
"",
|
|
292
|
-
"Install zca-cli, then re-run onboarding:",
|
|
293
|
-
"Docs: https://docs.openclaw.ai/channels/zalouser",
|
|
294
|
-
].join("\n"),
|
|
295
|
-
"Missing Dependency",
|
|
296
|
-
);
|
|
297
|
-
return { cfg, accountId: DEFAULT_ACCOUNT_ID };
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const zalouserOverride = accountOverrides.zalouser?.trim();
|
|
301
221
|
const defaultAccountId = resolveDefaultZalouserAccountId(cfg);
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
defaultAccountId,
|
|
312
|
-
});
|
|
313
|
-
}
|
|
222
|
+
const accountId = await resolveAccountIdForConfigure({
|
|
223
|
+
cfg,
|
|
224
|
+
prompter,
|
|
225
|
+
label: "Zalo Personal",
|
|
226
|
+
accountOverride: accountOverrides.zalouser,
|
|
227
|
+
shouldPromptAccountIds,
|
|
228
|
+
listAccountIds: listZalouserAccountIds,
|
|
229
|
+
defaultAccountId,
|
|
230
|
+
});
|
|
314
231
|
|
|
315
232
|
let next = cfg;
|
|
316
233
|
const account = resolveZalouserAccountSync({ cfg: next, accountId });
|
|
@@ -325,23 +242,32 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
325
242
|
});
|
|
326
243
|
|
|
327
244
|
if (wantsLogin) {
|
|
328
|
-
await
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
245
|
+
const start = await startZaloQrLogin({ profile: account.profile, timeoutMs: 35_000 });
|
|
246
|
+
if (start.qrDataUrl) {
|
|
247
|
+
const qrPath = await writeQrDataUrlToTempFile(start.qrDataUrl, account.profile);
|
|
248
|
+
await prompter.note(
|
|
249
|
+
[
|
|
250
|
+
start.message,
|
|
251
|
+
qrPath
|
|
252
|
+
? `QR image saved to: ${qrPath}`
|
|
253
|
+
: "Could not write QR image file; use gateway web login UI instead.",
|
|
254
|
+
"Scan + approve on phone, then continue.",
|
|
255
|
+
].join("\n"),
|
|
256
|
+
"QR Login",
|
|
257
|
+
);
|
|
258
|
+
const scanned = await prompter.confirm({
|
|
259
|
+
message: "Did you scan and approve the QR on your phone?",
|
|
260
|
+
initialValue: true,
|
|
261
|
+
});
|
|
262
|
+
if (scanned) {
|
|
263
|
+
const waited = await waitForZaloQrLogin({
|
|
264
|
+
profile: account.profile,
|
|
265
|
+
timeoutMs: 120_000,
|
|
266
|
+
});
|
|
267
|
+
await prompter.note(waited.message, waited.connected ? "Success" : "Login pending");
|
|
344
268
|
}
|
|
269
|
+
} else {
|
|
270
|
+
await prompter.note(start.message, "Login pending");
|
|
345
271
|
}
|
|
346
272
|
}
|
|
347
273
|
} else {
|
|
@@ -350,12 +276,26 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
350
276
|
initialValue: true,
|
|
351
277
|
});
|
|
352
278
|
if (!keepSession) {
|
|
353
|
-
await
|
|
354
|
-
await
|
|
279
|
+
await logoutZaloProfile(account.profile);
|
|
280
|
+
const start = await startZaloQrLogin({
|
|
281
|
+
profile: account.profile,
|
|
282
|
+
force: true,
|
|
283
|
+
timeoutMs: 35_000,
|
|
284
|
+
});
|
|
285
|
+
if (start.qrDataUrl) {
|
|
286
|
+
const qrPath = await writeQrDataUrlToTempFile(start.qrDataUrl, account.profile);
|
|
287
|
+
await prompter.note(
|
|
288
|
+
[start.message, qrPath ? `QR image saved to: ${qrPath}` : undefined]
|
|
289
|
+
.filter(Boolean)
|
|
290
|
+
.join("\n"),
|
|
291
|
+
"QR Login",
|
|
292
|
+
);
|
|
293
|
+
const waited = await waitForZaloQrLogin({ profile: account.profile, timeoutMs: 120_000 });
|
|
294
|
+
await prompter.note(waited.message, waited.connected ? "Success" : "Login pending");
|
|
295
|
+
}
|
|
355
296
|
}
|
|
356
297
|
}
|
|
357
298
|
|
|
358
|
-
// Enable the channel
|
|
359
299
|
next = setZalouserAccountScopedConfig(
|
|
360
300
|
next,
|
|
361
301
|
accountId,
|
|
@@ -371,14 +311,16 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
371
311
|
});
|
|
372
312
|
}
|
|
373
313
|
|
|
314
|
+
const updatedAccount = resolveZalouserAccountSync({ cfg: next, accountId });
|
|
374
315
|
const accessConfig = await promptChannelAccessConfig({
|
|
375
316
|
prompter,
|
|
376
317
|
label: "Zalo groups",
|
|
377
|
-
currentPolicy:
|
|
378
|
-
currentEntries: Object.keys(
|
|
318
|
+
currentPolicy: updatedAccount.config.groupPolicy ?? "allowlist",
|
|
319
|
+
currentEntries: Object.keys(updatedAccount.config.groups ?? {}),
|
|
379
320
|
placeholder: "Family, Work, 123456789",
|
|
380
|
-
updatePrompt: Boolean(
|
|
321
|
+
updatePrompt: Boolean(updatedAccount.config.groups),
|
|
381
322
|
});
|
|
323
|
+
|
|
382
324
|
if (accessConfig) {
|
|
383
325
|
if (accessConfig.policy !== "allowlist") {
|
|
384
326
|
next = setZalouserGroupPolicy(next, accountId, accessConfig.policy);
|
|
@@ -386,9 +328,8 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
386
328
|
let keys = accessConfig.entries;
|
|
387
329
|
if (accessConfig.entries.length > 0) {
|
|
388
330
|
try {
|
|
389
|
-
const resolved = await
|
|
390
|
-
|
|
391
|
-
accountId,
|
|
331
|
+
const resolved = await resolveZaloGroupsByEntries({
|
|
332
|
+
profile: updatedAccount.profile,
|
|
392
333
|
entries: accessConfig.entries,
|
|
393
334
|
});
|
|
394
335
|
const resolvedIds = resolved
|
|
@@ -398,18 +339,12 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
398
339
|
.filter((entry) => !entry.resolved)
|
|
399
340
|
.map((entry) => entry.input);
|
|
400
341
|
keys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)];
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
: undefined,
|
|
408
|
-
]
|
|
409
|
-
.filter(Boolean)
|
|
410
|
-
.join("\n"),
|
|
411
|
-
"Zalo groups",
|
|
412
|
-
);
|
|
342
|
+
const resolution = formatResolvedUnresolvedNote({
|
|
343
|
+
resolved: resolvedIds,
|
|
344
|
+
unresolved,
|
|
345
|
+
});
|
|
346
|
+
if (resolution) {
|
|
347
|
+
await prompter.note(resolution, "Zalo groups");
|
|
413
348
|
}
|
|
414
349
|
} catch (err) {
|
|
415
350
|
await prompter.note(
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { probeZalouser } from "./probe.js";
|
|
3
|
+
import { getZaloUserInfo } from "./zalo-js.js";
|
|
4
|
+
|
|
5
|
+
vi.mock("./zalo-js.js", () => ({
|
|
6
|
+
getZaloUserInfo: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
const mockGetUserInfo = vi.mocked(getZaloUserInfo);
|
|
10
|
+
|
|
11
|
+
describe("probeZalouser", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
mockGetUserInfo.mockReset();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.useRealTimers();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("returns ok=true with user when authenticated", async () => {
|
|
21
|
+
mockGetUserInfo.mockResolvedValueOnce({
|
|
22
|
+
userId: "123",
|
|
23
|
+
displayName: "Alice",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
await expect(probeZalouser("default")).resolves.toEqual({
|
|
27
|
+
ok: true,
|
|
28
|
+
user: { userId: "123", displayName: "Alice" },
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns not authenticated when no user info is returned", async () => {
|
|
33
|
+
mockGetUserInfo.mockResolvedValueOnce(null);
|
|
34
|
+
await expect(probeZalouser("default")).resolves.toEqual({
|
|
35
|
+
ok: false,
|
|
36
|
+
error: "Not authenticated",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns error when user lookup throws", async () => {
|
|
41
|
+
mockGetUserInfo.mockRejectedValueOnce(new Error("network down"));
|
|
42
|
+
await expect(probeZalouser("default")).resolves.toEqual({
|
|
43
|
+
ok: false,
|
|
44
|
+
error: "network down",
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("times out when lookup takes too long", async () => {
|
|
49
|
+
vi.useFakeTimers();
|
|
50
|
+
mockGetUserInfo.mockReturnValueOnce(new Promise(() => undefined));
|
|
51
|
+
|
|
52
|
+
const pending = probeZalouser("default", 10);
|
|
53
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
54
|
+
|
|
55
|
+
await expect(pending).resolves.toEqual({
|
|
56
|
+
ok: false,
|
|
57
|
+
error: "Not authenticated",
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
package/src/probe.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { BaseProbeResult } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { BaseProbeResult } from "openclaw/plugin-sdk/zalouser";
|
|
2
2
|
import type { ZcaUserInfo } from "./types.js";
|
|
3
|
-
import {
|
|
3
|
+
import { getZaloUserInfo } from "./zalo-js.js";
|
|
4
4
|
|
|
5
5
|
export type ZalouserProbeResult = BaseProbeResult<string> & {
|
|
6
6
|
user?: ZcaUserInfo;
|
|
@@ -10,18 +10,25 @@ export async function probeZalouser(
|
|
|
10
10
|
profile: string,
|
|
11
11
|
timeoutMs?: number,
|
|
12
12
|
): Promise<ZalouserProbeResult> {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
try {
|
|
14
|
+
const user = timeoutMs
|
|
15
|
+
? await Promise.race([
|
|
16
|
+
getZaloUserInfo(profile),
|
|
17
|
+
new Promise<null>((resolve) =>
|
|
18
|
+
setTimeout(() => resolve(null), Math.max(timeoutMs, 1000)),
|
|
19
|
+
),
|
|
20
|
+
])
|
|
21
|
+
: await getZaloUserInfo(profile);
|
|
17
22
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
if (!user) {
|
|
24
|
+
return { ok: false, error: "Not authenticated" };
|
|
25
|
+
}
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return {
|
|
27
|
+
return { ok: true, user };
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
error: error instanceof Error ? error.message : String(error),
|
|
32
|
+
};
|
|
25
33
|
}
|
|
26
|
-
return { ok: true, user };
|
|
27
34
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/zalouser";
|
|
4
|
+
|
|
5
|
+
export async function writeQrDataUrlToTempFile(
|
|
6
|
+
qrDataUrl: string,
|
|
7
|
+
profile: string,
|
|
8
|
+
): Promise<string | null> {
|
|
9
|
+
const trimmed = qrDataUrl.trim();
|
|
10
|
+
const match = trimmed.match(/^data:image\/png;base64,(.+)$/i);
|
|
11
|
+
const base64 = (match?.[1] ?? "").trim();
|
|
12
|
+
if (!base64) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default";
|
|
16
|
+
const filePath = path.join(
|
|
17
|
+
resolvePreferredOpenClawTmpDir(),
|
|
18
|
+
`openclaw-zalouser-qr-${safeProfile}.png`,
|
|
19
|
+
);
|
|
20
|
+
await fsp.writeFile(filePath, Buffer.from(base64, "base64"));
|
|
21
|
+
return filePath;
|
|
22
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { normalizeZaloReactionIcon } from "./reaction.js";
|
|
3
|
+
|
|
4
|
+
describe("zalouser reaction alias normalization", () => {
|
|
5
|
+
it("maps common aliases", () => {
|
|
6
|
+
expect(normalizeZaloReactionIcon("like")).toBe("/-strong");
|
|
7
|
+
expect(normalizeZaloReactionIcon("👍")).toBe("/-strong");
|
|
8
|
+
expect(normalizeZaloReactionIcon("heart")).toBe("/-heart");
|
|
9
|
+
expect(normalizeZaloReactionIcon("😂")).toBe(":>");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("defaults empty icon to like", () => {
|
|
13
|
+
expect(normalizeZaloReactionIcon("")).toBe("/-strong");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("passes through unknown custom reactions", () => {
|
|
17
|
+
expect(normalizeZaloReactionIcon("/custom")).toBe("/custom");
|
|
18
|
+
});
|
|
19
|
+
});
|
package/src/reaction.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Reactions } from "./zca-client.js";
|
|
2
|
+
|
|
3
|
+
const REACTION_ALIAS_MAP = new Map<string, string>([
|
|
4
|
+
["like", Reactions.LIKE],
|
|
5
|
+
["👍", Reactions.LIKE],
|
|
6
|
+
[":+1:", Reactions.LIKE],
|
|
7
|
+
["heart", Reactions.HEART],
|
|
8
|
+
["❤️", Reactions.HEART],
|
|
9
|
+
["<3", Reactions.HEART],
|
|
10
|
+
["haha", Reactions.HAHA],
|
|
11
|
+
["laugh", Reactions.HAHA],
|
|
12
|
+
["😂", Reactions.HAHA],
|
|
13
|
+
["wow", Reactions.WOW],
|
|
14
|
+
["😮", Reactions.WOW],
|
|
15
|
+
["cry", Reactions.CRY],
|
|
16
|
+
["😢", Reactions.CRY],
|
|
17
|
+
["angry", Reactions.ANGRY],
|
|
18
|
+
["😡", Reactions.ANGRY],
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export function normalizeZaloReactionIcon(raw: string): string {
|
|
22
|
+
const trimmed = raw.trim();
|
|
23
|
+
if (!trimmed) {
|
|
24
|
+
return Reactions.LIKE;
|
|
25
|
+
}
|
|
26
|
+
return (
|
|
27
|
+
REACTION_ALIAS_MAP.get(trimmed.toLowerCase()) ?? REACTION_ALIAS_MAP.get(trimmed) ?? trimmed
|
|
28
|
+
);
|
|
29
|
+
}
|
package/src/runtime.ts
CHANGED