@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/src/monitor.ts CHANGED
@@ -1,26 +1,45 @@
1
- import type { ChildProcess } from "node:child_process";
2
1
  import type {
3
2
  MarkdownTableMode,
4
3
  OpenClawConfig,
5
4
  OutboundReplyPayload,
6
5
  RuntimeEnv,
7
- } from "openclaw/plugin-sdk";
6
+ } from "openclaw/plugin-sdk/zalouser";
8
7
  import {
8
+ createTypingCallbacks,
9
9
  createScopedPairingAccess,
10
10
  createReplyPrefixOptions,
11
+ evaluateGroupRouteAccessForPolicy,
12
+ issuePairingChallenge,
11
13
  resolveOutboundMediaUrls,
12
14
  mergeAllowlist,
15
+ resolveMentionGatingWithBypass,
13
16
  resolveOpenProviderRuntimeGroupPolicy,
14
17
  resolveDefaultGroupPolicy,
15
18
  resolveSenderCommandAuthorization,
16
19
  sendMediaWithLeadingCaption,
17
20
  summarizeMapping,
18
21
  warnMissingProviderGroupPolicyFallbackOnce,
19
- } from "openclaw/plugin-sdk";
22
+ } from "openclaw/plugin-sdk/zalouser";
23
+ import {
24
+ buildZalouserGroupCandidates,
25
+ findZalouserGroupEntry,
26
+ isZalouserGroupEntryAllowed,
27
+ } from "./group-policy.js";
28
+ import { formatZalouserMessageSidFull, resolveZalouserMessageSid } from "./message-sid.js";
20
29
  import { getZalouserRuntime } from "./runtime.js";
21
- import { sendMessageZalouser } from "./send.js";
22
- import type { ResolvedZalouserAccount, ZcaFriend, ZcaGroup, ZcaMessage } from "./types.js";
23
- import { parseJsonOutput, runZca, runZcaStreaming } from "./zca.js";
30
+ import {
31
+ sendDeliveredZalouser,
32
+ sendMessageZalouser,
33
+ sendSeenZalouser,
34
+ sendTypingZalouser,
35
+ } from "./send.js";
36
+ import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
37
+ import {
38
+ listZaloFriends,
39
+ listZaloGroups,
40
+ resolveZaloGroupContext,
41
+ startZaloListener,
42
+ } from "./zalo-js.js";
24
43
 
25
44
  export type ZalouserMonitorOptions = {
26
45
  account: ResolvedZalouserAccount;
@@ -62,136 +81,111 @@ function logVerbose(core: ZalouserCoreRuntime, runtime: RuntimeEnv, message: str
62
81
  }
63
82
  }
64
83
 
65
- function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
84
+ function isSenderAllowed(senderId: string | undefined, allowFrom: string[]): boolean {
66
85
  if (allowFrom.includes("*")) {
67
86
  return true;
68
87
  }
69
- const normalizedSenderId = senderId.toLowerCase();
88
+ const normalizedSenderId = senderId?.trim().toLowerCase();
89
+ if (!normalizedSenderId) {
90
+ return false;
91
+ }
70
92
  return allowFrom.some((entry) => {
71
93
  const normalized = entry.toLowerCase().replace(/^(zalouser|zlu):/i, "");
72
94
  return normalized === normalizedSenderId;
73
95
  });
74
96
  }
75
97
 
