@openclaw/zalouser 2026.3.1 → 2026.3.2

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,4 +1,3 @@
1
- import type { ChildProcess } from "node:child_process";
2
1
  import type {
3
2
  MarkdownTableMode,
4
3
  OpenClawConfig,
@@ -6,10 +5,12 @@ import type {
6
5
  RuntimeEnv,
7
6
  } from "openclaw/plugin-sdk";
8
7
  import {
8
+ createTypingCallbacks,
9
9
  createScopedPairingAccess,
10
10
  createReplyPrefixOptions,
11
11
  resolveOutboundMediaUrls,
12
12
  mergeAllowlist,
13
+ resolveMentionGatingWithBypass,
13
14
  resolveOpenProviderRuntimeGroupPolicy,
14
15
  resolveDefaultGroupPolicy,
15
16
  resolveSenderCommandAuthorization,
@@ -17,10 +18,26 @@ import {
17
18
  summarizeMapping,
18
19
  warnMissingProviderGroupPolicyFallbackOnce,
19
20
  } from "openclaw/plugin-sdk";
21
+ import {
22
+ buildZalouserGroupCandidates,
23
+ findZalouserGroupEntry,
24
+ isZalouserGroupEntryAllowed,
25
+ } from "./group-policy.js";
26
+ import { formatZalouserMessageSidFull, resolveZalouserMessageSid } from "./message-sid.js";
20
27
  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";
28
+ import {
29
+ sendDeliveredZalouser,
30
+ sendMessageZalouser,
31
+ sendSeenZalouser,
32
+ sendTypingZalouser,
33
+ } from "./send.js";
34
+ import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
35
+ import {
36
+ listZaloFriends,
37
+ listZaloGroups,
38
+ resolveZaloGroupContext,
39
+ startZaloListener,
40
+ } from "./zalo-js.js";
24
41
 
25
42
  export type ZalouserMonitorOptions = {
26
43
  account: ResolvedZalouserAccount;
@@ -62,136 +79,133 @@ function logVerbose(core: ZalouserCoreRuntime, runtime: RuntimeEnv, message: str
62
79
  }
63
80
  }
64
81
 
65
- function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
82
+ function isSenderAllowed(senderId: string | undefined, allowFrom: string[]): boolean {
66
83
  if (allowFrom.includes("*")) {
67
84
  return true;
68
85
  }
69
- const normalizedSenderId = senderId.toLowerCase();
86
+ const normalizedSenderId = senderId?.trim().toLowerCase();
87
+ if (!normalizedSenderId) {
88
+ return false;
89
+ }
70
90
  return allowFrom.some((entry) => {
71
91
  const normalized = entry.toLowerCase().replace(/^(zalouser|zlu):/i, "");
72
92
  return normalized === normalizedSenderId;
73
93
  });
74
94
  }
75
95
 
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
96
  function isGroupAllowed(params: {
88
97
  groupId: string;
89
98
  groupName?: string | null;
90
- groups: Record<string, { allow?: boolean; enabled?: boolean }>;
99
+ groups: Record<string, { allow?: boolean; enabled?: boolean; requireMention?: boolean }>;
91
100
  }): boolean {
92
101
  const groups = params.groups ?? {};
93
102
  const keys = Object.keys(groups);
94
103
  if (keys.length === 0) {
95
104
  return false;
96
105
  }
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;
113
- }
114
- return false;
106
+ const entry = findZalouserGroupEntry(
107
+ groups,
108
+ buildZalouserGroupCandidates({
109
+ groupId: params.groupId,
110
+ groupName: params.groupName,
111
+ includeGroupIdAlias: true,
112
+ includeWildcard: true,
113
+ }),
114
+ );
115
+ return isZalouserGroupEntryAllowed(entry);
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
- });
118
+ function resolveGroupRequireMention(params: {
119
+ groupId: string;
120
+ groupName?: string | null;
121
+ groups: Record<string, { allow?: boolean; enabled?: boolean; requireMention?: boolean }>;
122
+ }): boolean {
123
+ const entry = findZalouserGroupEntry(
124
+ params.groups ?? {},
125
+ buildZalouserGroupCandidates({
126
+ groupId: params.groupId,
127
+ groupName: params.groupName,
128
+ includeGroupIdAlias: true,
129
+ includeWildcard: true,
130
+ }),
131
+ );
132
+ if (typeof entry?.requireMention === "boolean") {
133
+ return entry.requireMention;
134
+ }
135
+ return true;
136
+ }
147
137
 
