@clawdbot/zalouser 2026.1.16 → 2026.1.21

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/src/onboarding.ts CHANGED
@@ -1,27 +1,35 @@
1
- import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
2
- import type { WizardPrompter } from "../../../src/wizard/prompts.js";
1
+ import type {
2
+ ChannelOnboardingAdapter,
3
+ ChannelOnboardingDmPolicy,
4
+ ClawdbotConfig,
5
+ WizardPrompter,
6
+ } from "clawdbot/plugin-sdk";
7
+ import {
8
+ addWildcardAllowFrom,
9
+ DEFAULT_ACCOUNT_ID,
10
+ normalizeAccountId,
11
+ promptAccountId,
12
+ promptChannelAccessConfig,
13
+ } from "clawdbot/plugin-sdk";
3
14
 
4
15
  import {
5
16
  listZalouserAccountIds,
6
17
  resolveDefaultZalouserAccountId,
7
18
  resolveZalouserAccountSync,
8
- normalizeAccountId,
9
19
  checkZcaAuthenticated,
10
20
  } from "./accounts.js";
11
- import { runZcaInteractive, checkZcaInstalled } from "./zca.js";
12
- import { DEFAULT_ACCOUNT_ID, type CoreConfig } from "./types.js";
21
+ import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from "./zca.js";
22
+ import type { ZcaFriend, ZcaGroup } from "./types.js";
13
23
 
14
24
  const channel = "zalouser" as const;
15
25
 
16
26
  function setZalouserDmPolicy(
17
- cfg: CoreConfig,
27
+ cfg: ClawdbotConfig,
18
28
  dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
19
- ): CoreConfig {
29
+ ): ClawdbotConfig {
20
30
  const allowFrom =
21
31
  dmPolicy === "open"
22
- ? [...(cfg.channels?.zalouser?.allowFrom ?? []), "*"].filter(
23
- (v, i, a) => a.indexOf(v) === i,
24
- )
32
+ ? addWildcardAllowFrom(cfg.channels?.zalouser?.allowFrom)
25
33
  : undefined;
26
34
  return {
27
35
  ...cfg,
@@ -33,7 +41,7 @@ function setZalouserDmPolicy(
33
41
  ...(allowFrom ? { allowFrom } : {}),
34
42
  },
35
43
  },
36
- } as CoreConfig;
44
+ } as ClawdbotConfig;
37
45
  }
38
46
 
39
47
  async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
@@ -52,32 +60,80 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
52
60
  }
53
61
 
