@clawdbot/zalouser 2026.1.16
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 +221 -0
- package/index.ts +28 -0
- package/package.json +14 -0
- package/src/accounts.ts +121 -0
- package/src/channel.test.ts +18 -0
- package/src/channel.ts +523 -0
- package/src/core-bridge.ts +171 -0
- package/src/monitor.ts +372 -0
- package/src/onboarding.ts +312 -0
- package/src/send.ts +150 -0
- package/src/tool.ts +156 -0
- package/src/types.ts +109 -0
- package/src/zca.ts +183 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js";
|
|
2
|
+
import type {
|
|
3
|
+
ChannelAccountSnapshot,
|
|
4
|
+
ChannelDirectoryEntry,
|
|
5
|
+
} from "../../../src/channels/plugins/types.core.js";
|
|
6
|
+
|
|
7
|
+
import { formatPairingApproveHint } from "../../../src/channels/plugins/helpers.js";
|
|
8
|
+
import {
|
|
9
|
+
listZalouserAccountIds,
|
|
10
|
+
resolveDefaultZalouserAccountId,
|
|
11
|
+
resolveZalouserAccountSync,
|
|
12
|
+
getZcaUserInfo,
|
|
13
|
+
checkZcaAuthenticated,
|
|
14
|
+
type ResolvedZalouserAccount,
|
|
15
|
+
} from "./accounts.js";
|
|
16
|
+
import { zalouserOnboardingAdapter } from "./onboarding.js";
|
|
17
|
+
import { sendMessageZalouser } from "./send.js";
|
|
18
|
+
import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js";
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_ACCOUNT_ID,
|
|
21
|
+
type CoreConfig,
|
|
22
|
+
type ZalouserConfig,
|
|
23
|
+
type ZcaFriend,
|
|
24
|
+
type ZcaGroup,
|
|
25
|
+
type ZcaUserInfo,
|
|
26
|
+
} from "./types.js";
|
|
27
|
+
|
|
28
|
+
const meta = {
|
|
29
|
+
id: "zalouser",
|
|
30
|
+
label: "Zalo Personal",
|
|
31
|
+
selectionLabel: "Zalo (Personal Account)",
|
|
32
|
+
docsPath: "/channels/zalouser",
|
|
33
|
+
docsLabel: "zalouser",
|
|
34
|
+
blurb: "Zalo personal account via QR code login.",
|
|
35
|
+
aliases: ["zlu"],
|
|
36
|
+
order: 85,
|
|
37
|
+
quickstartAllowFrom: true,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function resolveZalouserQrProfile(accountId?: string | null): string {
|
|
41
|
+
const normalized = String(accountId ?? "").trim();
|
|
42
|
+
if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
|
|
43
|
+
return process.env.ZCA_PROFILE?.trim() || "default";
|
|
44
|
+
}
|
|
45
|
+
return normalized;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function mapUser(params: {
|
|
49
|
+
id: string;
|
|
50
|
+
name?: string | null;
|
|
51
|
+
avatarUrl?: string | null;
|
|
52
|
+
raw?: unknown;
|
|
53
|
+
}): ChannelDirectoryEntry {
|
|
54
|
+
return {
|
|
55
|
+
kind: "user",
|
|
56
|
+
id: params.id,
|
|
57
|
+
name: params.name ?? undefined,
|
|
58
|
+
avatarUrl: params.avatarUrl ?? undefined,
|
|
59
|
+
raw: params.raw,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mapGroup(params: {
|
|
64
|
+
id: string;
|
|
65
|
+
name?: string | null;
|
|
66
|
+
raw?: unknown;
|
|
67
|
+
}): ChannelDirectoryEntry {
|
|
68
|
+
return {
|
|
69
|
+
kind: "group",
|
|
70
|
+
id: params.id,
|
|
71
|
+
name: params.name ?? undefined,
|
|
72
|
+
raw: params.raw,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function deleteAccountFromConfigSection(params: {
|
|
77
|
+
cfg: CoreConfig;
|
|
78
|
+
accountId: string;
|
|
79
|
+
}): CoreConfig {
|
|
80
|
+
const { cfg, accountId } = params;
|
|
81
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
82
|
+
const { zalouser: _removed, ...restChannels } = cfg.channels ?? {};
|
|
83
|
+
return { ...cfg, channels: restChannels };
|
|
84
|
+
}
|
|
85
|
+
const accounts = { ...(cfg.channels?.zalouser?.accounts ?? {}) };
|
|
86
|
+
delete accounts[accountId];
|
|
87
|
+
return {
|
|
88
|
+
...cfg,
|
|
89
|
+
channels: {
|
|
90
|
+
...cfg.channels,
|
|
91
|
+
zalouser: {
|
|
92
|
+
...cfg.channels?.zalouser,
|
|
93
|
+
accounts,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function setAccountEnabledInConfigSection(params: {
|
|
100
|
+
cfg: CoreConfig;
|
|
101
|
+
accountId: string;
|
|
102
|
+
enabled: boolean;
|
|
103
|
+
}): CoreConfig {
|
|
104
|
+
const { cfg, accountId, enabled } = params;
|
|
105
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
106
|
+
return {
|
|
107
|
+
...cfg,
|
|
108
|
+
channels: {
|
|
109
|
+
...cfg.channels,
|
|
110
|
+
zalouser: {
|
|
111
|
+
...cfg.channels?.zalouser,
|
|
112
|
+
enabled,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
...cfg,
|
|
119
|
+
channels: {
|
|
120
|
+
...cfg.channels,
|
|
121
|
+
zalouser: {
|
|
122
|
+
...cfg.channels?.zalouser,
|
|
123
|
+
accounts: {
|
|
124
|
+
...(cfg.channels?.zalouser?.accounts ?? {}),
|
|
125
|
+
[accountId]: {
|
|
126
|
+
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
|
|
127
|
+
enabled,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|
136
|
+
id: "zalouser",
|
|
137
|
+
meta,
|
|
138
|
+
onboarding: zalouserOnboardingAdapter,
|
|
139
|
+
capabilities: {
|
|
140
|
+
chatTypes: ["direct", "group"],
|
|
141
|
+
media: true,
|
|
142
|
+
reactions: true,
|
|
143
|
+
threads: false,
|
|
144
|
+
polls: false,
|
|
145
|
+
nativeCommands: false,
|
|
146
|
+
blockStreaming: true,
|
|
147
|
+
},
|
|
148
|
+
reload: { configPrefixes: ["channels.zalouser"] },
|
|
149
|
+
config: {
|
|
150
|
+
listAccountIds: (cfg) => listZalouserAccountIds(cfg as CoreConfig),
|
|
151
|
+
resolveAccount: (cfg, accountId) =>
|
|
152
|
+
resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId }),
|
|
153
|
+
defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg as CoreConfig),
|
|
154
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
155
|
+
setAccountEnabledInConfigSection({
|
|
156
|
+
cfg: cfg as CoreConfig,
|
|
157
|
+
accountId,
|
|
158
|
+
enabled,
|
|
159
|
+
}),
|
|
160
|
+
deleteAccount: ({ cfg, accountId }) =>
|
|
161
|
+
deleteAccountFromConfigSection({
|
|
162
|
+
cfg: cfg as CoreConfig,
|
|
163
|
+
accountId,
|
|
164
|
+
}),
|
|
165
|
+
isConfigured: async (account) => {
|
|
166
|
+
// Check if zca auth status is OK for this profile
|
|
167
|
+
const result = await runZca(["auth", "status"], {
|
|
168
|
+
profile: account.profile,
|
|
169
|
+
timeout: 5000,
|
|
170
|
+
});
|
|
171
|
+
return result.ok;
|
|
172
|
+
},
|
|
173
|
+
describeAccount: (account): ChannelAccountSnapshot => ({
|
|
174
|
+
accountId: account.accountId,
|
|
175
|
+
name: account.name,
|
|
176
|
+
enabled: account.enabled,
|
|
177
|
+
configured: undefined,
|
|
178
|
+
}),
|
|
179
|
+
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
180
|
+
(resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
|
|
181
|
+
(entry) => String(entry),
|
|
182
|
+
),
|
|
183
|
+
formatAllowFrom: ({ allowFrom }) =>
|
|
184
|
+
allowFrom
|
|
185
|
+
.map((entry) => String(entry).trim())
|
|
186
|
+
.filter(Boolean)
|
|
187
|
+
.map((entry) => entry.replace(/^(zalouser|zlu):/i, ""))
|
|
188
|
+
.map((entry) => entry.toLowerCase()),
|
|
189
|
+
},
|
|
190
|
+
security: {
|
|
191
|
+
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
192
|
+
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
193
|
+
const useAccountPath = Boolean(
|
|
194
|
+
(cfg as CoreConfig).channels?.zalouser?.accounts?.[resolvedAccountId],
|
|
195
|
+
);
|
|
196
|
+
const basePath = useAccountPath
|
|
197
|
+
? `channels.zalouser.accounts.${resolvedAccountId}.`
|
|
198
|
+
: "channels.zalouser.";
|
|
199
|
+
return {
|
|
200
|
+
policy: account.config.dmPolicy ?? "pairing",
|
|
201
|
+
allowFrom: account.config.allowFrom ?? [],
|
|
202
|
+
policyPath: `${basePath}dmPolicy`,
|
|
203
|
+
allowFromPath: basePath,
|
|
204
|
+
approveHint: formatPairingApproveHint("zalouser"),
|
|
205
|
+
normalizeEntry: (raw) => raw.replace(/^(zalouser|zlu):/i, ""),
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
groups: {
|
|
210
|
+
resolveRequireMention: () => true,
|
|
211
|
+
},
|
|
212
|
+
threading: {
|
|
213
|
+
resolveReplyToMode: () => "off",
|
|
214
|
+
},
|
|
215
|
+
messaging: {
|
|
216
|
+
normalizeTarget: (raw) => {
|
|
217
|
+
const trimmed = raw?.trim();
|
|
218
|
+
if (!trimmed) return undefined;
|
|
219
|
+
return trimmed.replace(/^(zalouser|zlu):/i, "");
|
|
220
|
+
},
|
|
221
|
+
targetResolver: {
|
|
222
|
+
looksLikeId: (raw) => {
|
|
223
|
+
const trimmed = raw.trim();
|
|
224
|
+
if (!trimmed) return false;
|
|
225
|
+
return /^\d{3,}$/.test(trimmed);
|
|
226
|
+
},
|
|
227
|
+
hint: "<threadId>",
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
directory: {
|
|
231
|
+
self: async ({ cfg, accountId, runtime }) => {
|
|
232
|
+
const ok = await checkZcaInstalled();
|
|
233
|
+
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
|
234
|
+
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
|
235
|
+
const result = await runZca(["me", "info", "-j"], { profile: account.profile, timeout: 10000 });
|
|
236
|
+
if (!result.ok) {
|
|
237
|
+
runtime.error(result.stderr || "Failed to fetch profile");
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
const parsed = parseJsonOutput<ZcaUserInfo>(result.stdout);
|
|
241
|
+
if (!parsed?.userId) return null;
|
|
242
|
+
return mapUser({
|
|
243
|
+
id: String(parsed.userId),
|
|
244
|
+
name: parsed.displayName ?? null,
|
|
245
|
+
avatarUrl: parsed.avatar ?? null,
|
|
246
|
+
raw: parsed,
|
|
247
|
+
});
|
|
248
|
+
},
|
|
249
|
+
listPeers: async ({ cfg, accountId, query, limit }) => {
|
|
250
|
+
const ok = await checkZcaInstalled();
|
|
251
|
+
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
|
252
|
+
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
|
253
|
+
const args = query?.trim()
|
|
254
|
+
? ["friend", "find", query.trim()]
|
|
255
|
+
: ["friend", "list", "-j"];
|
|
256
|
+
const result = await runZca(args, { profile: account.profile, timeout: 15000 });
|
|
257
|
+
if (!result.ok) {
|
|
258
|
+
throw new Error(result.stderr || "Failed to list peers");
|
|
259
|
+
}
|
|
260
|
+
const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout);
|
|
261
|
+
const rows = Array.isArray(parsed)
|
|
262
|
+
? parsed.map((f) =>
|
|
263
|
+
mapUser({
|
|
264
|
+
id: String(f.userId),
|
|
265
|
+
name: f.displayName ?? null,
|
|
266
|
+
avatarUrl: f.avatar ?? null,
|
|
267
|
+
raw: f,
|
|
268
|
+
}),
|
|
269
|
+
)
|
|
270
|
+
: [];
|
|
271
|
+
return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
|
|
272
|
+
},
|
|
273
|
+
listGroups: async ({ cfg, accountId, query, limit }) => {
|
|
274
|
+
const ok = await checkZcaInstalled();
|
|
275
|
+
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
|
276
|
+
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
|
277
|
+
const result = await runZca(["group", "list", "-j"], { profile: account.profile, timeout: 15000 });
|
|
278
|
+
if (!result.ok) {
|
|
279
|
+
throw new Error(result.stderr || "Failed to list groups");
|
|
280
|
+
}
|
|
281
|
+
const parsed = parseJsonOutput<ZcaGroup[]>(result.stdout);
|
|
282
|
+
let rows = Array.isArray(parsed)
|
|
283
|
+
? parsed.map((g) =>
|
|
284
|
+
mapGroup({
|
|
285
|
+
id: String(g.groupId),
|
|
286
|
+
name: g.name ?? null,
|
|
287
|
+
raw: g,
|
|
288
|
+
}),
|
|
289
|
+
)
|
|
290
|
+
: [];
|
|
291
|
+
const q = query?.trim().toLowerCase();
|
|
292
|
+
if (q) {
|
|
293
|
+
rows = rows.filter((g) => (g.name ?? "").toLowerCase().includes(q) || g.id.includes(q));
|
|
294
|
+
}
|
|
295
|
+
return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
|
|
296
|
+
},
|
|
297
|
+
listGroupMembers: async ({ cfg, accountId, groupId, limit }) => {
|
|
298
|
+
const ok = await checkZcaInstalled();
|
|
299
|
+
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
|
300
|
+
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
|
301
|
+
const result = await runZca(["group", "members", groupId, "-j"], {
|
|
302
|
+
profile: account.profile,
|
|
303
|
+
timeout: 20000,
|
|
304
|
+
});
|
|
305
|
+
if (!result.ok) {
|
|
306
|
+
throw new Error(result.stderr || "Failed to list group members");
|
|
307
|
+
}
|
|
308
|
+
const parsed = parseJsonOutput<Array<Partial<ZcaFriend> & { userId?: string | number }>>(result.stdout);
|
|
309
|
+
const rows = Array.isArray(parsed)
|
|
310
|
+
? parsed
|
|
311
|
+
.map((m) => {
|
|
312
|
+
const id = m.userId ?? (m as { id?: string | number }).id;
|
|
313
|
+
if (!id) return null;
|
|
314
|
+
return mapUser({
|
|
315
|
+
id: String(id),
|
|
316
|
+
name: (m as { displayName?: string }).displayName ?? null,
|
|
317
|
+
avatarUrl: (m as { avatar?: string }).avatar ?? null,
|
|
318
|
+
raw: m,
|
|
319
|
+
});
|
|
320
|
+
})
|
|
321
|
+
.filter(Boolean)
|
|
322
|
+
: [];
|
|
323
|
+
const sliced = typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
|
|
324
|
+
return sliced as ChannelDirectoryEntry[];
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
pairing: {
|
|
328
|
+
idLabel: "zalouserUserId",
|
|
329
|
+
normalizeAllowEntry: (entry) => entry.replace(/^(zalouser|zlu):/i, ""),
|
|
330
|
+
notifyApproval: async ({ cfg, id }) => {
|
|
331
|
+
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig });
|
|
332
|
+
const authenticated = await checkZcaAuthenticated(account.profile);
|
|
333
|
+
if (!authenticated) throw new Error("Zalouser not authenticated");
|
|
334
|
+
await sendMessageZalouser(id, "Your pairing request has been approved.", {
|
|
335
|
+
profile: account.profile,
|
|
336
|
+
});
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
auth: {
|
|
340
|
+
login: async ({ cfg, accountId, runtime }) => {
|
|
341
|
+
const account = resolveZalouserAccountSync({
|
|
342
|
+
cfg: cfg as CoreConfig,
|
|
343
|
+
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
|
344
|
+
});
|
|
345
|
+
const ok = await checkZcaInstalled();
|
|
346
|
+
if (!ok) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
"Missing dependency: `zca` not found in PATH. See docs.clawd.bot/channels/zalouser",
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
runtime.log(
|
|
352
|
+
`Scan the QR code in this terminal to link Zalo Personal (account: ${account.accountId}, profile: ${account.profile}).`,
|
|
353
|
+
);
|
|
354
|
+
const result = await runZcaInteractive(["auth", "login"], { profile: account.profile });
|
|
355
|
+
if (!result.ok) {
|
|
356
|
+
throw new Error(result.stderr || "Zalouser login failed");
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
outbound: {
|
|
361
|
+
deliveryMode: "direct",
|
|
362
|
+
chunker: (text, limit) => {
|
|
363
|
+
if (!text) return [];
|
|
364
|
+
if (limit <= 0 || text.length <= limit) return [text];
|
|
365
|
+
const chunks: string[] = [];
|
|
366
|
+
let remaining = text;
|
|
367
|
+
while (remaining.length > limit) {
|
|
368
|
+
const window = remaining.slice(0, limit);
|
|
369
|
+
const lastNewline = window.lastIndexOf("\n");
|
|
370
|
+
const lastSpace = window.lastIndexOf(" ");
|
|
371
|
+
let breakIdx = lastNewline > 0 ? lastNewline : lastSpace;
|
|
372
|
+
if (breakIdx <= 0) breakIdx = limit;
|
|
373
|
+
const rawChunk = remaining.slice(0, breakIdx);
|
|
374
|
+
const chunk = rawChunk.trimEnd();
|
|
375
|
+
if (chunk.length > 0) chunks.push(chunk);
|
|
376
|
+
const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
|
|
377
|
+
const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
|
|
378
|
+
remaining = remaining.slice(nextStart).trimStart();
|
|
379
|
+
}
|
|
380
|
+
if (remaining.length) chunks.push(remaining);
|
|
381
|
+
return chunks;
|
|
382
|
+
},
|
|
383
|
+
textChunkLimit: 2000,
|
|
384
|
+
sendText: async ({ to, text, accountId, cfg }) => {
|
|
385
|
+
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
|
386
|
+
const result = await sendMessageZalouser(to, text, { profile: account.profile });
|
|
387
|
+
return {
|
|
388
|
+
channel: "zalouser",
|
|
389
|
+
ok: result.ok,
|
|
390
|
+
messageId: result.messageId ?? "",
|
|
391
|
+
error: result.error ? new Error(result.error) : undefined,
|
|
392
|
+
};
|
|
393
|
+
},
|
|
394
|
+
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
|
|
395
|
+
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
|
396
|
+
const result = await sendMessageZalouser(to, text, {
|
|
397
|
+
profile: account.profile,
|
|
398
|
+
mediaUrl,
|
|
399
|
+
});
|
|
400
|
+
return {
|
|
401
|
+
channel: "zalouser",
|
|
402
|
+
ok: result.ok,
|
|
403
|
+
messageId: result.messageId ?? "",
|
|
404
|
+
error: result.error ? new Error(result.error) : undefined,
|
|
405
|
+
};
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
status: {
|
|
409
|
+
defaultRuntime: {
|
|
410
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
411
|
+
running: false,
|
|
412
|
+
lastStartAt: null,
|
|
413
|
+
lastStopAt: null,
|
|
414
|
+
lastError: null,
|
|
415
|
+
},
|
|
416
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
417
|
+
configured: snapshot.configured ?? false,
|
|
418
|
+
running: snapshot.running ?? false,
|
|
419
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
420
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
421
|
+
lastError: snapshot.lastError ?? null,
|
|
422
|
+
probe: snapshot.probe,
|
|
423
|
+
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
424
|
+
}),
|
|
425
|
+
probeAccount: async ({ account, timeoutMs }) => {
|
|
426
|
+
const result = await runZca(["me", "info", "-j"], {
|
|
427
|
+
profile: account.profile,
|
|
428
|
+
timeout: timeoutMs,
|
|
429
|
+
});
|
|
430
|
+
if (!result.ok) {
|
|
431
|
+
return { ok: false, error: result.stderr };
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
return { ok: true, user: JSON.parse(result.stdout) };
|
|
435
|
+
} catch {
|
|
436
|
+
return { ok: false, error: "Failed to parse user info" };
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
buildAccountSnapshot: async ({ account, runtime }) => {
|
|
440
|
+
const configured = await checkZcaAuthenticated(account.profile);
|
|
441
|
+
return {
|
|
442
|
+
accountId: account.accountId,
|
|
443
|
+
name: account.name,
|
|
444
|
+
enabled: account.enabled,
|
|
445
|
+
configured,
|
|
446
|
+
running: runtime?.running ?? false,
|
|
447
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
448
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
449
|
+
lastError: configured ? (runtime?.lastError ?? null) : "not configured",
|
|
450
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
451
|
+
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
452
|
+
dmPolicy: account.config.dmPolicy ?? "pairing",
|
|
453
|
+
};
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
gateway: {
|
|
457
|
+
startAccount: async (ctx) => {
|
|
458
|
+
const account = ctx.account;
|
|
459
|
+
let userLabel = "";
|
|
460
|
+
try {
|
|
461
|
+
const userInfo = await getZcaUserInfo(account.profile);
|
|
462
|
+
if (userInfo?.displayName) userLabel = ` (${userInfo.displayName})`;
|
|
463
|
+
ctx.setStatus({
|
|
464
|
+
accountId: account.accountId,
|
|
465
|
+
user: userInfo,
|
|
466
|
+
});
|
|
467
|
+
} catch {
|
|
468
|
+
// ignore probe errors
|
|
469
|
+
}
|
|
470
|
+
ctx.log?.info(`[${account.accountId}] starting zalouser provider${userLabel}`);
|
|
471
|
+
const { monitorZalouserProvider } = await import("./monitor.js");
|
|
472
|
+
return monitorZalouserProvider({
|
|
473
|
+
account,
|
|
474
|
+
config: ctx.cfg as CoreConfig,
|
|
475
|
+
runtime: ctx.runtime,
|
|
476
|
+
abortSignal: ctx.abortSignal,
|
|
477
|
+
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
|
478
|
+
});
|
|
479
|
+
},
|
|
480
|
+
loginWithQrStart: async (params) => {
|
|
481
|
+
const profile = resolveZalouserQrProfile(params.accountId);
|
|
482
|
+
// Start login and get QR code
|
|
483
|
+
const result = await runZca(["auth", "login", "--qr-base64"], {
|
|
484
|
+
profile,
|
|
485
|
+
timeout: params.timeoutMs ?? 30000,
|
|
486
|
+
});
|
|
487
|
+
if (!result.ok) {
|
|
488
|
+
return { message: result.stderr || "Failed to start QR login" };
|
|
489
|
+
}
|
|
490
|
+
// The stdout should contain the base64 QR data URL
|
|
491
|
+
const qrMatch = result.stdout.match(/data:image\/png;base64,[A-Za-z0-9+/=]+/);
|
|
492
|
+
if (qrMatch) {
|
|
493
|
+
return { qrDataUrl: qrMatch[0], message: "Scan QR code with Zalo app" };
|
|
494
|
+
}
|
|
495
|
+
return { message: result.stdout || "QR login started" };
|
|
496
|
+
},
|
|
497
|
+
loginWithQrWait: async (params) => {
|
|
498
|
+
const profile = resolveZalouserQrProfile(params.accountId);
|
|
499
|
+
// Check if already authenticated
|
|
500
|
+
const statusResult = await runZca(["auth", "status"], {
|
|
501
|
+
profile,
|
|
502
|
+
timeout: params.timeoutMs ?? 60000,
|
|
503
|
+
});
|
|
504
|
+
return {
|
|
505
|
+
connected: statusResult.ok,
|
|
506
|
+
message: statusResult.ok ? "Login successful" : statusResult.stderr || "Login pending",
|
|
507
|
+
};
|
|
508
|
+
},
|
|
509
|
+
logoutAccount: async (ctx) => {
|
|
510
|
+
const result = await runZca(["auth", "logout"], {
|
|
511
|
+
profile: ctx.account.profile,
|
|
512
|
+
timeout: 10000,
|
|
513
|
+
});
|
|
514
|
+
return {
|
|
515
|
+
cleared: result.ok,
|
|
516
|
+
loggedOut: result.ok,
|
|
517
|
+
message: result.ok ? "Logged out" : result.stderr,
|
|
518
|
+
};
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
export type { ResolvedZalouserAccount };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
export type CoreChannelDeps = {
|
|
6
|
+
chunkMarkdownText: (text: string, limit: number) => string[];
|
|
7
|
+
formatAgentEnvelope: (params: {
|
|
8
|
+
channel: string;
|
|
9
|
+
from: string;
|
|
10
|
+
timestamp?: number;
|
|
11
|
+
body: string;
|
|
12
|
+
}) => string;
|
|
13
|
+
dispatchReplyWithBufferedBlockDispatcher: (params: {
|
|
14
|
+
ctx: unknown;
|
|
15
|
+
cfg: unknown;
|
|
16
|
+
dispatcherOptions: {
|
|
17
|
+
deliver: (payload: unknown) => Promise<void>;
|
|
18
|
+
onError?: (err: unknown, info: { kind: string }) => void;
|
|
19
|
+
};
|
|
20
|
+
}) => Promise<void>;
|
|
21
|
+
resolveAgentRoute: (params: {
|
|
22
|
+
cfg: unknown;
|
|
23
|
+
channel: string;
|
|
24
|
+
accountId: string;
|
|
25
|
+
peer: { kind: "dm" | "group" | "channel"; id: string };
|
|
26
|
+
}) => { sessionKey: string; accountId: string };
|
|
27
|
+
buildPairingReply: (params: { channel: string; idLine: string; code: string }) => string;
|
|
28
|
+
readChannelAllowFromStore: (channel: string) => Promise<string[]>;
|
|
29
|
+
upsertChannelPairingRequest: (params: {
|
|
30
|
+
channel: string;
|
|
31
|
+
id: string;
|
|
32
|
+
meta?: { name?: string };
|
|
33
|
+
}) => Promise<{ code: string; created: boolean }>;
|
|
34
|
+
fetchRemoteMedia: (params: { url: string }) => Promise<{ buffer: Buffer; contentType?: string }>;
|
|
35
|
+
saveMediaBuffer: (
|
|
36
|
+
buffer: Buffer,
|
|
37
|
+
contentType: string | undefined,
|
|
38
|
+
type: "inbound" | "outbound",
|
|
39
|
+
maxBytes: number,
|
|
40
|
+
) => Promise<{ path: string; contentType: string }>;
|
|
41
|
+
shouldLogVerbose: () => boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
let coreRootCache: string | null = null;
|
|
45
|
+
let coreDepsPromise: Promise<CoreChannelDeps> | null = null;
|
|
46
|
+
|
|
47
|
+
function findPackageRoot(startDir: string, name: string): string | null {
|
|
48
|
+
let dir = startDir;
|
|
49
|
+
for (;;) {
|
|
50
|
+
const pkgPath = path.join(dir, "package.json");
|
|
51
|
+
try {
|
|
52
|
+
if (fs.existsSync(pkgPath)) {
|
|
53
|
+
const raw = fs.readFileSync(pkgPath, "utf8");
|
|
54
|
+
const pkg = JSON.parse(raw) as { name?: string };
|
|
55
|
+
if (pkg.name === name) return dir;
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// ignore parse errors
|
|
59
|
+
}
|
|
60
|
+
const parent = path.dirname(dir);
|
|
61
|
+
if (parent === dir) return null;
|
|
62
|
+
dir = parent;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveClawdbotRoot(): string {
|
|
67
|
+
if (coreRootCache) return coreRootCache;
|
|
68
|
+
const override = process.env.CLAWDBOT_ROOT?.trim();
|
|
69
|
+
if (override) {
|
|
70
|
+
coreRootCache = override;
|
|
71
|
+
return override;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const candidates = new Set<string>();
|
|
75
|
+
if (process.argv[1]) {
|
|
76
|
+
candidates.add(path.dirname(process.argv[1]));
|
|
77
|
+
}
|
|
78
|
+
candidates.add(process.cwd());
|
|
79
|
+
try {
|
|
80
|
+
const urlPath = fileURLToPath(import.meta.url);
|
|
81
|
+
candidates.add(path.dirname(urlPath));
|
|
82
|
+
} catch {
|
|
83
|
+
// ignore
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const start of candidates) {
|
|
87
|
+
const found = findPackageRoot(start, "clawdbot");
|
|
88
|
+
if (found) {
|
|
89
|
+
coreRootCache = found;
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new Error(
|
|
95
|
+
"Unable to resolve Clawdbot root. Set CLAWDBOT_ROOT to the package root.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function importCoreModule<T>(relativePath: string): Promise<T> {
|
|
100
|
+
const root = resolveClawdbotRoot();
|
|
101
|
+
const distPath = path.join(root, "dist", relativePath);
|
|
102
|
+
if (!fs.existsSync(distPath)) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return (await import(pathToFileURL(distPath).href)) as T;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function loadCoreChannelDeps(): Promise<CoreChannelDeps> {
|
|
111
|
+
if (coreDepsPromise) return coreDepsPromise;
|
|
112
|
+
|
|
113
|
+
coreDepsPromise = (async () => {
|
|
114
|
+
const [
|
|
115
|
+
chunk,
|
|
116
|
+
envelope,
|
|
117
|
+
dispatcher,
|
|
118
|
+
routing,
|
|
119
|
+
pairingMessages,
|
|
120
|
+
pairingStore,
|
|
121
|
+
mediaFetch,
|
|
122
|
+
mediaStore,
|
|
123
|
+
globals,
|
|
124
|
+
] = await Promise.all([
|
|
125
|
+
importCoreModule<{ chunkMarkdownText: CoreChannelDeps["chunkMarkdownText"] }>(
|
|
126
|
+
"auto-reply/chunk.js",
|
|
127
|
+
),
|
|
128
|
+
importCoreModule<{ formatAgentEnvelope: CoreChannelDeps["formatAgentEnvelope"] }>(
|
|
129
|
+
"auto-reply/envelope.js",
|
|
130
|
+
),
|
|
131
|
+
importCoreModule<{
|
|
132
|
+
dispatchReplyWithBufferedBlockDispatcher: CoreChannelDeps["dispatchReplyWithBufferedBlockDispatcher"];
|
|
133
|
+
}>("auto-reply/reply/provider-dispatcher.js"),
|
|
134
|
+
importCoreModule<{ resolveAgentRoute: CoreChannelDeps["resolveAgentRoute"] }>(
|
|
135
|
+
"routing/resolve-route.js",
|
|
136
|
+
),
|
|
137
|
+
importCoreModule<{ buildPairingReply: CoreChannelDeps["buildPairingReply"] }>(
|
|
138
|
+
"pairing/pairing-messages.js",
|
|
139
|
+
),
|
|
140
|
+
importCoreModule<{
|
|
141
|
+
readChannelAllowFromStore: CoreChannelDeps["readChannelAllowFromStore"];
|
|
142
|
+
upsertChannelPairingRequest: CoreChannelDeps["upsertChannelPairingRequest"];
|
|
143
|
+
}>("pairing/pairing-store.js"),
|
|
144
|
+
importCoreModule<{ fetchRemoteMedia: CoreChannelDeps["fetchRemoteMedia"] }>(
|
|
145
|
+
"media/fetch.js",
|
|
146
|
+
),
|
|
147
|
+
importCoreModule<{ saveMediaBuffer: CoreChannelDeps["saveMediaBuffer"] }>(
|
|
148
|
+
"media/store.js",
|
|
149
|
+
),
|
|
150
|
+
importCoreModule<{ shouldLogVerbose: CoreChannelDeps["shouldLogVerbose"] }>(
|
|
151
|
+
"globals.js",
|
|
152
|
+
),
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
chunkMarkdownText: chunk.chunkMarkdownText,
|
|
157
|
+
formatAgentEnvelope: envelope.formatAgentEnvelope,
|
|
158
|
+
dispatchReplyWithBufferedBlockDispatcher:
|
|
159
|
+
dispatcher.dispatchReplyWithBufferedBlockDispatcher,
|
|
160
|
+
resolveAgentRoute: routing.resolveAgentRoute,
|
|
161
|
+
buildPairingReply: pairingMessages.buildPairingReply,
|
|
162
|
+
readChannelAllowFromStore: pairingStore.readChannelAllowFromStore,
|
|
163
|
+
upsertChannelPairingRequest: pairingStore.upsertChannelPairingRequest,
|
|
164
|
+
fetchRemoteMedia: mediaFetch.fetchRemoteMedia,
|
|
165
|
+
saveMediaBuffer: mediaStore.saveMediaBuffer,
|
|
166
|
+
shouldLogVerbose: globals.shouldLogVerbose,
|
|
167
|
+
};
|
|
168
|
+
})();
|
|
169
|
+
|
|
170
|
+
return coreDepsPromise;
|
|
171
|
+
}
|