148
- proc.stderr?.on("data", (data: Buffer) => {
149
- const text = data.toString().trim();
150
- if (text) {
151
- runtime.error(`[zalouser] zca stderr: ${text}`);
152
- }
138
+ async function sendZalouserDeliveryAcks(params: {
139
+ profile: string;
140
+ isGroup: boolean;
141
+ message: NonNullable<ZaloInboundMessage["eventMessage"]>;
142
+ }): Promise<void> {
143
+ await sendDeliveredZalouser({
144
+ profile: params.profile,
145
+ isGroup: params.isGroup,
146
+ message: params.message,
147
+ isSeen: true,
153
148
  });
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
- }
149
+ await sendSeenZalouser({
150
+ profile: params.profile,
151
+ isGroup: params.isGroup,
152
+ message: params.message,
159
153
  });
160
-
161
- abortSignal.addEventListener(
162
- "abort",
163
- () => {
164
- proc.kill("SIGTERM");
165
- },
166
- { once: true },
167
- );
168
-
169
- return proc;
170
154
  }
171
155
 
172
156
  async function processMessage(
173
- message: ZcaMessage,
157
+ message: ZaloInboundMessage,
174
158
  account: ResolvedZalouserAccount,
175
159
  config: OpenClawConfig,
176
160
  core: ZalouserCoreRuntime,
177
161
  runtime: RuntimeEnv,
178
162
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
179
163
  ): Promise<void> {
180
- const { threadId, content, timestamp, metadata } = message;
181
164
  const pairing = createScopedPairingAccess({
182
165
  core,
183
166
  channel: "zalouser",
184
167
  accountId: account.accountId,
185
168
  });
186
- if (!content?.trim()) {
169
+
170
+ const rawBody = message.content?.trim();
171
+ if (!rawBody) {
187
172
  return;
188
173
  }
189
174
 
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;
175
+ const isGroup = message.isGroup;
176
+ const chatId = message.threadId;
177
+ const senderId = message.senderId?.trim();
178
+ if (!senderId) {
179
+ logVerbose(core, runtime, `zalouser: drop message ${chatId} (missing senderId)`);
180
+ return;
181
+ }
182
+ const senderName = message.senderName ?? "";
183
+ const configuredGroupName = message.groupName?.trim() || "";
184
+ const groupContext =
185
+ isGroup && !configuredGroupName
186
+ ? await resolveZaloGroupContext(account.profile, chatId).catch((err) => {
187
+ logVerbose(
188
+ core,
189
+ runtime,
190
+ `zalouser: group context lookup failed for ${chatId}: ${String(err)}`,
191
+ );
192
+ return null;
193
+ })
194
+ : null;
195
+ const groupName = configuredGroupName || groupContext?.name?.trim() || "";
196
+ const groupMembers = groupContext?.members?.slice(0, 20).join(", ") || undefined;
197
+
198
+ if (message.eventMessage) {
199
+ try {
200
+ await sendZalouserDeliveryAcks({
201
+ profile: account.profile,
202
+ isGroup,
203
+ message: message.eventMessage,
204
+ });
205
+ } catch (err) {
206
+ logVerbose(core, runtime, `zalouser: delivery/seen ack failed for ${chatId}: ${String(err)}`);
207
+ }
208
+ }
195
209
 
196
210
  const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
197
211
  const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
@@ -203,8 +217,9 @@ async function processMessage(
203
217
  providerMissingFallbackApplied,
204
218
  providerKey: "zalouser",
205
219
  accountId: account.accountId,
206
- log: (message) => logVerbose(core, runtime, message),
220
+ log: (entry) => logVerbose(core, runtime, entry),
207
221
  });
222
+
208
223
  const groups = account.config.groups ?? {};
209
224
  if (isGroup) {
210
225
  if (groupPolicy === "disabled") {
@@ -222,7 +237,6 @@ async function processMessage(
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,7 +260,6 @@ 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
265
  const { code, created } = await pairing.upsertPairingRequest({
@@ -287,11 +300,8 @@ async function processMessage(
287
300
  }
288
301
  }
289
302
 
290
- if (
291
- isGroup &&
292
- core.channel.commands.isControlCommandMessage(rawBody, config) &&
293
- commandAuthorized !== true
294
- ) {
303
+ const hasControlCommand = core.channel.commands.isControlCommandMessage(rawBody, config);
304
+ if (isGroup && hasControlCommand && commandAuthorized !== true) {
295
305
  logVerbose(
296
306
  core,
297
307
  runtime,
@@ -315,7 +325,46 @@ async function processMessage(
315
325
  },
316
326
  });
317
327
 
318
- const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
328
+ const requireMention = isGroup
329
+ ? resolveGroupRequireMention({
330
+ groupId: chatId,
331
+ groupName,
332
+ groups,
333
+ })
334
+ : false;
335
+ const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
336
+ const explicitMention = {
337
+ hasAnyMention: message.hasAnyMention === true,
338
+ isExplicitlyMentioned: message.wasExplicitlyMentioned === true,
339
+ canResolveExplicit: message.canResolveExplicitMention === true,
340
+ };
341
+ const wasMentioned = isGroup
342
+ ? core.channel.mentions.matchesMentionWithExplicit({
343
+ text: rawBody,
344
+ mentionRegexes,
345
+ explicit: explicitMention,
346
+ })
347
+ : true;
348
+ const mentionGate = resolveMentionGatingWithBypass({
349
+ isGroup,
350
+ requireMention,
351
+ canDetectMention: mentionRegexes.length > 0 || explicitMention.canResolveExplicit,
352
+ wasMentioned,
353
+ implicitMention: message.implicitMention === true,
354
+ hasAnyMention: explicitMention.hasAnyMention,
355
+ allowTextCommands: core.channel.commands.shouldHandleTextCommands({
356
+ cfg: config,
357
+ surface: "zalouser",
358
+ }),
359
+ hasControlCommand,
360
+ commandAuthorized: commandAuthorized === true,
361
+ });
362
+ if (isGroup && mentionGate.shouldSkip) {
363
+ logVerbose(core, runtime, `zalouser: skip group ${chatId} (mention required, not mentioned)`);
364
+ return;
365
+ }
366
+
367
+ const fromLabel = isGroup ? groupName || `group:${chatId}` : senderName || `user:${senderId}`;
319
368
  const storePath = core.channel.session.resolveStorePath(config.session?.store, {
320
369
  agentId: route.agentId,
321
370
  });
@@ -327,7 +376,7 @@ async function processMessage(
327
376
  const body = core.channel.reply.formatAgentEnvelope({
328
377
  channel: "Zalo Personal",
329
378
  from: fromLabel,
330
- timestamp: timestamp ? timestamp * 1000 : undefined,
379
+ timestamp: message.timestampMs,
331
380
  previousTimestamp,
332
381
  envelope: envelopeOptions,
333
382
  body: rawBody,
@@ -344,12 +393,24 @@ async function processMessage(
344
393
  AccountId: route.accountId,
345
394
  ChatType: isGroup ? "group" : "direct",
346
395
  ConversationLabel: fromLabel,
396
+ GroupSubject: isGroup ? groupName || undefined : undefined,
397
+ GroupChannel: isGroup ? groupName || undefined : undefined,
398
+ GroupMembers: isGroup ? groupMembers : undefined,
347
399
  SenderName: senderName || undefined,
348
400
  SenderId: senderId,
401
+ WasMentioned: isGroup ? mentionGate.effectiveWasMentioned : undefined,
349
402
  CommandAuthorized: commandAuthorized,
350
403
  Provider: "zalouser",
351
404
  Surface: "zalouser",
352
- MessageSid: message.msgId ?? `${timestamp}`,
405
+ MessageSid: resolveZalouserMessageSid({
406
+ msgId: message.msgId,
407
+ cliMsgId: message.cliMsgId,
408
+ fallback: `${message.timestampMs}`,
409
+ }),
410
+ MessageSidFull: formatZalouserMessageSidFull({
411
+ msgId: message.msgId,
412
+ cliMsgId: message.cliMsgId,
413
+ }),
353
414
  OriginatingChannel: "zalouser",
354
415
  OriginatingTo: `zalouser:${chatId}`,
355
416
  });
@@ -369,12 +430,24 @@ async function processMessage(
369
430
  channel: "zalouser",
370
431
  accountId: account.accountId,
371
432
  });
433
+ const typingCallbacks = createTypingCallbacks({
434
+ start: async () => {
435
+ await sendTypingZalouser(chatId, {
436
+ profile: account.profile,
437
+ isGroup,
438
+ });
439
+ },
440
+ onStartError: (err) => {
441
+ logVerbose(core, runtime, `zalouser typing failed for ${chatId}: ${String(err)}`);
442
+ },
443
+ });
372
444
 
373
445
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
374
446
  ctx: ctxPayload,
375
447
  cfg: config,
376
448
  dispatcherOptions: {
377
449
  ...prefixOptions,
450
+ typingCallbacks,
378
451
  deliver: async (payload) => {
379
452
  await deliverZalouserReply({
380
453
  payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string },
@@ -466,10 +539,6 @@ export async function monitorZalouserProvider(
466
539
  const { abortSignal, statusSink, runtime } = options;
467
540
 
468
541
  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
542
 
474
543
  try {
475
544
  const profile = account.profile;
@@ -478,147 +547,144 @@ export async function monitorZalouserProvider(
478
547
  .filter((entry) => entry && entry !== "*");
479
548
 
480
549
  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
- }
550
+ const friends = await listZaloFriends(profile);
551
+ const byName = buildNameIndex(friends, (friend) => friend.displayName);
552
+ const additions: string[] = [];
553
+ const mapping: string[] = [];
554
+ const unresolved: string[] = [];
555
+ for (const entry of allowFromEntries) {
556
+ if (/^\d+$/.test(entry)) {
557
+ additions.push(entry);
558
+ continue;
559
+ }
560
+ const matches = byName.get(entry.toLowerCase()) ?? [];
561
+ const match = matches[0];
562
+ const id = match?.userId ? String(match.userId) : undefined;
563
+ if (id) {
564
+ additions.push(id);
565
+ mapping.push(`${entry}→${id}`);
566
+ } else {
567
+ unresolved.push(entry);
502
568
  }
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
569
  }
570
+ const allowFrom = mergeAllowlist({ existing: account.config.allowFrom, additions });
571
+ account = {
572
+ ...account,
573
+ config: {
574
+ ...account.config,
575
+ allowFrom,
576
+ },
577
+ };
578
+ summarizeMapping("zalouser users", mapping, unresolved, runtime);
515
579
  }
516
580
 
517
581
  const groupsConfig = account.config.groups ?? {};
518
582
  const groupKeys = Object.keys(groupsConfig).filter((key) => key !== "*");
519
583
  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;
584
+ const groups = await listZaloGroups(profile);
585
+ const byName = buildNameIndex(groups, (group) => group.name);
586
+ const mapping: string[] = [];
587
+ const unresolved: string[] = [];
588
+ const nextGroups = { ...groupsConfig };
589
+ for (const entry of groupKeys) {
590
+ const cleaned = normalizeZalouserEntry(entry);
591
+ if (/^\d+$/.test(cleaned)) {
592
+ if (!nextGroups[cleaned]) {
593
+ nextGroups[cleaned] = groupsConfig[entry];
535
594
  }
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);
595
+ mapping.push(`${entry}→${cleaned}`);
596
+ continue;
597
+ }
598
+ const matches = byName.get(cleaned.toLowerCase()) ?? [];
599
+ const match = matches[0];
600
+ const id = match?.groupId ? String(match.groupId) : undefined;
601
+ if (id) {
602
+ if (!nextGroups[id]) {
603
+ nextGroups[id] = groupsConfig[entry];
546
604
  }
605
+ mapping.push(`${entry}→${id}`);
606
+ } else {
607
+ unresolved.push(entry);
547
608
  }
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
609
  }
610
+ account = {
611
+ ...account,
612
+ config: {
613
+ ...account.config,
614
+ groups: nextGroups,
615
+ },
616
+ };
617
+ summarizeMapping("zalouser groups", mapping, unresolved, runtime);
559
618
  }
560
619
  } catch (err) {
561
620
  runtime.log?.(`zalouser resolve failed; using config entries. ${String(err)}`);
562
621
  }
563
622
 
623
+ let listenerStop: (() => void) | null = null;
624
+ let stopped = false;
625
+
564
626
  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;
627
+ if (stopped) {
628
+ return;
573
629
  }
574
- resolveRunning?.();
630
+ stopped = true;
631
+ listenerStop?.();
632
+ listenerStop = null;
575
633
  };
576
634
 
577
- const startListener = () => {
578
- if (stopped || abortSignal.aborted) {
579
- resolveRunning?.();
580
- return;
581
- }
635
+ const listener = await startZaloListener({
636
+ accountId: account.accountId,
637
+ profile: account.profile,
638
+ abortSignal,
639
+ onMessage: (msg) => {
640
+ if (stopped) {
641
+ return;
642
+ }
643
+ logVerbose(core, runtime, `[${account.accountId}] inbound message`);
644
+ statusSink?.({ lastInboundAt: Date.now() });
645
+ processMessage(msg, account, config, core, runtime, statusSink).catch((err) => {
646
+ runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`);
647
+ });
648
+ },
649
+ onError: (err) => {
650
+ if (stopped || abortSignal.aborted) {
651
+ return;
652
+ }
653
+ runtime.error(`[${account.accountId}] Zalo listener error: ${String(err)}`);
654
+ },
655
+ });
582
656
 
583
- logVerbose(
584
- core,
585
- runtime,
586
- `[${account.accountId}] starting zca listener (profile=${account.profile})`,
587
- );
657
+ listenerStop = listener.stop;
588
658
 
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
- });
659
+ await new Promise<void>((resolve) => {
660
+ abortSignal.addEventListener(
661
+ "abort",
662
+ () => {
663
+ stop();
664
+ resolve();
598
665
  },
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
- }
607
- },
608
- abortSignal,
666
+ { once: true },
609
667
  );
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
668
  });
617
669
 
618
- startListener();
619
-
620
- // Wait for the running promise to resolve (on abort/stop)
621
- await runningPromise;
622
-
623
670
  return { stop };
624
671
  }
672
+
673
+ export const __testing = {
674
+ processMessage: async (params: {
675
+ message: ZaloInboundMessage;
676
+ account: ResolvedZalouserAccount;
677
+ config: OpenClawConfig;
678
+ runtime: RuntimeEnv;
679
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
680
+ }) => {
681
+ await processMessage(
682
+ params.message,
683
+ params.account,
684
+ params.config,
685
+ getZalouserRuntime(),
686
+ params.runtime,
687
+ params.statusSink,
688
+ );
689
+ },
690
+ };