54
62
  async function promptZalouserAllowFrom(params: {
55
- cfg: CoreConfig;
63
+ cfg: ClawdbotConfig;
56
64
  prompter: WizardPrompter;
57
65
  accountId: string;
58
- }): Promise<CoreConfig> {
66
+ }): Promise<ClawdbotConfig> {
59
67
  const { cfg, prompter, accountId } = params;
60
68
  const resolved = resolveZalouserAccountSync({ cfg, accountId });
61
69
  const existingAllowFrom = resolved.config.allowFrom ?? [];
62
- const entry = await prompter.text({
63
- message: "Zalouser allowFrom (user id)",
64
- placeholder: "123456789",
65
- initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
66
- validate: (value) => {
67
- const raw = String(value ?? "").trim();
68
- if (!raw) return "Required";
69
- if (!/^\d+$/.test(raw)) return "Use a numeric Zalo user id";
70
- return undefined;
71
- },
72
- });
73
- const normalized = String(entry).trim();
74
- const merged = [
75
- ...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
76
- normalized,
77
- ];
78
- const unique = [...new Set(merged)];
70
+ const parseInput = (raw: string) =>
71
+ raw
72
+ .split(/[\n,;]+/g)
73
+ .map((entry) => entry.trim())
74
+ .filter(Boolean);
75
+
76
+ const resolveUserId = async (input: string): Promise<string | null> => {
77
+ const trimmed = input.trim();
78
+ if (!trimmed) return null;
79
+ if (/^\d+$/.test(trimmed)) return trimmed;
80
+ const ok = await checkZcaInstalled();
81
+ if (!ok) return null;
82
+ const result = await runZca(["friend", "find", trimmed], {
83
+ profile: resolved.profile,
84
+ timeout: 15000,
85
+ });
86
+ if (!result.ok) return null;
87
+ const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout);
88
+ const rows = Array.isArray(parsed) ? parsed : [];
89
+ const match = rows[0];
90
+ if (!match?.userId) return null;
91
+ if (rows.length > 1) {
92
+ await prompter.note(
93
+ `Multiple matches for "${trimmed}", using ${match.displayName ?? match.userId}.`,
94
+ "Zalo Personal allowlist",
95
+ );
96
+ }
97
+ return String(match.userId);
98
+ };
99
+
100
+ while (true) {
101
+ const entry = await prompter.text({
102
+ message: "Zalouser allowFrom (username or user id)",
103
+ placeholder: "Alice, 123456789",
104
+ initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
105
+ validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
106
+ });
107
+ const parts = parseInput(String(entry));
108
+ const results = await Promise.all(parts.map((part) => resolveUserId(part)));
109
+ const unresolved = parts.filter((_, idx) => !results[idx]);
110
+ if (unresolved.length > 0) {
111
+ await prompter.note(
112
+ `Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or ensure zca is available.`,
113
+ "Zalo Personal allowlist",
114
+ );
115
+ continue;
116
+ }
117
+ const merged = [
118
+ ...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
119
+ ...(results.filter(Boolean) as string[]),
120
+ ];
121
+ const unique = [...new Set(merged)];
122
+ if (accountId === DEFAULT_ACCOUNT_ID) {
123
+ return {
124
+ ...cfg,
125
+ channels: {
126
+ ...cfg.channels,
127
+ zalouser: {
128
+ ...cfg.channels?.zalouser,
129
+ enabled: true,
130
+ dmPolicy: "allowlist",
131
+ allowFrom: unique,
132
+ },
133
+ },
134
+ } as ClawdbotConfig;
135
+ }
79
136
 
80
- if (accountId === DEFAULT_ACCOUNT_ID) {
81
137
  return {
82
138
  ...cfg,
83
139
  channels: {
@@ -85,13 +141,39 @@ async function promptZalouserAllowFrom(params: {
85
141
  zalouser: {
86
142
  ...cfg.channels?.zalouser,
87
143
  enabled: true,
88
- dmPolicy: "allowlist",
89
- allowFrom: unique,
144
+ accounts: {
145
+ ...(cfg.channels?.zalouser?.accounts ?? {}),
146
+ [accountId]: {
147
+ ...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
148
+ enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
149
+ dmPolicy: "allowlist",
150
+ allowFrom: unique,
151
+ },
152
+ },
90
153
  },
91
154
  },
92
- } as CoreConfig;
155
+ } as ClawdbotConfig;
93
156
  }
157
+ }
94
158
 
159
+ function setZalouserGroupPolicy(
160
+ cfg: ClawdbotConfig,
161
+ accountId: string,
162
+ groupPolicy: "open" | "allowlist" | "disabled",
163
+ ): ClawdbotConfig {
164
+ if (accountId === DEFAULT_ACCOUNT_ID) {
165
+ return {
166
+ ...cfg,
167
+ channels: {
168
+ ...cfg.channels,
169
+ zalouser: {
170
+ ...cfg.channels?.zalouser,
171
+ enabled: true,
172
+ groupPolicy,
173
+ },
174
+ },
175
+ } as ClawdbotConfig;
176
+ }
95
177
  return {
96
178
  ...cfg,
97
179
  channels: {
@@ -104,55 +186,83 @@ async function promptZalouserAllowFrom(params: {
104
186
  [accountId]: {
105
187
  ...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
106
188
  enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
107
- dmPolicy: "allowlist",
108
- allowFrom: unique,
189
+ groupPolicy,
109
190
  },
110
191
  },
111
192
  },
112
193
  },
113
- } as CoreConfig;
194
+ } as ClawdbotConfig;
114
195
  }
115
196
 
