@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/monitor.ts CHANGED
@@ -1,20 +1,20 @@
1
1
  import type { ChildProcess } from "node:child_process";
2
2
 
3
- import type { RuntimeEnv } from "../../../src/runtime.js";
4
- import {
5
- isControlCommandMessage,
6
- shouldComputeCommandAuthorized,
7
- } from "../../../src/auto-reply/command-detection.js";
8
- import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
9
- import { resolveCommandAuthorizedFromAuthorizers } from "../../../src/channels/command-gating.js";
10
- import { loadCoreChannelDeps, type CoreChannelDeps } from "./core-bridge.js";
3
+ import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
4
+ import { mergeAllowlist, summarizeMapping } from "clawdbot/plugin-sdk";
11
5
  import { sendMessageZalouser } from "./send.js";
12
- import type { CoreConfig, ResolvedZalouserAccount, ZcaMessage } from "./types.js";
13
- import { runZcaStreaming } from "./zca.js";
6
+ import type {
7
+ ResolvedZalouserAccount,
8
+ ZcaFriend,
9
+ ZcaGroup,
10
+ ZcaMessage,
11
+ } from "./types.js";
12
+ import { getZalouserRuntime } from "./runtime.js";
13
+ import { parseJsonOutput, runZca, runZcaStreaming } from "./zca.js";
14
14
 
