@openclaw/zalouser 2026.2.25 → 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,9 +5,12 @@ import type {
6
5
  RuntimeEnv,
7
6
  } from "openclaw/plugin-sdk";
8
7
  import {
8
+ createTypingCallbacks,
9
+ createScopedPairingAccess,
9
10
  createReplyPrefixOptions,
10
11
  resolveOutboundMediaUrls,
11
12
  mergeAllowlist,
13
+ resolveMentionGatingWithBypass,
12
14
  resolveOpenProviderRuntimeGroupPolicy,
13
15
  resolveDefaultGroupPolicy,
14
16
  resolveSenderCommandAuthorization,
@@ -16,10 +18,26 @@ import {
16
18
  summarizeMapping,
17
19
  warnMissingProviderGroupPolicyFallbackOnce,
18
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";
19
27
  import { getZalouserRuntime } from "./runtime.js";
20
- import { sendMessageZalouser } from "./send.js";
21
- import type { ResolvedZalouserAccount, ZcaFriend, ZcaGroup, ZcaMessage } from "./types.js";
22
- 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";
23
41
 
24
42
  export type ZalouserMonitorOptions = {
25
43
  account: ResolvedZalouserAccount;
@@ -61,131 +79,133 @@ function logVerbose(core: ZalouserCoreRuntime, runtime: RuntimeEnv, message: str
61
79
  }
62
80
  }
63
81
 
64
- function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
82
+ function isSenderAllowed(senderId: string | undefined, allowFrom: string[]): boolean {
65
83
  if (allowFrom.includes("*")) {
66
84
  return true;
67
85
  }
68
- const normalizedSenderId = senderId.toLowerCase();
86
+ const normalizedSenderId = senderId?.trim().toLowerCase();
87
+ if (!normalizedSenderId) {
88
+ return false;
89
+ }
69
90
  return allowFrom.some((entry) => {
70
91
  const normalized = entry.toLowerCase().replace(/^(zalouser|zlu):/i, "");
71
92
  return normalized === normalizedSenderId;
72
93
  });
73
94
  }
74
95
 
75
- function normalizeGroupSlug(raw?: string | null): string {
76
- const trimmed = raw?.trim().toLowerCase() ?? "";
77
- if (!trimmed) {
78
- return "";
79
- }
80
- return trimmed
81
- .replace(/^#/, "")
82
- .replace(/[^a-z0-9]+/g, "-")
83
- .replace(/^-+|-+$/g, "");
84
- }
85
-
86
96
  function isGroupAllowed(params: {
87
97
  groupId: string;
88
98
  groupName?: string | null;
89
- groups: Record<string, { allow?: boolean; enabled?: boolean }>;
99
+ groups: Record<string, { allow?: boolean; enabled?: boolean; requireMention?: boolean }>;
90
100
  }): boolean {
91
101
  const groups = params.groups ?? {};
92
102
  const keys = Object.keys(groups);
93
103
  if (keys.length === 0) {
94
104
  return false;
95
105
  }
96
- const candidates = [
97
- params.groupId,
98
- `group:${params.groupId}`,
99
- params.groupName ?? "",
100
- normalizeGroupSlug(params.groupName ?? ""),
101
- ].filter(Boolean);
102
- for (const candidate of candidates) {
103
- const entry = groups[candidate];
104
- if (!entry) {
105
- continue;
106
- }
107
- return entry.allow !== false && entry.enabled !== false;
108
- }
109
- const wildcard = groups["*"];
110
- if (wildcard) {
111
- return wildcard.allow !== false && wildcard.enabled !== false;
112
- }
113
- 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);
114
116
  }
115
117
 
116
- function startZcaListener(
117
- runtime: RuntimeEnv,
118
- profile: string,
119
- onMessage: (msg: ZcaMessage) => void,
120
- onError: (err: Error) => void,
121
- abortSignal: AbortSignal,
122
- ): ChildProcess {
123
- let buffer = "";
124
-
125
- const { proc, promise } = runZcaStreaming(["listen", "-r", "-k"], {
126
- profile,
127
- onData: (chunk) => {
128
- buffer += chunk;
129
- const lines = buffer.split("\n");
130
- buffer = lines.pop() ?? "";
131
- for (const line of lines) {
132
- const trimmed = line.trim();
133
- if (!trimmed) {
134
- continue;
135
- }
136
- try {
137
- const parsed = JSON.parse(trimmed) as ZcaMessage;
138
- onMessage(parsed);
139
- } catch {
140
- // ignore non-JSON lines
141
- }
142
- }
143
- },
144
- onError,
145
- });
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
+ }
146
137
 
147
- proc.stderr?.on("data", (data: Buffer) => {
148
- const text = data.toString().trim();
149
- if (text) {
150
- runtime.error(`[zalouser] zca stderr: ${text}`);
151
- }
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,
152
148
  });
153
-
154
- void promise.then((result) => {
155
- if (!result.ok && !abortSignal.aborted) {
156
- onError(new Error(result.stderr || `zca listen exited with code ${result.exitCode}`));
157
- }
149
+ await sendSeenZalouser({
150
+ profile: params.profile,
151
+ isGroup: params.isGroup,
152
+ message: params.message,
158
153
  });
159
-
160
- abortSignal.addEventListener(
161
- "abort",
162
- () => {
163
- proc.kill("SIGTERM");
164
- },
165
- { once: true },
166
- );
167
-
168
- return proc;
169
154
  }
170
155
 
171
156
  async function processMessage(
172
- message: ZcaMessage,
157
+ message: ZaloInboundMessage,
173
158
  account: ResolvedZalouserAccount,
174
159
  config: OpenClawConfig,
175
160
  core: ZalouserCoreRuntime,
176
161
  runtime: RuntimeEnv,
177
162
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
178
163
  ): Promise<void> {
179
- const { threadId, content, timestamp, metadata } = message;
180
- if (!content?.trim()) {
164
+ const pairing = createScopedPairingAccess({
165
+ core,
166
+ channel: "zalouser",
167
+ accountId: account.accountId,
168
+ });
169
+
170
+ const rawBody = message.content?.trim();
171
+ if (!rawBody) {
181
172
  return;
182
173
  }
183
174
 
184
- const isGroup = metadata?.isGroup ?? false;
185
- const senderId = metadata?.fromId ?? threadId;
186
- const senderName = metadata?.senderName ?? "";
187
- const groupName = metadata?.threadName ?? "";
188
- 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
+ }
189
209
 
190
210
  const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
191
211
  const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
@@ -197,8 +217,9 @@ async function processMessage(
197
217
  providerMissingFallbackApplied,
198
218
  providerKey: "zalouser",
199
219
  accountId: account.accountId,
200
- log: (message) => logVerbose(core, runtime, message),
220
+ log: (entry) => logVerbose(core, runtime, entry),
201
221
  });
222
+
202
223
  const groups = account.config.groups ?? {};
203
224
  if (isGroup) {
204
225
  if (groupPolicy === "disabled") {
@@ -216,7 +237,6 @@ async function processMessage(
216
237
 
217
238
  const dmPolicy = account.config.dmPolicy ?? "pairing";
218
239
  const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
219
- const rawBody = content.trim();
220
240
  const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
221
241
  cfg: config,
222
242
  rawBody,
@@ -225,7 +245,7 @@ async function processMessage(
225
245
  configuredAllowFrom: configAllowFrom,
226
246
  senderId,
227
247
  isSenderAllowed,
228
- readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalouser"),
248
+ readAllowFromStore: pairing.readAllowFromStore,
229
249
  shouldComputeCommandAuthorized: (body, cfg) =>
230
250
  core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
231
251
  resolveCommandAuthorizedFromAuthorizers: (params) =>
@@ -240,11 +260,9 @@ async function processMessage(
240
260
 
241
261
  if (dmPolicy !== "open") {
242
262
  const allowed = senderAllowedForCommands;
243
-
244
263
  if (!allowed) {
245
264
  if (dmPolicy === "pairing") {
246
- const { code, created } = await core.channel.pairing.upsertPairingRequest({
247
- channel: "zalouser",
265
+ const { code, created } = await pairing.upsertPairingRequest({
248
266
  id: senderId,
249
267
  meta: { name: senderName || undefined },
250
268
  });
@@ -282,11 +300,8 @@ async function processMessage(
282
300
  }
283
301
  }
284
302
 
285
- if (
286
- isGroup &&
287
- core.channel.commands.isControlCommandMessage(rawBody, config) &&
288
- commandAuthorized !== true
289
- ) {
303
+ const hasControlCommand = core.channel.commands.isControlCommandMessage(rawBody, config);
304
+ if (isGroup && hasControlCommand && commandAuthorized !== true) {
290
305
  logVerbose(
291
306
  core,
292
307
  runtime,
@@ -310,7 +325,46 @@ async function processMessage(
310
325
  },
311
326
  });
312
327
 
313
- 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}`;
314
368
  const storePath = core.channel.session.resolveStorePath(config.session?.store, {
315
369
  agentId: route.agentId,
316
370
  });
@@ -322,7 +376,7 @@ async function processMessage(
322
376
  const body = core.channel.reply.formatAgentEnvelope({
323
377
  channel: "Zalo Personal",
324
378
  from: fromLabel,
325
- timestamp: timestamp ? timestamp * 1000 : undefined,
379
+ timestamp: message.timestampMs,
326
380
  previousTimestamp,
327
381
  envelope: envelopeOptions,
328
382
  body: rawBody,
@@ -339,12 +393,24 @@ async function processMessage(
339
393
  AccountId: route.accountId,
340
394
  ChatType: isGroup ? "group" : "direct",
341
395
  ConversationLabel: fromLabel,
396
+ GroupSubject: isGroup ? groupName || undefined : undefined,
397
+ GroupChannel: isGroup ? groupName || undefined : undefined,
398
+ GroupMembers: isGroup ? groupMembers : undefined,
342
399
  SenderName: senderName || undefined,
343
400
  SenderId: senderId,
401
+ WasMentioned: isGroup ? mentionGate.effectiveWasMentioned : undefined,
344
402
  CommandAuthorized: commandAuthorized,
345
403
  Provider: "zalouser",
346
404
  Surface: "zalouser",
347
- 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
+ }),
348
414
  OriginatingChannel: "zalouser",
349
415
  OriginatingTo: `zalouser:${chatId}`,
350
416
  });
@@ -364,12 +430,24 @@ async function processMessage(
364
430
  channel: "zalouser",
365
431
  accountId: account.accountId,
366
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
+ });
367
444
 
368
445
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
369
446
  ctx: ctxPayload,
370
447
  cfg: config,
371
448
  dispatcherOptions: {
372
449
  ...prefixOptions,
450
+ typingCallbacks,
373
451
  deliver: async (payload) => {
374
452
  await deliverZalouserReply({
375
453
  payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string },
@@ -461,10 +539,6 @@ export async function monitorZalouserProvider(
461
539
  const { abortSignal, statusSink, runtime } = options;
462
540
 
463
541
  const core = getZalouserRuntime();
464
- let stopped = false;
465
- let proc: ChildProcess | null = null;
466
- let restartTimer: ReturnType<typeof setTimeout> | null = null;
467
- let resolveRunning: (() => void) | null = null;
468
542
 
469
543
  try {
470
544
  const profile = account.profile;
@@ -473,147 +547,144 @@ export async function monitorZalouserProvider(
473
547
  .filter((entry) => entry && entry !== "*");
474
548
 
475
549
  if (allowFromEntries.length > 0) {
476
- const result = await runZca(["friend", "list", "-j"], { profile, timeout: 15000 });
477
- if (result.ok) {
478
- const friends = parseJsonOutput<ZcaFriend[]>(result.stdout) ?? [];
479
- const byName = buildNameIndex(friends, (friend) => friend.displayName);
480
- const additions: string[] = [];
481
- const mapping: string[] = [];
482
- const unresolved: string[] = [];
483
- for (const entry of allowFromEntries) {
484
- if (/^\d+$/.test(entry)) {
485
- additions.push(entry);
486
- continue;
487
- }
488
- const matches = byName.get(entry.toLowerCase()) ?? [];
489
- const match = matches[0];
490
- const id = match?.userId ? String(match.userId) : undefined;
491
- if (id) {
492
- additions.push(id);
493
- mapping.push(`${entry}→${id}`);
494
- } else {
495
- unresolved.push(entry);
496
- }
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);
497
568
  }
498
- const allowFrom = mergeAllowlist({ existing: account.config.allowFrom, additions });
499
- account = {
500
- ...account,
501
- config: {
502
- ...account.config,
503
- allowFrom,
504
- },
505
- };
506
- summarizeMapping("zalouser users", mapping, unresolved, runtime);
507
- } else {
508
- runtime.log?.(`zalouser user resolve failed; using config entries. ${result.stderr}`);
509
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);
510
579
  }
511
580
 
512
581
  const groupsConfig = account.config.groups ?? {};
513
582
  const groupKeys = Object.keys(groupsConfig).filter((key) => key !== "*");
514
583
  if (groupKeys.length > 0) {
515
- const result = await runZca(["group", "list", "-j"], { profile, timeout: 15000 });
516
- if (result.ok) {
517
- const groups = parseJsonOutput<ZcaGroup[]>(result.stdout) ?? [];
518
- const byName = buildNameIndex(groups, (group) => group.name);
519
- const mapping: string[] = [];
520
- const unresolved: string[] = [];
521
- const nextGroups = { ...groupsConfig };
522
- for (const entry of groupKeys) {
523
- const cleaned = normalizeZalouserEntry(entry);
524
- if (/^\d+$/.test(cleaned)) {
525
- if (!nextGroups[cleaned]) {
526
- nextGroups[cleaned] = groupsConfig[entry];
527
- }
528
- mapping.push(`${entry}→${cleaned}`);
529
- 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];
530
594
  }
531
- const matches = byName.get(cleaned.toLowerCase()) ?? [];
532
- const match = matches[0];
533
- const id = match?.groupId ? String(match.groupId) : undefined;
534
- if (id) {
535
- if (!nextGroups[id]) {
536
- nextGroups[id] = groupsConfig[entry];
537
- }
538
- mapping.push(`${entry}→${id}`);
539
- } else {
540
- 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];
541
604
  }
605
+ mapping.push(`${entry}→${id}`);
606
+ } else {
607
+ unresolved.push(entry);
542
608
  }
543
- account = {
544
- ...account,
545
- config: {
546
- ...account.config,
547
- groups: nextGroups,
548
- },
549
- };
550
- summarizeMapping("zalouser groups", mapping, unresolved, runtime);
551
- } else {
552
- runtime.log?.(`zalouser group resolve failed; using config entries. ${result.stderr}`);
553
609
  }
610
+ account = {
611
+ ...account,
612
+ config: {
613
+ ...account.config,
614
+ groups: nextGroups,
615
+ },
616
+ };
617
+ summarizeMapping("zalouser groups", mapping, unresolved, runtime);
554
618
  }
555
619
  } catch (err) {
556
620
  runtime.log?.(`zalouser resolve failed; using config entries. ${String(err)}`);
557
621
  }
558
622
 
623
+ let listenerStop: (() => void) | null = null;
624
+ let stopped = false;
625
+
559
626
  const stop = () => {
560
- stopped = true;
561
- if (restartTimer) {
562
- clearTimeout(restartTimer);
563
- restartTimer = null;
564
- }
565
- if (proc) {
566
- proc.kill("SIGTERM");
567
- proc = null;
627
+ if (stopped) {
628
+ return;
568
629
  }
569
- resolveRunning?.();
630
+ stopped = true;
631
+ listenerStop?.();
632
+ listenerStop = null;
570
633
  };
571
634
 
572
- const startListener = () => {
573
- if (stopped || abortSignal.aborted) {
574
- resolveRunning?.();
575
- return;
576
- }
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
+ });
577
656
 
578
- logVerbose(
579
- core,
580
- runtime,
581
- `[${account.accountId}] starting zca listener (profile=${account.profile})`,
582
- );
657
+ listenerStop = listener.stop;
583
658
 
584
- proc = startZcaListener(
585
- runtime,
586
- account.profile,
587
- (msg) => {
588
- logVerbose(core, runtime, `[${account.accountId}] inbound message`);
589
- statusSink?.({ lastInboundAt: Date.now() });
590
- processMessage(msg, account, config, core, runtime, statusSink).catch((err) => {
591
- runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`);
592
- });
659
+ await new Promise<void>((resolve) => {
660
+ abortSignal.addEventListener(
661
+ "abort",
662
+ () => {
663
+ stop();
664
+ resolve();
593
665
  },
594
- (err) => {
595
- runtime.error(`[${account.accountId}] zca listener error: ${String(err)}`);
596
- if (!stopped && !abortSignal.aborted) {
597
- logVerbose(core, runtime, `[${account.accountId}] restarting listener in 5s...`);
598
- restartTimer = setTimeout(startListener, 5000);
599
- } else {
600
- resolveRunning?.();
601
- }
602
- },
603
- abortSignal,
666
+ { once: true },
604
667
  );
605
- };
606
-
607
- // Create a promise that stays pending until abort or stop
608
- const runningPromise = new Promise<void>((resolve) => {
609
- resolveRunning = resolve;
610
- abortSignal.addEventListener("abort", () => resolve(), { once: true });
611
668
  });
612
669
 
613
- startListener();
614
-
615
- // Wait for the running promise to resolve (on abort/stop)
616
- await runningPromise;
617
-
618
670
  return { stop };
619
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
+ };