116
- async function promptAccountId(params: {
117
- cfg: CoreConfig;
118
- prompter: WizardPrompter;
119
- label: string;
120
- currentId: string;
121
- listAccountIds: (cfg: CoreConfig) => string[];
122
- defaultAccountId: string;
123
- }): Promise<string> {
124
- const { cfg, prompter, label, currentId, listAccountIds, defaultAccountId } = params;
125
- const existingIds = listAccountIds(cfg);
126
- const options = [
127
- ...existingIds.map((id) => ({
128
- value: id,
129
- label: id === defaultAccountId ? `${id} (default)` : id,
130
- })),
131
- { value: "__new__", label: "Create new account" },
132
- ];
133
-
134
- const selected = await prompter.select({
135
- message: `${label} account`,
136
- options,
137
- initialValue: currentId,
138
- });
139
-
140
- if (selected === "__new__") {
141
- const newId = await prompter.text({
142
- message: "New account ID",
143
- placeholder: "work",
144
- validate: (value) => {
145
- const raw = String(value ?? "").trim().toLowerCase();
146
- if (!raw) return "Required";
147
- if (!/^[a-z0-9_-]+$/.test(raw)) return "Use lowercase alphanumeric, dash, or underscore";
148
- if (existingIds.includes(raw)) return "Account already exists";
149
- return undefined;
197
+ function setZalouserGroupAllowlist(
198
+ cfg: ClawdbotConfig,
199
+ accountId: string,
200
+ groupKeys: string[],
201
+ ): ClawdbotConfig {
202
+ const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }]));
203
+ if (accountId === DEFAULT_ACCOUNT_ID) {
204
+ return {
205
+ ...cfg,
206
+ channels: {
207
+ ...cfg.channels,
208
+ zalouser: {
209
+ ...cfg.channels?.zalouser,
210
+ enabled: true,
211
+ groups,
212
+ },
150
213
  },
151
- });
152
- return String(newId).trim().toLowerCase();
214
+ } as ClawdbotConfig;
215
+ }
216
+ return {
217
+ ...cfg,
218
+ channels: {
219
+ ...cfg.channels,
220
+ zalouser: {
221
+ ...cfg.channels?.zalouser,
222
+ enabled: true,
223
+ accounts: {
224
+ ...(cfg.channels?.zalouser?.accounts ?? {}),
225
+ [accountId]: {
226
+ ...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
227
+ enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
228
+ groups,
229
+ },
230
+ },
231
+ },
232
+ },
233
+ } as ClawdbotConfig;
234
+ }
235
+
236
+ async function resolveZalouserGroups(params: {
237
+ cfg: ClawdbotConfig;
238
+ accountId: string;
239
+ entries: string[];
240
+ }): Promise<Array<{ input: string; resolved: boolean; id?: string }>> {
241
+ const account = resolveZalouserAccountSync({ cfg: params.cfg, accountId: params.accountId });
242
+ const result = await runZca(["group", "list", "-j"], { profile: account.profile, timeout: 15000 });
243
+ if (!result.ok) throw new Error(result.stderr || "Failed to list groups");
244
+ const groups = (parseJsonOutput<ZcaGroup[]>(result.stdout) ?? []).filter(
245
+ (group) => Boolean(group.groupId),
246
+ );
247
+ const byName = new Map<string, ZcaGroup[]>();
248
+ for (const group of groups) {
249
+ const name = group.name?.trim().toLowerCase();
250
+ if (!name) continue;
251
+ const list = byName.get(name) ?? [];
252
+ list.push(group);
253
+ byName.set(name, list);
153
254
  }
154
255
 
155
- return selected as string;
256
+ return params.entries.map((input) => {
257
+ const trimmed = input.trim();
258
+ if (!trimmed) return { input, resolved: false };
259
+ if (/^\d+$/.test(trimmed)) return { input, resolved: true, id: trimmed };
260
+ const matches = byName.get(trimmed.toLowerCase()) ?? [];
261
+ const match = matches[0];
262
+ return match?.groupId
263
+ ? { input, resolved: true, id: String(match.groupId) }
264
+ : { input, resolved: false };
265
+ });
156
266
  }
157
267
 