15
15
  export type ZalouserMonitorOptions = {
16
16
  account: ResolvedZalouserAccount;
17
- config: CoreConfig;
17
+ config: ClawdbotConfig;
18
18
  runtime: RuntimeEnv;
19
19
  abortSignal: AbortSignal;
20
20
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
@@ -26,8 +26,29 @@ export type ZalouserMonitorResult = {
26
26
 
27
27
  const ZALOUSER_TEXT_LIMIT = 2000;
28
28
 
29
- function logVerbose(deps: CoreChannelDeps, runtime: RuntimeEnv, message: string): void {
30
- if (deps.shouldLogVerbose()) {
29
+ function normalizeZalouserEntry(entry: string): string {
30
+ return entry.replace(/^(zalouser|zlu):/i, "").trim();
31
+ }
32
+
33
+ function buildNameIndex<T>(
34
+ items: T[],
35
+ nameFn: (item: T) => string | undefined,
36
+ ): Map<string, T[]> {
37
+ const index = new Map<string, T[]>();
38
+ for (const item of items) {
39
+ const name = nameFn(item)?.trim().toLowerCase();
40
+ if (!name) continue;
41
+ const list = index.get(name) ?? [];
42
+ list.push(item);
43
+ index.set(name, list);
44
+ }
45
+ return index;
46
+ }
47
+
48
+ type ZalouserCoreRuntime = ReturnType<typeof getZalouserRuntime>;
49
+
50
+ function logVerbose(core: ZalouserCoreRuntime, runtime: RuntimeEnv, message: string): void {
51
+ if (core.logging.shouldLogVerbose()) {
31
52
  runtime.log(`[zalouser] ${message}`);
32
53
  }
33
54
  }
@@ -41,6 +62,39 @@ function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
41
62
  });
42
63
  }
43
64
 
65
+ function normalizeGroupSlug(raw?: string | null): string {
66
+ const trimmed = raw?.trim().toLowerCase() ?? "";
67
+ if (!trimmed) return "";
68
+ return trimmed
69
+ .replace(/^#/, "")
70
+ .replace(/[^a-z0-9]+/g, "-")
71
+ .replace(/^-+|-+$/g, "");
72
+ }
73
+
74
+ function isGroupAllowed(params: {
75
+ groupId: string;
76
+ groupName?: string | null;
77
+ groups: Record<string, { allow?: boolean; enabled?: boolean }>;
78
+ }): boolean {
79
+ const groups = params.groups ?? {};
80
+ const keys = Object.keys(groups);
81
+ if (keys.length === 0) return false;
82
+ const candidates = [
83
+ params.groupId,
84
+ `group:${params.groupId}`,
85
+ params.groupName ?? "",
86
+ normalizeGroupSlug(params.groupName ?? ""),
87
+ ].filter(Boolean);
88
+ for (const candidate of candidates) {
89
+ const entry = groups[candidate];
90
+ if (!entry) continue;
91
+ return entry.allow !== false && entry.enabled !== false;
92
+ }
93
+ const wildcard = groups["*"];
94
+ if (wildcard) return wildcard.allow !== false && wildcard.enabled !== false;
95
+ return false;
96
+ }
97
+
44
98
  function startZcaListener(
45
99
  runtime: RuntimeEnv,
46
100
  profile: string,
@@ -95,8 +149,8 @@ function startZcaListener(
95
149
  async function processMessage(
96
150
  message: ZcaMessage,
97
151
  account: ResolvedZalouserAccount,
98
- config: CoreConfig,
99
- deps: CoreChannelDeps,
152
+ config: ClawdbotConfig,
153
+ core: ZalouserCoreRuntime,
100
154
  runtime: RuntimeEnv,
101
155
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
102
156
  ): Promise<void> {
@@ -106,21 +160,42 @@ async function processMessage(
106
160
  const isGroup = metadata?.isGroup ?? false;
107
161
  const senderId = metadata?.fromId ?? threadId;
108
162
  const senderName = metadata?.senderName ?? "";
163
+ const groupName = metadata?.threadName ?? "";
109
164
  const chatId = threadId;
110
165
 
166
+ const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
167
+ const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
168
+ const groups = account.config.groups ?? {};
169
+ if (isGroup) {
170
+ if (groupPolicy === "disabled") {
171
+ logVerbose(core, runtime, `zalouser: drop group ${chatId} (groupPolicy=disabled)`);
172
+ return;
173
+ }
174
+ if (groupPolicy === "allowlist") {
175
+ const allowed = isGroupAllowed({ groupId: chatId, groupName, groups });
176
+ if (!allowed) {
177
+ logVerbose(core, runtime, `zalouser: drop group ${chatId} (not allowlisted)`);
178
+ return;
179
+ }
180
+ }
181
+ }
182
+
111
183
  const dmPolicy = account.config.dmPolicy ?? "pairing";
112
184
  const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
113
185
  const rawBody = content.trim();
114
- const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config);
186
+ const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(
187
+ rawBody,
188
+ config,
189
+ );
115
190
  const storeAllowFrom =
116
191
  !isGroup && (dmPolicy !== "open" || shouldComputeAuth)
117
- ? await deps.readChannelAllowFromStore("zalouser").catch(() => [])
192
+ ? await core.channel.pairing.readAllowFromStore("zalouser").catch(() => [])
118
193
  : [];
119
194
  const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
120
195
  const useAccessGroups = config.commands?.useAccessGroups !== false;
121
196
  const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
122
197
  const commandAuthorized = shouldComputeAuth
123
- ? resolveCommandAuthorizedFromAuthorizers({
198
+ ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
124
199
  useAccessGroups,
125
200
  authorizers: [{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }],
126
201
  })
@@ -128,7 +203,7 @@ async function processMessage(
128
203
 
129
204
  if (!isGroup) {
130
205
  if (dmPolicy === "disabled") {
131
- logVerbose(deps, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`);
206
+ logVerbose(core, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`);
132
207
  return;
133
208
  }
134
209
 
@@ -137,18 +212,18 @@ async function processMessage(
137
212
 
138
213
  if (!allowed) {
139
214
  if (dmPolicy === "pairing") {
140
- const { code, created } = await deps.upsertChannelPairingRequest({
215
+ const { code, created } = await core.channel.pairing.upsertPairingRequest({
141
216
  channel: "zalouser",
142
217
  id: senderId,
143
218
  meta: { name: senderName || undefined },
144
219
  });
145
220
 
146
221
  if (created) {
147
- logVerbose(deps, runtime, `zalouser pairing request sender=${senderId}`);
222
+ logVerbose(core, runtime, `zalouser pairing request sender=${senderId}`);
148
223
  try {
149
224
  await sendMessageZalouser(
150
225
  chatId,
151
- deps.buildPairingReply({
226
+ core.channel.pairing.buildPairingReply({
152
227
  channel: "zalouser",
153
228
  idLine: `Your Zalo user id: ${senderId}`,
154
229
  code,
@@ -158,7 +233,7 @@ async function processMessage(
158
233
  statusSink?.({ lastOutboundAt: Date.now() });
159
234
  } catch (err) {
160
235
  logVerbose(
161
- deps,
236
+ core,
162
237
  runtime,
163
238
  `zalouser pairing reply failed for ${senderId}: ${String(err)}`,
164
239
  );
@@ -166,7 +241,7 @@ async function processMessage(
166
241
  }
167
242
  } else {
168
243
  logVerbose(
169
- deps,
244
+ core,
170
245
  runtime,
171
246
  `Blocked unauthorized zalouser sender ${senderId} (dmPolicy=${dmPolicy})`,
172
247
  );
@@ -176,14 +251,18 @@ async function processMessage(
176
251
  }
177
252
  }
178
253
 
179
- if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
180
- logVerbose(deps, runtime, `zalouser: drop control command from unauthorized sender ${senderId}`);
254
+ if (
255
+ isGroup &&
256
+ core.channel.commands.isControlCommandMessage(rawBody, config) &&
257
+ commandAuthorized !== true
258
+ ) {
259
+ logVerbose(core, runtime, `zalouser: drop control command from unauthorized sender ${senderId}`);
181
260
  return;
182
261
  }
183
262
 
184
263
  const peer = isGroup ? { kind: "group" as const, id: chatId } : { kind: "group" as const, id: senderId };
185
264
 
186
- const route = deps.resolveAgentRoute({
265
+ const route = core.channel.routing.resolveAgentRoute({
187
266
  cfg: config,
188
267
  channel: "zalouser",
189
268
  accountId: account.accountId,
@@ -194,16 +273,25 @@ async function processMessage(
194
273
  },
195
274
  });
196
275
 
197
- const rawBody = content.trim();
198
- const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
199
- const body = deps.formatAgentEnvelope({
200
- channel: "Zalo Personal",
201
- from: fromLabel,
276
+ const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
277
+ const storePath = core.channel.session.resolveStorePath(config.session?.store, {
278
+ agentId: route.agentId,
279
+ });
280
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
281
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
282
+ storePath,
283
+ sessionKey: route.sessionKey,
284
+ });
285
+ const body = core.channel.reply.formatAgentEnvelope({
286
+ channel: "Zalo Personal",
287
+ from: fromLabel,
202
288
  timestamp: timestamp ? timestamp * 1000 : undefined,
289
+ previousTimestamp,
290
+ envelope: envelopeOptions,
203
291
  body: rawBody,
204
292
  });
205
293
 
206
- const ctxPayload = finalizeInboundContext({
294
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
207
295
  Body: body,
208
296
  RawBody: rawBody,
209
297
  CommandBody: rawBody,
@@ -223,7 +311,15 @@ async function processMessage(
223
311
  OriginatingTo: `zalouser:${chatId}`,
224
312
  });
225
313
 
226
- await deps.dispatchReplyWithBufferedBlockDispatcher({
314
+ void core.channel.session.recordSessionMetaFromInbound({
315
+ storePath,
316
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
317
+ ctx: ctxPayload,
318
+ }).catch((err) => {
319
+ runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
320
+ });
321
+
322
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
227
323
  ctx: ctxPayload,
228
324
  cfg: config,
229
325
  dispatcherOptions: {
@@ -234,7 +330,7 @@ async function processMessage(
234
330
  chatId,
235
331
  isGroup,
236
332
  runtime,
237
- deps,
333
+ core,
238
334
  statusSink,
239
335
  });
240
336
  },
@@ -253,10 +349,10 @@ async function deliverZalouserReply(params: {
253
349
  chatId: string;
254
350
  isGroup: boolean;
255
351
  runtime: RuntimeEnv;
256
- deps: CoreChannelDeps;
352
+ core: ZalouserCoreRuntime;
257
353
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
258
354
  }): Promise<void> {
259
- const { payload, profile, chatId, isGroup, runtime, deps, statusSink } = params;
355
+ const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params;
260
356
 
261
357
  const mediaList = payload.mediaUrls?.length
262
358
  ? payload.mediaUrls
@@ -270,7 +366,7 @@ async function deliverZalouserReply(params: {
270
366
  const caption = first ? payload.text : undefined;
271
367
  first = false;
272
368
  try {
273
- logVerbose(deps, runtime, `Sending media to ${chatId}`);
369
+ logVerbose(core, runtime, `Sending media to ${chatId}`);
274
370
  await sendMessageZalouser(chatId, caption ?? "", {
275
371
  profile,
276
372
  mediaUrl,
@@ -285,8 +381,8 @@ async function deliverZalouserReply(params: {
285
381
  }
286
382
 
287
383
  if (payload.text) {
288
- const chunks = deps.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT);
289
- logVerbose(deps, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
384
+ const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT);
385
+ logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
290
386
  for (const chunk of chunks) {
291
387
  try {
292
388
  await sendMessageZalouser(chatId, chunk, { profile, isGroup });
@@ -301,14 +397,101 @@ async function deliverZalouserReply(params: {
301
397
  export async function monitorZalouserProvider(
302
398
  options: ZalouserMonitorOptions,
303
399
  ): Promise<ZalouserMonitorResult> {
304
- const { account, config, abortSignal, statusSink, runtime } = options;
400
+ let { account, config } = options;
401
+ const { abortSignal, statusSink, runtime } = options;
305
402
 
306
- const deps = await loadCoreChannelDeps();
403
+ const core = getZalouserRuntime();
307
404
  let stopped = false;
308
405
  let proc: ChildProcess | null = null;
309
406
  let restartTimer: ReturnType<typeof setTimeout> | null = null;
310
407
  let resolveRunning: (() => void) | null = null;
311
408
 
409
+ try {
410
+ const profile = account.profile;
411
+ const allowFromEntries = (account.config.allowFrom ?? [])
412
+ .map((entry) => normalizeZalouserEntry(String(entry)))
413
+ .filter((entry) => entry && entry !== "*");
414
+
415
+ if (allowFromEntries.length > 0) {
416
+ const result = await runZca(["friend", "list", "-j"], { profile, timeout: 15000 });
417
+ if (result.ok) {
418
+ const friends = parseJsonOutput<ZcaFriend[]>(result.stdout) ?? [];
419
+ const byName = buildNameIndex(friends, (friend) => friend.displayName);
420
+ const additions: string[] = [];
421
+ const mapping: string[] = [];
422
+ const unresolved: string[] = [];
423
+ for (const entry of allowFromEntries) {
424
+ if (/^\d+$/.test(entry)) {
425
+ additions.push(entry);
426
+ continue;
427
+ }
428
+ const matches = byName.get(entry.toLowerCase()) ?? [];
429
+ const match = matches[0];
430
+ const id = match?.userId ? String(match.userId) : undefined;
431
+ if (id) {
432
+ additions.push(id);
433
+ mapping.push(`${entry}→${id}`);
434
+ } else {
435
+ unresolved.push(entry);
436
+ }
437
+ }
438
+ const allowFrom = mergeAllowlist({ existing: account.config.allowFrom, additions });
439
+ account = {
440
+ ...account,
441
+ config: {
442
+ ...account.config,
443
+ allowFrom,
444
+ },
445
+ };
446
+ summarizeMapping("zalouser users", mapping, unresolved, runtime);
447
+ } else {
448
+ runtime.log?.(`zalouser user resolve failed; using config entries. ${result.stderr}`);
449
+ }
450
+ }
451
+
452
+ const groupsConfig = account.config.groups ?? {};
453
+ const groupKeys = Object.keys(groupsConfig).filter((key) => key !== "*");
454
+ if (groupKeys.length > 0) {
455
+ const result = await runZca(["group", "list", "-j"], { profile, timeout: 15000 });
456
+ if (result.ok) {
457
+ const groups = parseJsonOutput<ZcaGroup[]>(result.stdout) ?? [];
458
+ const byName = buildNameIndex(groups, (group) => group.name);
459
+ const mapping: string[] = [];
460
+ const unresolved: string[] = [];
461
+ const nextGroups = { ...groupsConfig };
462
+ for (const entry of groupKeys) {
463
+ const cleaned = normalizeZalouserEntry(entry);
464
+ if (/^\d+$/.test(cleaned)) {
465
+ if (!nextGroups[cleaned]) nextGroups[cleaned] = groupsConfig[entry];
466
+ mapping.push(`${entry}→${cleaned}`);
467
+ continue;
468
+ }
469
+ const matches = byName.get(cleaned.toLowerCase()) ?? [];
470
+ const match = matches[0];
471
+ const id = match?.groupId ? String(match.groupId) : undefined;
472
+ if (id) {
473
+ if (!nextGroups[id]) nextGroups[id] = groupsConfig[entry];
474
+ mapping.push(`${entry}→${id}`);
475
+ } else {
476
+ unresolved.push(entry);
477
+ }
478
+ }
479
+ account = {
480
+ ...account,
481
+ config: {
482
+ ...account.config,
483
+ groups: nextGroups,
484
+ },
485
+ };
486
+ summarizeMapping("zalouser groups", mapping, unresolved, runtime);
487
+ } else {
488
+ runtime.log?.(`zalouser group resolve failed; using config entries. ${result.stderr}`);
489
+ }
490
+ }
491
+ } catch (err) {
492
+ runtime.log?.(`zalouser resolve failed; using config entries. ${String(err)}`);
493
+ }
494
+
312
495
  const stop = () => {
313
496
  stopped = true;
314
497
  if (restartTimer) {
@@ -329,7 +512,7 @@ export async function monitorZalouserProvider(
329
512
  }
330
513
 
331
514
  logVerbose(
332
- deps,
515
+ core,
333
516
  runtime,
334
517
  `[${account.accountId}] starting zca listener (profile=${account.profile})`,
335
518
  );
@@ -338,16 +521,16 @@ export async function monitorZalouserProvider(
338
521
  runtime,
339
522
  account.profile,
340
523
  (msg) => {
341
- logVerbose(deps, runtime, `[${account.accountId}] inbound message`);
524
+ logVerbose(core, runtime, `[${account.accountId}] inbound message`);
342
525
  statusSink?.({ lastInboundAt: Date.now() });
343
- processMessage(msg, account, config, deps, runtime, statusSink).catch((err) => {
526
+ processMessage(msg, account, config, core, runtime, statusSink).catch((err) => {
344
527
  runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`);
345
528
  });
346
529
  },
347
530
  (err) => {
348
531
  runtime.error(`[${account.accountId}] zca listener error: ${String(err)}`);
349
532
  if (!stopped && !abortSignal.aborted) {
350
- logVerbose(deps, runtime, `[${account.accountId}] restarting listener in 5s...`);
533
+ logVerbose(core, runtime, `[${account.accountId}] restarting listener in 5s...`);
351
534
  restartTimer = setTimeout(startListener, 5000);
352
535
  } else {
353
536
  resolveRunning?.();