76
- function normalizeGroupSlug(raw?: string | null): string {
77
- const trimmed = raw?.trim().toLowerCase() ?? "";
78
- if (!trimmed) {
79
- return "";
80
- }
81
- return trimmed
82
- .replace(/^#/, "")
83
- .replace(/[^a-z0-9]+/g, "-")
84
- .replace(/^-+|-+$/g, "");
85
- }
86
-
87
- function isGroupAllowed(params: {
98
+ function resolveGroupRequireMention(params: {
88
99
  groupId: string;
89
100
  groupName?: string | null;
90
- groups: Record<string, { allow?: boolean; enabled?: boolean }>;
101
+ groups: Record<string, { allow?: boolean; enabled?: boolean; requireMention?: boolean }>;
91
102
  }): boolean {
92
- const groups = params.groups ?? {};
93
- const keys = Object.keys(groups);
94
- if (keys.length === 0) {
95
- return false;
96
- }
97
- const candidates = [
98
- params.groupId,
99
- `group:${params.groupId}`,
100
- params.groupName ?? "",
101
- normalizeGroupSlug(params.groupName ?? ""),
102
- ].filter(Boolean);
103
- for (const candidate of candidates) {
104
- const entry = groups[candidate];
105
- if (!entry) {
106
- continue;
107
- }
108
- return entry.allow !== false && entry.enabled !== false;
109
- }
110
- const wildcard = groups["*"];
111
- if (wildcard) {
112
- return wildcard.allow !== false && wildcard.enabled !== false;
103
+ const entry = findZalouserGroupEntry(
104
+ params.groups ?? {},
105
+ buildZalouserGroupCandidates({
106
+ groupId: params.groupId,
107
+ groupName: params.groupName,
108
+ includeGroupIdAlias: true,
109
+ includeWildcard: true,
110
+ }),
111
+ );
112
+ if (typeof entry?.requireMention === "boolean") {
113
+ return entry.requireMention;
113
114
  }
114
- return false;
115
+ return true;
115
116
  }
116
117
 
117
- function startZcaListener(
118
- runtime: RuntimeEnv,
119
- profile: string,
120
- onMessage: (msg: ZcaMessage) => void,
121
- onError: (err: Error) => void,
122
- abortSignal: AbortSignal,
123
- ): ChildProcess {
124
- let buffer = "";
125
-
126
- const { proc, promise } = runZcaStreaming(["listen", "-r", "-k"], {
127
- profile,
128
- onData: (chunk) => {
129
- buffer += chunk;
130
- const lines = buffer.split("\n");
131
- buffer = lines.pop() ?? "";
132
- for (const line of lines) {
133
- const trimmed = line.trim();
134
- if (!trimmed) {
135
- continue;
136
- }
137
- try {
138
- const parsed = JSON.parse(trimmed) as ZcaMessage;
139
- onMessage(parsed);
140
- } catch {
141
- // ignore non-JSON lines
142
- }
143
- }
144
- },
145
- onError,
146
- });
147
-
148
- proc.stderr?.on("data", (data: Buffer) => {
149
- const text = data.toString().trim();
150
- if (text) {
151
- runtime.error(`[zalouser] zca stderr: ${text}`);
152
- }
118
+ async function sendZalouserDeliveryAcks(params: {
119
+ profile: string;
120
+ isGroup: boolean;
121
+ message: NonNullable<ZaloInboundMessage["eventMessage"]>;
122
+ }): Promise<void> {
123
+ await sendDeliveredZalouser({
124
+ profile: params.profile,
125
+ isGroup: params.isGroup,
126
+ message: params.message,
127
+ isSeen: true,
153
128
  });
154
-
155
- void promise.then((result) => {
156
- if (!result.ok && !abortSignal.aborted) {
157
- onError(new Error(result.stderr || `zca listen exited with code ${result.exitCode}`));
158
- }
129
+ await sendSeenZalouser({
130
+ profile: params.profile,
131
+ isGroup: params.isGroup,
132
+ message: params.message,
159
133
  });
160
-
161
- abortSignal.addEventListener(
162
- "abort",
163
- () => {
164
- proc.kill("SIGTERM");
165
- },
166
- { once: true },
167
- );
168
-
169
- return proc;
170
134
  }
171
135
 
172
136
  async function processMessage(
173
- message: ZcaMessage,
137
+ message: ZaloInboundMessage,
174
138
  account: ResolvedZalouserAccount,
175
139
  config: OpenClawConfig,
176
140
  core: ZalouserCoreRuntime,
177
141
  runtime: RuntimeEnv,
178
142
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
179
143
  ): Promise<void> {
180
- const { threadId, content, timestamp, metadata } = message;
181
144
  const pairing = createScopedPairingAccess({
182
145
  core,
183
146
  channel: "zalouser",
184
147
  accountId: account.accountId,
185
148
  });
186
- if (!content?.trim()) {
149
+
150
+ const rawBody = message.content?.trim();
151
+ if (!rawBody) {
187
152
  return;
188
153
  }
189
154
 
190
- const isGroup = metadata?.isGroup ?? false;
191
- const senderId = metadata?.fromId ?? threadId;
192
- const senderName = metadata?.senderName ?? "";
193
- const groupName = metadata?.threadName ?? "";
194
- const chatId = threadId;
155
+ const isGroup = message.isGroup;
156
+ const chatId = message.threadId;
157
+ const senderId = message.senderId?.trim();
158
+ if (!senderId) {
159
+ logVerbose(core, runtime, `zalouser: drop message ${chatId} (missing senderId)`);
160
+ return;
161
+ }
162
+ const senderName = message.senderName ?? "";
163
+ const configuredGroupName = message.groupName?.trim() || "";
164
+ const groupContext =
165
+ isGroup && !configuredGroupName
166
+ ? await resolveZaloGroupContext(account.profile, chatId).catch((err) => {
167
+ logVerbose(
168
+ core,
169
+ runtime,
170
+ `zalouser: group context lookup failed for ${chatId}: ${String(err)}`,
171
+ );
172
+ return null;
173
+ })
174
+ : null;
175
+ const groupName = configuredGroupName || groupContext?.name?.trim() || "";
176
+ const groupMembers = groupContext?.members?.slice(0, 20).join(", ") || undefined;
177
+
178
+ if (message.eventMessage) {
179
+ try {
180
+ await sendZalouserDeliveryAcks({
181
+ profile: account.profile,
182
+ isGroup,
183
+ message: message.eventMessage,
184
+ });
185
+ } catch (err) {
186
+ logVerbose(core, runtime, `zalouser: delivery/seen ack failed for ${chatId}: ${String(err)}`);
187
+ }
188
+ }
195
189
 
196
190
  const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
197
191
  const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
@@ -203,26 +197,46 @@ async function processMessage(
203
197
  providerMissingFallbackApplied,
204
198
  providerKey: "zalouser",
205
199
  accountId: account.accountId,
206
- log: (message) => logVerbose(core, runtime, message),
200
+ log: (entry) => logVerbose(core, runtime, entry),
207
201
  });
202
+
208
203
  const groups = account.config.groups ?? {};
209
204
  if (isGroup) {
210
- if (groupPolicy === "disabled") {
211
- logVerbose(core, runtime, `zalouser: drop group ${chatId} (groupPolicy=disabled)`);
212
- return;
213
- }
214
- if (groupPolicy === "allowlist") {
215
- const allowed = isGroupAllowed({ groupId: chatId, groupName, groups });
216
- if (!allowed) {
205
+ const groupEntry = findZalouserGroupEntry(
206
+ groups,
207
+ buildZalouserGroupCandidates({
208
+ groupId: chatId,
209
+ groupName,
210
+ includeGroupIdAlias: true,
211
+ includeWildcard: true,
212
+ }),
213
+ );
214
+ const routeAccess = evaluateGroupRouteAccessForPolicy({
215
+ groupPolicy,
216
+ routeAllowlistConfigured: Object.keys(groups).length > 0,
217
+ routeMatched: Boolean(groupEntry),
218
+ routeEnabled: isZalouserGroupEntryAllowed(groupEntry),
219
+ });
220
+ if (!routeAccess.allowed) {
221
+ if (routeAccess.reason === "disabled") {
222
+ logVerbose(core, runtime, `zalouser: drop group ${chatId} (groupPolicy=disabled)`);
223
+ } else if (routeAccess.reason === "empty_allowlist") {
224
+ logVerbose(
225
+ core,
226
+ runtime,
227
+ `zalouser: drop group ${chatId} (groupPolicy=allowlist, no allowlist)`,
228
+ );
229
+ } else if (routeAccess.reason === "route_not_allowlisted") {
217
230
  logVerbose(core, runtime, `zalouser: drop group ${chatId} (not allowlisted)`);
218
- return;
231
+ } else if (routeAccess.reason === "route_disabled") {
232
+ logVerbose(core, runtime, `zalouser: drop group ${chatId} (group disabled)`);
219
233
  }
234
+ return;
220
235
  }
221
236
  }
222
237
 
223
238
  const dmPolicy = account.config.dmPolicy ?? "pairing";
224
239
  const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
225
- const rawBody = content.trim();
226
240
  const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
227
241
  cfg: config,
228
242
  rawBody,
@@ -246,35 +260,29 @@ async function processMessage(
246
260
 
247
261
  if (dmPolicy !== "open") {
248
262
  const allowed = senderAllowedForCommands;
249
-
250
263
  if (!allowed) {
251
264
  if (dmPolicy === "pairing") {
252
- const { code, created } = await pairing.upsertPairingRequest({
253
- id: senderId,
265
+ await issuePairingChallenge({
266
+ channel: "zalouser",
267
+ senderId,
268
+ senderIdLine: `Your Zalo user id: ${senderId}`,
254
269
  meta: { name: senderName || undefined },
255
- });
256
-
257
- if (created) {
258
- logVerbose(core, runtime, `zalouser pairing request sender=${senderId}`);
259
- try {
260
- await sendMessageZalouser(
261
- chatId,
262
- core.channel.pairing.buildPairingReply({
263
- channel: "zalouser",
264
- idLine: `Your Zalo user id: ${senderId}`,
265
- code,
266
- }),
267
- { profile: account.profile },
268
- );
270
+ upsertPairingRequest: pairing.upsertPairingRequest,
271
+ onCreated: () => {
272
+ logVerbose(core, runtime, `zalouser pairing request sender=${senderId}`);
273
+ },
274
+ sendPairingReply: async (text) => {
275
+ await sendMessageZalouser(chatId, text, { profile: account.profile });
269
276
  statusSink?.({ lastOutboundAt: Date.now() });
270
- } catch (err) {
277
+ },
278
+ onReplyError: (err) => {
271
279
  logVerbose(
272
280
  core,
273
281
  runtime,
274
282
  `zalouser pairing reply failed for ${senderId}: ${String(err)}`,
275
283
  );
276
- }
277
- }
284
+ },
285
+ });
278
286
  } else {
279
287
  logVerbose(
280
288
  core,
@@ -287,11 +295,8 @@ async function processMessage(
287
295
  }
288
296
  }
289
297
 
290
- if (
291
- isGroup &&
292
- core.channel.commands.isControlCommandMessage(rawBody, config) &&
293
- commandAuthorized !== true
294
- ) {
298
+ const hasControlCommand = core.channel.commands.isControlCommandMessage(rawBody, config);
299
+ if (isGroup && hasControlCommand && commandAuthorized !== true) {
295
300
  logVerbose(
296
301
  core,
297
302
  runtime,
@@ -315,7 +320,46 @@ async function processMessage(
315
320
  },
316
321
  });
317
322
 
318
- const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
323
+ const requireMention = isGroup
324
+ ? resolveGroupRequireMention({
325
+ groupId: chatId,
326
+ groupName,
327
+ groups,
328
+ })
329
+ : false;
330
+ const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
331
+ const explicitMention = {
332
+ hasAnyMention: message.hasAnyMention === true,
333
+ isExplicitlyMentioned: message.wasExplicitlyMentioned === true,
334
+ canResolveExplicit: message.canResolveExplicitMention === true,
335
+ };
336
+ const wasMentioned = isGroup
337
+ ? core.channel.mentions.matchesMentionWithExplicit({
338
+ text: rawBody,
339
+ mentionRegexes,
340
+ explicit: explicitMention,
341
+ })
342
+ : true;
343
+ const mentionGate = resolveMentionGatingWithBypass({
344
+ isGroup,
345
+ requireMention,
346
+ canDetectMention: mentionRegexes.length > 0 || explicitMention.canResolveExplicit,
347
+ wasMentioned,
348
+ implicitMention: message.implicitMention === true,
349
+ hasAnyMention: explicitMention.hasAnyMention,
350
+ allowTextCommands: core.channel.commands.shouldHandleTextCommands({
351
+ cfg: config,
352
+ surface: "zalouser",
353
+ }),
354
+ hasControlCommand,
355
+ commandAuthorized: commandAuthorized === true,
356
+ });
357
+ if (isGroup && mentionGate.shouldSkip) {
358
+ logVerbose(core, runtime, `zalouser: skip group ${chatId} (mention required, not mentioned)`);
359
+ return;
360
+ }
361
+
362
+ const fromLabel = isGroup ? groupName || `group:${chatId}` : senderName || `user:${senderId}`;
319
363
  const storePath = core.channel.session.resolveStorePath(config.session?.store, {
320
364
  agentId: route.agentId,
321
365
  });
@@ -327,7 +371,7 @@ async function processMessage(
327
371
  const body = core.channel.reply.formatAgentEnvelope({
328
372
  channel: "Zalo Personal",
329
373
  from: fromLabel,
330
- timestamp: timestamp ? timestamp * 1000 : undefined,
374
+ timestamp: message.timestampMs,
331
375
  previousTimestamp,
332
376
  envelope: envelopeOptions,
333
377
  body: rawBody,
@@ -344,12 +388,24 @@ async function processMessage(
344
388
  AccountId: route.accountId,
345
389
  ChatType: isGroup ? "group" : "direct",
346
390
  ConversationLabel: fromLabel,
391
+ GroupSubject: isGroup ? groupName || undefined : undefined,
392
+ GroupChannel: isGroup ? groupName || undefined : undefined,
393
+ GroupMembers: isGroup ? groupMembers : undefined,
347
394
  SenderName: senderName || undefined,
348
395
  SenderId: senderId,
396
+ WasMentioned: isGroup ? mentionGate.effectiveWasMentioned : undefined,
349
397
  CommandAuthorized: commandAuthorized,
350
398
  Provider: "zalouser",
351
399
  Surface: "zalouser",
352
- MessageSid: message.msgId ?? `${timestamp}`,
400
+ MessageSid: resolveZalouserMessageSid({
401
+ msgId: message.msgId,
402
+ cliMsgId: message.cliMsgId,
403
+ fallback: `${message.timestampMs}`,
404
+ }),
405
+ MessageSidFull: formatZalouserMessageSidFull({
406
+ msgId: message.msgId,
407
+ cliMsgId: message.cliMsgId,
408
+ }),
353
409
  OriginatingChannel: "zalouser",
354
410
  OriginatingTo: `zalouser:${chatId}`,
355
411
  });
@@ -369,12 +425,24 @@ async function processMessage(
369
425
  channel: "zalouser",
370
426
  accountId: account.accountId,
371
427
  });
428
+ const typingCallbacks = createTypingCallbacks({
429
+ start: async () => {
430
+ await sendTypingZalouser(chatId, {
431
+ profile: account.profile,
432
+ isGroup,
433
+ });
434
+ },
435
+ onStartError: (err) => {
436
+ logVerbose(core, runtime, `zalouser typing failed for ${chatId}: ${String(err)}`);
437
+ },
438
+ });
372
439
 
373
440
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
374
441
  ctx: ctxPayload,
375
442
  cfg: config,
376
443
  dispatcherOptions: {
377
444
  ...prefixOptions,
445
+ typingCallbacks,
378
446
  deliver: async (payload) => {
379
447
  await deliverZalouserReply({
380
448
  payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string },
@@ -466,10 +534,6 @@ export async function monitorZalouserProvider(
466
534
  const { abortSignal, statusSink, runtime } = options;
467
535
 
468
536
  const core = getZalouserRuntime();
469
- let stopped = false;
470
- let proc: ChildProcess | null = null;
471
- let restartTimer: ReturnType<typeof setTimeout> | null = null;
472
- let resolveRunning: (() => void) | null = null;
473
537
 
474
538
  try {
475
539
  const profile = account.profile;
@@ -478,147 +542,144 @@ export async function monitorZalouserProvider(
478
542
  .filter((entry) => entry && entry !== "*");
479
543
 
480
544
  if (allowFromEntries.length > 0) {
481
- const result = await runZca(["friend", "list", "-j"], { profile, timeout: 15000 });
482
- if (result.ok) {
483
- const friends = parseJsonOutput<ZcaFriend[]>(result.stdout) ?? [];
484
- const byName = buildNameIndex(friends, (friend) => friend.displayName);
485
- const additions: string[] = [];
486
- const mapping: string[] = [];
487
- const unresolved: string[] = [];
488
- for (const entry of allowFromEntries) {
489
- if (/^\d+$/.test(entry)) {
490
- additions.push(entry);
491
- continue;
492
- }
493
- const matches = byName.get(entry.toLowerCase()) ?? [];
494
- const match = matches[0];
495
- const id = match?.userId ? String(match.userId) : undefined;
496
- if (id) {
497
- additions.push(id);
498
- mapping.push(`${entry}→${id}`);
499
- } else {
500
- unresolved.push(entry);
501
- }
545
+ const friends = await listZaloFriends(profile);
546
+ const byName = buildNameIndex(friends, (friend) => friend.displayName);
547
+ const additions: string[] = [];
548
+ const mapping: string[] = [];
549
+ const unresolved: string[] = [];
550
+ for (const entry of allowFromEntries) {
551
+ if (/^\d+$/.test(entry)) {
552
+ additions.push(entry);
553
+ continue;
554
+ }
555
+ const matches = byName.get(entry.toLowerCase()) ?? [];
556
+ const match = matches[0];
557
+ const id = match?.userId ? String(match.userId) : undefined;
558
+ if (id) {
559
+ additions.push(id);
560
+ mapping.push(`${entry}→${id}`);
561
+ } else {
562
+ unresolved.push(entry);
502
563
  }
503
- const allowFrom = mergeAllowlist({ existing: account.config.allowFrom, additions });
504
- account = {
505
- ...account,
506
- config: {
507
- ...account.config,
508
- allowFrom,
509
- },
510
- };
511
- summarizeMapping("zalouser users", mapping, unresolved, runtime);
512
- } else {
513
- runtime.log?.(`zalouser user resolve failed; using config entries. ${result.stderr}`);
514
564
  }
565
+ const allowFrom = mergeAllowlist({ existing: account.config.allowFrom, additions });
566
+ account = {
567
+ ...account,
568
+ config: {
569
+ ...account.config,
570
+ allowFrom,
571
+ },
572
+ };
573
+ summarizeMapping("zalouser users", mapping, unresolved, runtime);
515
574
  }
516
575
 
517
576
  const groupsConfig = account.config.groups ?? {};
518
577
  const groupKeys = Object.keys(groupsConfig).filter((key) => key !== "*");
519
578
  if (groupKeys.length > 0) {
520
- const result = await runZca(["group", "list", "-j"], { profile, timeout: 15000 });
521
- if (result.ok) {
522
- const groups = parseJsonOutput<ZcaGroup[]>(result.stdout) ?? [];
523
- const byName = buildNameIndex(groups, (group) => group.name);
524
- const mapping: string[] = [];
525
- const unresolved: string[] = [];
526
- const nextGroups = { ...groupsConfig };
527
- for (const entry of groupKeys) {
528
- const cleaned = normalizeZalouserEntry(entry);
529
- if (/^\d+$/.test(cleaned)) {
530
- if (!nextGroups[cleaned]) {
531
- nextGroups[cleaned] = groupsConfig[entry];
532
- }
533
- mapping.push(`${entry}→${cleaned}`);
534
- continue;
579
+ const groups = await listZaloGroups(profile);
580
+ const byName = buildNameIndex(groups, (group) => group.name);
581
+ const mapping: string[] = [];
582
+ const unresolved: string[] = [];
583
+ const nextGroups = { ...groupsConfig };
584
+ for (const entry of groupKeys) {
585
+ const cleaned = normalizeZalouserEntry(entry);
586
+ if (/^\d+$/.test(cleaned)) {
587
+ if (!nextGroups[cleaned]) {
588
+ nextGroups[cleaned] = groupsConfig[entry];
535
589
  }
536
- const matches = byName.get(cleaned.toLowerCase()) ?? [];
537
- const match = matches[0];
538
- const id = match?.groupId ? String(match.groupId) : undefined;
539
- if (id) {
540
- if (!nextGroups[id]) {
541
- nextGroups[id] = groupsConfig[entry];
542
- }
543
- mapping.push(`${entry}→${id}`);
544
- } else {
545
- unresolved.push(entry);
590
+ mapping.push(`${entry}→${cleaned}`);
591
+ continue;
592
+ }
593
+ const matches = byName.get(cleaned.toLowerCase()) ?? [];
594
+ const match = matches[0];
595
+ const id = match?.groupId ? String(match.groupId) : undefined;
596
+ if (id) {
597
+ if (!nextGroups[id]) {
598
+ nextGroups[id] = groupsConfig[entry];
546
599
  }
600
+ mapping.push(`${entry}→${id}`);
601
+ } else {
602
+ unresolved.push(entry);
547
603
  }
548
- account = {
549
- ...account,
550
- config: {
551
- ...account.config,
552
- groups: nextGroups,
553
- },
554
- };
555
- summarizeMapping("zalouser groups", mapping, unresolved, runtime);
556
- } else {
557
- runtime.log?.(`zalouser group resolve failed; using config entries. ${result.stderr}`);
558
604
  }
605
+ account = {
606
+ ...account,
607
+ config: {
608
+ ...account.config,
609
+ groups: nextGroups,
610
+ },
611
+ };
612
+ summarizeMapping("zalouser groups", mapping, unresolved, runtime);
559
613
  }
560
614
  } catch (err) {
561
615
  runtime.log?.(`zalouser resolve failed; using config entries. ${String(err)}`);
562
616
  }
563
617
 
618
+ let listenerStop: (() => void) | null = null;
619
+ let stopped = false;
620
+
564
621
  const stop = () => {
565
- stopped = true;
566
- if (restartTimer) {
567
- clearTimeout(restartTimer);
568
- restartTimer = null;
569
- }
570
- if (proc) {
571
- proc.kill("SIGTERM");
572
- proc = null;
622
+ if (stopped) {
623
+ return;
573
624
  }
574
- resolveRunning?.();
625
+ stopped = true;
626
+ listenerStop?.();
627
+ listenerStop = null;
575
628
  };
576
629
 
577
- const startListener = () => {
578
- if (stopped || abortSignal.aborted) {
579
- resolveRunning?.();
580
- return;
581
- }
630
+ const listener = await startZaloListener({
631
+ accountId: account.accountId,
632
+ profile: account.profile,
633
+ abortSignal,
634
+ onMessage: (msg) => {
635
+ if (stopped) {
636
+ return;
637
+ }
638
+ logVerbose(core, runtime, `[${account.accountId}] inbound message`);
639
+ statusSink?.({ lastInboundAt: Date.now() });
640
+ processMessage(msg, account, config, core, runtime, statusSink).catch((err) => {
641
+ runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`);
642
+ });
643
+ },
644
+ onError: (err) => {
645
+ if (stopped || abortSignal.aborted) {
646
+ return;
647
+ }
648
+ runtime.error(`[${account.accountId}] Zalo listener error: ${String(err)}`);
649
+ },
650
+ });
582
651
 
583
- logVerbose(
584
- core,
585
- runtime,
586
- `[${account.accountId}] starting zca listener (profile=${account.profile})`,
587
- );
652
+ listenerStop = listener.stop;
588
653
 
589
- proc = startZcaListener(
590
- runtime,
591
- account.profile,
592
- (msg) => {
593
- logVerbose(core, runtime, `[${account.accountId}] inbound message`);
594
- statusSink?.({ lastInboundAt: Date.now() });
595
- processMessage(msg, account, config, core, runtime, statusSink).catch((err) => {
596
- runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`);
597
- });
598
- },
599
- (err) => {
600
- runtime.error(`[${account.accountId}] zca listener error: ${String(err)}`);
601
- if (!stopped && !abortSignal.aborted) {
602
- logVerbose(core, runtime, `[${account.accountId}] restarting listener in 5s...`);
603
- restartTimer = setTimeout(startListener, 5000);
604
- } else {
605
- resolveRunning?.();
606
- }
654
+ await new Promise<void>((resolve) => {
655
+ abortSignal.addEventListener(
656
+ "abort",
657
+ () => {
658
+ stop();
659
+ resolve();
607
660
  },
608
- abortSignal,
661
+ { once: true },
609
662
  );
610
- };
611
-
612
- // Create a promise that stays pending until abort or stop
613
- const runningPromise = new Promise<void>((resolve) => {
614
- resolveRunning = resolve;
615
- abortSignal.addEventListener("abort", () => resolve(), { once: true });
616
663
  });
617
664
 
618
- startListener();
619
-
620
- // Wait for the running promise to resolve (on abort/stop)
621
- await runningPromise;
622
-
623
665
  return { stop };
624
666
  }
667
+
668
+ export const __testing = {
669
+ processMessage: async (params: {
670
+ message: ZaloInboundMessage;
671
+ account: ResolvedZalouserAccount;
672
+ config: OpenClawConfig;
673
+ runtime: RuntimeEnv;
674
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
675
+ }) => {
676
+ await processMessage(
677
+ params.message,
678
+ params.account,
679
+ params.config,
680
+ getZalouserRuntime(),
681
+ params.runtime,
682
+ params.statusSink,
683
+ );
684
+ },
685
+ };