158
268
  const dmPolicy: ChannelOnboardingDmPolicy = {
@@ -160,18 +270,29 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
160
270
  channel,
161
271
  policyKey: "channels.zalouser.dmPolicy",
162
272
  allowFromKey: "channels.zalouser.allowFrom",
163
- getCurrent: (cfg) => ((cfg as CoreConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing",
164
- setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as CoreConfig, policy),
273
+ getCurrent: (cfg) => ((cfg as ClawdbotConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing",
274
+ setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as ClawdbotConfig, policy),
275
+ promptAllowFrom: async ({ cfg, prompter, accountId }) => {
276
+ const id =
277
+ accountId && normalizeAccountId(accountId)
278
+ ? normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID
279
+ : resolveDefaultZalouserAccountId(cfg as ClawdbotConfig);
280
+ return promptZalouserAllowFrom({
281
+ cfg: cfg as ClawdbotConfig,
282
+ prompter,
283
+ accountId: id,
284
+ });
285
+ },
165
286
  };
166
287
 
167
288
  export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
168
289
  channel,
169
290
  dmPolicy,
170
291
  getStatus: async ({ cfg }) => {
171
- const ids = listZalouserAccountIds(cfg as CoreConfig);
292
+ const ids = listZalouserAccountIds(cfg as ClawdbotConfig);
172
293
  let configured = false;
173
294
  for (const accountId of ids) {
174
- const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
295
+ const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
175
296
  const isAuth = await checkZcaAuthenticated(account.profile);
176
297
  if (isAuth) {
177
298
  configured = true;
@@ -203,14 +324,14 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
203
324
  }
204
325
 
205
326
  const zalouserOverride = accountOverrides.zalouser?.trim();
206
- const defaultAccountId = resolveDefaultZalouserAccountId(cfg as CoreConfig);
327
+ const defaultAccountId = resolveDefaultZalouserAccountId(cfg as ClawdbotConfig);
207
328
  let accountId = zalouserOverride
208
329
  ? normalizeAccountId(zalouserOverride)
209
330
  : defaultAccountId;
210
331
 
211
332
  if (shouldPromptAccountIds && !zalouserOverride) {
212
333
  accountId = await promptAccountId({
213
- cfg: cfg as CoreConfig,
334
+ cfg: cfg as ClawdbotConfig,
214
335
  prompter,
215
336
  label: "Zalo Personal",
216
337
  currentId: accountId,
@@ -219,7 +340,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
219
340
  });
220
341
  }
221
342
 
222
- let next = cfg as CoreConfig;
343
+ let next = cfg as ClawdbotConfig;
223
344
  const account = resolveZalouserAccountSync({ cfg: next, accountId });
224
345
  const alreadyAuthenticated = await checkZcaAuthenticated(account.profile);
225
346
 
@@ -277,7 +398,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
277
398
  profile: account.profile !== "default" ? account.profile : undefined,
278
399
  },
279
400
  },
280
- } as CoreConfig;
401
+ } as ClawdbotConfig;
281
402
  } else {
282
403
  next = {
283
404
  ...next,
@@ -296,7 +417,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
296
417
  },
297
418
  },
298
419
  },
299
- } as CoreConfig;
420
+ } as ClawdbotConfig;
300
421
  }
301
422
 
302
423
  if (forceAllowFrom) {
@@ -307,6 +428,61 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
307
428
  });
308
429
  }
309
430
 
431
+ const accessConfig = await promptChannelAccessConfig({
432
+ prompter,
433
+ label: "Zalo groups",
434
+ currentPolicy: account.config.groupPolicy ?? "open",
435
+ currentEntries: Object.keys(account.config.groups ?? {}),
436
+ placeholder: "Family, Work, 123456789",
437
+ updatePrompt: Boolean(account.config.groups),
438
+ });
439
+ if (accessConfig) {
440
+ if (accessConfig.policy !== "allowlist") {
441
+ next = setZalouserGroupPolicy(next, accountId, accessConfig.policy);
442
+ } else {
443
+ let keys = accessConfig.entries;
444
+ if (accessConfig.entries.length > 0) {
445
+ try {
446
+ const resolved = await resolveZalouserGroups({
447
+ cfg: next,
448
+ accountId,
449
+ entries: accessConfig.entries,
450
+ });
451
+ const resolvedIds = resolved
452
+ .filter((entry) => entry.resolved && entry.id)
453
+ .map((entry) => entry.id as string);
454
+ const unresolved = resolved
455
+ .filter((entry) => !entry.resolved)
456
+ .map((entry) => entry.input);
457
+ keys = [
458
+ ...resolvedIds,
459
+ ...unresolved.map((entry) => entry.trim()).filter(Boolean),
460
+ ];
461
+ if (resolvedIds.length > 0 || unresolved.length > 0) {
462
+ await prompter.note(
463
+ [
464
+ resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined,
465
+ unresolved.length > 0
466
+ ? `Unresolved (kept as typed): ${unresolved.join(", ")}`
467
+ : undefined,
468
+ ]
469
+ .filter(Boolean)
470
+ .join("\n"),
471
+ "Zalo groups",
472
+ );
473
+ }
474
+ } catch (err) {
475
+ await prompter.note(
476
+ `Group lookup failed; keeping entries as typed. ${String(err)}`,
477
+ "Zalo groups",
478
+ );
479
+ }
480
+ }
481
+ next = setZalouserGroupPolicy(next, accountId, "allowlist");
482
+ next = setZalouserGroupAllowlist(next, accountId, keys);
483
+ }
484
+ }
485
+
310
486
  return { cfg: next, accountId };
311
487
  },
312
488
  };
package/src/probe.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { runZca, parseJsonOutput } from "./zca.js";
2
+ import type { ZcaUserInfo } from "./types.js";
3
+
4
+ export interface ZalouserProbeResult {
5
+ ok: boolean;
6
+ user?: ZcaUserInfo;
7
+ error?: string;
8
+ }
9
+
10
+ export async function probeZalouser(
11
+ profile: string,
12
+ timeoutMs?: number,
13
+ ): Promise<ZalouserProbeResult> {
14
+ const result = await runZca(["me", "info", "-j"], {
15
+ profile,
16
+ timeout: timeoutMs,
17
+ });
18
+
19
+ if (!result.ok) {
20
+ return { ok: false, error: result.stderr || "Failed to probe" };
21
+ }
22
+
23
+ const user = parseJsonOutput<ZcaUserInfo>(result.stdout);
24
+ if (!user) {
25
+ return { ok: false, error: "Failed to parse user info" };
26
+ }
27
+ return { ok: true, user };
28
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginRuntime } from "clawdbot/plugin-sdk";
2
+
3
+ let runtime: PluginRuntime | null = null;
4
+
5
+ export function setZalouserRuntime(next: PluginRuntime): void {
6
+ runtime = next;
7
+ }
8
+
9
+ export function getZalouserRuntime(): PluginRuntime {
10
+ if (!runtime) {
11
+ throw new Error("Zalouser runtime not initialized");
12
+ }
13
+ return runtime;
14
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { collectZalouserStatusIssues } from "./status-issues.js";
4
+
5
+ describe("collectZalouserStatusIssues", () => {
6
+ it("flags missing zca when configured is false", () => {
7
+ const issues = collectZalouserStatusIssues([
8
+ {
9
+ accountId: "default",
10
+ enabled: true,
11
+ configured: false,
12
+ lastError: "zca CLI not found in PATH",
13
+ },
14
+ ]);
15
+ expect(issues).toHaveLength(1);
16
+ expect(issues[0]?.kind).toBe("runtime");
17
+ expect(issues[0]?.message).toMatch(/zca CLI not found/i);
18
+ });
19
+
20
+ it("flags missing auth when configured is false", () => {
21
+ const issues = collectZalouserStatusIssues([
22
+ {
23
+ accountId: "default",
24
+ enabled: true,
25
+ configured: false,
26
+ lastError: "not authenticated",
27
+ },
28
+ ]);
29
+ expect(issues).toHaveLength(1);
30
+ expect(issues[0]?.kind).toBe("auth");
31
+ expect(issues[0]?.message).toMatch(/Not authenticated/i);
32
+ });
33
+
34
+ it("warns when dmPolicy is open", () => {
35
+ const issues = collectZalouserStatusIssues([
36
+ {
37
+ accountId: "default",
38
+ enabled: true,
39
+ configured: true,
40
+ dmPolicy: "open",
41
+ },
42
+ ]);
43
+ expect(issues).toHaveLength(1);
44
+ expect(issues[0]?.kind).toBe("config");
45
+ });
46
+
47
+ it("skips disabled accounts", () => {
48
+ const issues = collectZalouserStatusIssues([
49
+ {
50
+ accountId: "default",
51
+ enabled: false,
52
+ configured: false,
53
+ lastError: "zca CLI not found in PATH",
54
+ },
55
+ ]);
56
+ expect(issues).toHaveLength(0);
57
+ });
58
+ });
@@ -0,0 +1,81 @@
1
+ import type { ChannelAccountSnapshot, ChannelStatusIssue } from "clawdbot/plugin-sdk";
2
+
3
+ type ZalouserAccountStatus = {
4
+ accountId?: unknown;
5
+ enabled?: unknown;
6
+ configured?: unknown;
7
+ dmPolicy?: unknown;
8
+ lastError?: unknown;
9
+ };
10
+
11
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
12
+ Boolean(value && typeof value === "object");
13
+
14
+ const asString = (value: unknown): string | undefined =>
15
+ typeof value === "string" ? value : typeof value === "number" ? String(value) : undefined;
16
+
17
+ function readZalouserAccountStatus(value: ChannelAccountSnapshot): ZalouserAccountStatus | null {
18
+ if (!isRecord(value)) return null;
19
+ return {
20
+ accountId: value.accountId,
21
+ enabled: value.enabled,
22
+ configured: value.configured,
23
+ dmPolicy: value.dmPolicy,
24
+ lastError: value.lastError,
25
+ };
26
+ }
27
+
28
+ function isMissingZca(lastError?: string): boolean {
29
+ if (!lastError) return false;
30
+ const lower = lastError.toLowerCase();
31
+ return lower.includes("zca") && (lower.includes("not found") || lower.includes("enoent"));
32
+ }
33
+
34
+ export function collectZalouserStatusIssues(
35
+ accounts: ChannelAccountSnapshot[],
36
+ ): ChannelStatusIssue[] {
37
+ const issues: ChannelStatusIssue[] = [];
38
+ for (const entry of accounts) {
39
+ const account = readZalouserAccountStatus(entry);
40
+ if (!account) continue;
41
+ const accountId = asString(account.accountId) ?? "default";
42
+ const enabled = account.enabled !== false;
43
+ if (!enabled) continue;
44
+
45
+ const configured = account.configured === true;
46
+ const lastError = asString(account.lastError)?.trim();
47
+
48
+ if (!configured) {
49
+ if (isMissingZca(lastError)) {
50
+ issues.push({
51
+ channel: "zalouser",
52
+ accountId,
53
+ kind: "runtime",
54
+ message: "zca CLI not found in PATH.",
55
+ fix: "Install zca-cli and ensure it is on PATH for the Gateway process.",
56
+ });
57
+ } else {
58
+ issues.push({
59
+ channel: "zalouser",
60
+ accountId,
61
+ kind: "auth",
62
+ message: "Not authenticated (no zca session).",
63
+ fix: "Run: clawdbot channels login --channel zalouser",
64
+ });
65
+ }
66
+ continue;
67
+ }
68
+
69
+ if (account.dmPolicy === "open") {
70
+ issues.push({
71
+ channel: "zalouser",
72
+ accountId,
73
+ kind: "config",
74
+ message:
75
+ 'Zalo Personal dmPolicy is "open", allowing any user to message the bot without pairing.',
76
+ fix: 'Set channels.zalouser.dmPolicy to "pairing" or "allowlist" to restrict access.',
77
+ });
78
+ }
79
+ }
80
+ return issues;
81
+ }
package/src/types.ts CHANGED
@@ -68,15 +68,14 @@ export type ListenOptions = CommonOptions & {
68
68
  prefix?: string;
69
69
  };
70
70
 
71
- // Channel plugin config types
72
- export const DEFAULT_ACCOUNT_ID = "default";
73
-
74
71
  export type ZalouserAccountConfig = {
75
72
  enabled?: boolean;
76
73
  name?: string;
77
74
  profile?: string;
78
75
  dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
79
76
  allowFrom?: Array<string | number>;
77
+ groupPolicy?: "open" | "allowlist" | "disabled";
78
+ groups?: Record<string, { allow?: boolean; enabled?: boolean }>;
80
79
  messagePrefix?: string;
81
80
  };
82
81
 
@@ -87,18 +86,12 @@ export type ZalouserConfig = {
87
86
  defaultAccount?: string;
88
87
  dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
89
88
  allowFrom?: Array<string | number>;
89
+ groupPolicy?: "open" | "allowlist" | "disabled";
90
+ groups?: Record<string, { allow?: boolean; enabled?: boolean }>;
90
91
  messagePrefix?: string;
91
92
  accounts?: Record<string, ZalouserAccountConfig>;
92
93
  };
93
94
 
94
- export type CoreConfig = {
95
- channels?: {
96
- zalouser?: ZalouserConfig;
97
- [key: string]: unknown;
98
- };
99
- [key: string]: unknown;
100
- };
101
-
102
95
  export type ResolvedZalouserAccount = {
103
96
  accountId: string;
104
97
  name?: string;