@openclaw/zalouser 2026.3.7 → 2026.3.10

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,3 +1,13 @@
1
+ import {
2
+ DM_GROUP_ACCESS_REASON,
3
+ DEFAULT_GROUP_HISTORY_LIMIT,
4
+ type HistoryEntry,
5
+ KeyedAsyncQueue,
6
+ buildPendingHistoryContextFromMap,
7
+ clearHistoryEntriesIfEnabled,
8
+ recordPendingHistoryEntryIfEnabled,
9
+ resolveDmGroupAccessWithLists,
10
+ } from "openclaw/plugin-sdk/compat";
1
11
  import type {
2
12
  MarkdownTableMode,
3
13
  OpenClawConfig,
@@ -73,8 +83,111 @@ function buildNameIndex<T>(items: T[], nameFn: (item: T) => string | undefined):
73
83
  return index;
74
84
  }
75
85
 
86
+ function resolveUserAllowlistEntries(
87
+ entries: string[],
88
+ byName: Map<string, Array<{ userId: string }>>,
89
+ ): {
90
+ additions: string[];
91
+ mapping: string[];
92
+ unresolved: string[];
93
+ } {
94
+ const additions: string[] = [];
95
+ const mapping: string[] = [];
96
+ const unresolved: string[] = [];
97
+ for (const entry of entries) {
98
+ if (/^\d+$/.test(entry)) {
99
+ additions.push(entry);
100
+ continue;
101
+ }
102
+ const matches = byName.get(entry.toLowerCase()) ?? [];
103
+ const match = matches[0];
104
+ const id = match?.userId ? String(match.userId) : undefined;
105
+ if (id) {
106
+ additions.push(id);
107
+ mapping.push(`${entry}->${id}`);
108
+ } else {
109
+ unresolved.push(entry);
110
+ }
111
+ }
112
+ return { additions, mapping, unresolved };
113
+ }
114
+
76
115
  type ZalouserCoreRuntime = ReturnType<typeof getZalouserRuntime>;
77
116
 
117
+ type ZalouserGroupHistoryState = {
118
+ historyLimit: number;
119
+ groupHistories: Map<string, HistoryEntry[]>;
120
+ };
121
+
122
+ function resolveInboundQueueKey(message: ZaloInboundMessage): string {
123
+ const threadId = message.threadId?.trim() || "unknown";
124
+ if (message.isGroup) {
125
+ return `group:${threadId}`;
126
+ }
127
+ const senderId = message.senderId?.trim();
128
+ return `direct:${senderId || threadId}`;
129
+ }
130
+
131
+ function createDeferred<T>() {
132
+ let resolve!: (value: T | PromiseLike<T>) => void;
133
+ let reject!: (reason?: unknown) => void;
134
+ const promise = new Promise<T>((res, rej) => {
135
+ resolve = res;
136
+ reject = rej;
137
+ });
138
+ return { promise, resolve, reject };
139
+ }
140
+
141
+ function resolveZalouserDmSessionScope(config: OpenClawConfig) {
142
+ const configured = config.session?.dmScope;
143
+ return configured === "main" || !configured ? "per-channel-peer" : configured;
144
+ }
145
+
146
+ function resolveZalouserInboundSessionKey(params: {
147
+ core: ZalouserCoreRuntime;
148
+ config: OpenClawConfig;
149
+ route: { agentId: string; accountId: string; sessionKey: string };
150
+ storePath: string;
151
+ isGroup: boolean;
152
+ senderId: string;
153
+ }): string {
154
+ if (params.isGroup) {
155
+ return params.route.sessionKey;
156
+ }
157
+
158
+ const directSessionKey = params.core.channel.routing
159
+ .buildAgentSessionKey({
160
+ agentId: params.route.agentId,
161
+ channel: "zalouser",
162
+ accountId: params.route.accountId,
163
+ peer: { kind: "direct", id: params.senderId },
164
+ dmScope: resolveZalouserDmSessionScope(params.config),
165
+ identityLinks: params.config.session?.identityLinks,
166
+ })
167
+ .toLowerCase();
168
+ const legacySessionKey = params.core.channel.routing
169
+ .buildAgentSessionKey({
170
+ agentId: params.route.agentId,
171
+ channel: "zalouser",
172
+ accountId: params.route.accountId,
173
+ peer: { kind: "group", id: params.senderId },
174
+ })
175
+ .toLowerCase();
176
+ const hasDirectSession =
177
+ params.core.channel.session.readSessionUpdatedAt({
178
+ storePath: params.storePath,
179
+ sessionKey: directSessionKey,
180
+ }) !== undefined;
181
+ const hasLegacySession =
182
+ params.core.channel.session.readSessionUpdatedAt({
183
+ storePath: params.storePath,
184
+ sessionKey: legacySessionKey,
185
+ }) !== undefined;
186
+
187
+ // Keep existing DM history on upgrade, but use canonical direct keys for new sessions.
188
+ return hasLegacySession && !hasDirectSession ? legacySessionKey : directSessionKey;
189
+ }
190
+
78
191
  function logVerbose(core: ZalouserCoreRuntime, runtime: RuntimeEnv, message: string): void {
79
192
  if (core.logging.shouldLogVerbose()) {
80
193
  runtime.log(`[zalouser] ${message}`);
@@ -139,6 +252,7 @@ async function processMessage(
139
252
  config: OpenClawConfig,
140
253
  core: ZalouserCoreRuntime,
141
254
  runtime: RuntimeEnv,
255
+ historyState: ZalouserGroupHistoryState,
142
256
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
143
257
  ): Promise<void> {
144
258
  const pairing = createScopedPairingAccess({
@@ -151,6 +265,7 @@ async function processMessage(
151
265
  if (!rawBody) {
152
266
  return;
153
267
  }
268
+ const commandBody = message.commandContent?.trim() || rawBody;
154
269
 
155
270
  const isGroup = message.isGroup;
156
271
  const chatId = message.threadId;
@@ -237,65 +352,90 @@ async function processMessage(
237
352
 
238
353
  const dmPolicy = account.config.dmPolicy ?? "pairing";
239
354
  const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
240
- const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
241
- cfg: config,
242
- rawBody,
355
+ const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((v) => String(v));
356
+ const shouldComputeCommandAuth = core.channel.commands.shouldComputeCommandAuthorized(
357
+ commandBody,
358
+ config,
359
+ );
360
+ const storeAllowFrom =
361
+ !isGroup && dmPolicy !== "allowlist" && (dmPolicy !== "open" || shouldComputeCommandAuth)
362
+ ? await pairing.readAllowFromStore().catch(() => [])
363
+ : [];
364
+ const accessDecision = resolveDmGroupAccessWithLists({
243
365
  isGroup,
244
366
  dmPolicy,
245
- configuredAllowFrom: configAllowFrom,
246
- senderId,
247
- isSenderAllowed,
248
- readAllowFromStore: pairing.readAllowFromStore,
249
- shouldComputeCommandAuthorized: (body, cfg) =>
250
- core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
251
- resolveCommandAuthorizedFromAuthorizers: (params) =>
252
- core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params),
367
+ groupPolicy,
368
+ allowFrom: configAllowFrom,
369
+ groupAllowFrom: configGroupAllowFrom,
370
+ storeAllowFrom,
371
+ isSenderAllowed: (allowFrom) => isSenderAllowed(senderId, allowFrom),
253
372
  });
254
-
255
- if (!isGroup) {
256
- if (dmPolicy === "disabled") {
257
- logVerbose(core, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`);
258
- return;
373
+ if (isGroup && accessDecision.decision !== "allow") {
374
+ if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) {
375
+ logVerbose(core, runtime, "Blocked zalouser group message (no group allowlist)");
376
+ } else if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) {
377
+ logVerbose(
378
+ core,
379
+ runtime,
380
+ `Blocked zalouser sender ${senderId} (not in groupAllowFrom/allowFrom)`,
381
+ );
259
382
  }
383
+ return;
384
+ }
260
385
 
261
- if (dmPolicy !== "open") {
262
- const allowed = senderAllowedForCommands;
263
- if (!allowed) {
264
- if (dmPolicy === "pairing") {
265
- await issuePairingChallenge({
266
- channel: "zalouser",
267
- senderId,
268
- senderIdLine: `Your Zalo user id: ${senderId}`,
269
- meta: { name: senderName || undefined },
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 });
276
- statusSink?.({ lastOutboundAt: Date.now() });
277
- },
278
- onReplyError: (err) => {
279
- logVerbose(
280
- core,
281
- runtime,
282
- `zalouser pairing reply failed for ${senderId}: ${String(err)}`,
283
- );
284
- },
285
- });
286
- } else {
386
+ if (!isGroup && accessDecision.decision !== "allow") {
387
+ if (accessDecision.decision === "pairing") {
388
+ await issuePairingChallenge({
389
+ channel: "zalouser",
390
+ senderId,
391
+ senderIdLine: `Your Zalo user id: ${senderId}`,
392
+ meta: { name: senderName || undefined },
393
+ upsertPairingRequest: pairing.upsertPairingRequest,
394
+ onCreated: () => {
395
+ logVerbose(core, runtime, `zalouser pairing request sender=${senderId}`);
396
+ },
397
+ sendPairingReply: async (text) => {
398
+ await sendMessageZalouser(chatId, text, { profile: account.profile });
399
+ statusSink?.({ lastOutboundAt: Date.now() });
400
+ },
401
+ onReplyError: (err) => {
287
402
  logVerbose(
288
403
  core,
289
404
  runtime,
290
- `Blocked unauthorized zalouser sender ${senderId} (dmPolicy=${dmPolicy})`,
405
+ `zalouser pairing reply failed for ${senderId}: ${String(err)}`,
291
406
  );
292
- }
293
- return;
294
- }
407
+ },
408
+ });
409
+ return;
410
+ }
411
+ if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) {
412
+ logVerbose(core, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`);
413
+ } else {
414
+ logVerbose(
415
+ core,
416
+ runtime,
417
+ `Blocked unauthorized zalouser sender ${senderId} (dmPolicy=${dmPolicy})`,
418
+ );
295
419
  }
420
+ return;
296
421
  }
297
422
 
298
- const hasControlCommand = core.channel.commands.isControlCommandMessage(rawBody, config);
423
+ const { commandAuthorized } = await resolveSenderCommandAuthorization({
424
+ cfg: config,
425
+ rawBody: commandBody,
426
+ isGroup,
427
+ dmPolicy,
428
+ configuredAllowFrom: configAllowFrom,
429
+ configuredGroupAllowFrom: configGroupAllowFrom,
430
+ senderId,
431
+ isSenderAllowed,
432
+ readAllowFromStore: async () => storeAllowFrom,
433
+ shouldComputeCommandAuthorized: (body, cfg) =>
434
+ core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
435
+ resolveCommandAuthorizedFromAuthorizers: (params) =>
436
+ core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params),
437
+ });
438
+ const hasControlCommand = core.channel.commands.isControlCommandMessage(commandBody, config);
299
439
  if (isGroup && hasControlCommand && commandAuthorized !== true) {
300
440
  logVerbose(
301
441
  core,
@@ -307,18 +447,19 @@ async function processMessage(
307
447
 
308
448
  const peer = isGroup
309
449
  ? { kind: "group" as const, id: chatId }
310
- : { kind: "group" as const, id: senderId };
450
+ : { kind: "direct" as const, id: senderId };
311
451
 
312
452
  const route = core.channel.routing.resolveAgentRoute({
313
453
  cfg: config,
314
454
  channel: "zalouser",
315
455
  accountId: account.accountId,
316
456
  peer: {
317
- // Use "group" kind to avoid dmScope=main collapsing all DMs into the main session.
457
+ // Keep DM peer kind as "direct" so session keys follow dmScope and UI labels stay DM-shaped.
318
458
  kind: peer.kind,
319
459
  id: peer.id,
320
460
  },
321
461
  });
462
+ const historyKey = isGroup ? route.sessionKey : undefined;
322
463
 
323
464
  const requireMention = isGroup
324
465
  ? resolveGroupRequireMention({
@@ -340,10 +481,11 @@ async function processMessage(
340
481
  explicit: explicitMention,
341
482
  })
342
483
  : true;
484
+ const canDetectMention = mentionRegexes.length > 0 || explicitMention.canResolveExplicit;
343
485
  const mentionGate = resolveMentionGatingWithBypass({
344
486
  isGroup,
345
487
  requireMention,
346
- canDetectMention: mentionRegexes.length > 0 || explicitMention.canResolveExplicit,
488
+ canDetectMention,
347
489
  wasMentioned,
348
490
  implicitMention: message.implicitMention === true,
349
491
  hasAnyMention: explicitMention.hasAnyMention,
@@ -354,7 +496,32 @@ async function processMessage(
354
496
  hasControlCommand,
355
497
  commandAuthorized: commandAuthorized === true,
356
498
  });
499
+ if (isGroup && requireMention && !canDetectMention && !mentionGate.effectiveWasMentioned) {
500
+ runtime.error?.(
501
+ `[${account.accountId}] zalouser mention required but detection unavailable ` +
502
+ `(missing mention regexes and bot self id); dropping group ${chatId}`,
503
+ );
504
+ return;
505
+ }
357
506
  if (isGroup && mentionGate.shouldSkip) {
507
+ recordPendingHistoryEntryIfEnabled({
508
+ historyMap: historyState.groupHistories,
509
+ historyKey: historyKey ?? "",
510
+ limit: historyState.historyLimit,
511
+ entry:
512
+ historyKey && rawBody
513
+ ? {
514
+ sender: senderName || senderId,
515
+ body: rawBody,
516
+ timestamp: message.timestampMs,
517
+ messageId: resolveZalouserMessageSid({
518
+ msgId: message.msgId,
519
+ cliMsgId: message.cliMsgId,
520
+ fallback: `${message.timestampMs}`,
521
+ }),
522
+ }
523
+ : null,
524
+ });
358
525
  logVerbose(core, runtime, `zalouser: skip group ${chatId} (mention required, not mentioned)`);
359
526
  return;
360
527
  }
@@ -363,10 +530,18 @@ async function processMessage(
363
530
  const storePath = core.channel.session.resolveStorePath(config.session?.store, {
364
531
  agentId: route.agentId,
365
532
  });
533
+ const inboundSessionKey = resolveZalouserInboundSessionKey({
534
+ core,
535
+ config,
536
+ route,
537
+ storePath,
538
+ isGroup,
539
+ senderId,
540
+ });
366
541
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
367
542
  const previousTimestamp = core.channel.session.readSessionUpdatedAt({
368
543
  storePath,
369
- sessionKey: route.sessionKey,
544
+ sessionKey: inboundSessionKey,
370
545
  });
371
546
  const body = core.channel.reply.formatAgentEnvelope({
372
547
  channel: "Zalo Personal",
@@ -376,15 +551,46 @@ async function processMessage(
376
551
  envelope: envelopeOptions,
377
552
  body: rawBody,
378
553
  });
554
+ const combinedBody =
555
+ isGroup && historyKey
556
+ ? buildPendingHistoryContextFromMap({
557
+ historyMap: historyState.groupHistories,
558
+ historyKey,
559
+ limit: historyState.historyLimit,
560
+ currentMessage: body,
561
+ formatEntry: (entry) =>
562
+ core.channel.reply.formatAgentEnvelope({
563
+ channel: "Zalo Personal",
564
+ from: fromLabel,
565
+ timestamp: entry.timestamp,
566
+ envelope: envelopeOptions,
567
+ body: `${entry.sender}: ${entry.body}${
568
+ entry.messageId ? ` [id:${entry.messageId}]` : ""
569
+ }`,
570
+ }),
571
+ })
572
+ : body;
573
+ const inboundHistory =
574
+ isGroup && historyKey && historyState.historyLimit > 0
575
+ ? (historyState.groupHistories.get(historyKey) ?? []).map((entry) => ({
576
+ sender: entry.sender,
577
+ body: entry.body,
578
+ timestamp: entry.timestamp,
579
+ }))
580
+ : undefined;
581
+
582
+ const normalizedTo = isGroup ? `zalouser:group:${chatId}` : `zalouser:${chatId}`;
379
583
 
380
584
  const ctxPayload = core.channel.reply.finalizeInboundContext({
381
- Body: body,
585
+ Body: combinedBody,
382
586
  BodyForAgent: rawBody,
587
+ InboundHistory: inboundHistory,
383
588
  RawBody: rawBody,
384
- CommandBody: rawBody,
589
+ CommandBody: commandBody,
590
+ BodyForCommands: commandBody,
385
591
  From: isGroup ? `zalouser:group:${chatId}` : `zalouser:${senderId}`,
386
- To: `zalouser:${chatId}`,
387
- SessionKey: route.sessionKey,
592
+ To: normalizedTo,
593
+ SessionKey: inboundSessionKey,
388
594
  AccountId: route.accountId,
389
595
  ChatType: isGroup ? "group" : "direct",
390
596
  ConversationLabel: fromLabel,
@@ -407,7 +613,7 @@ async function processMessage(
407
613
  cliMsgId: message.cliMsgId,
408
614
  }),
409
615
  OriginatingChannel: "zalouser",
410
- OriginatingTo: `zalouser:${chatId}`,
616
+ OriginatingTo: normalizedTo,
411
617
  });
412
618
 
413
619
  await core.channel.session.recordInboundSession({
@@ -433,6 +639,9 @@ async function processMessage(
433
639
  });
434
640
  },
435
641
  onStartError: (err) => {
642
+ runtime.error?.(
643
+ `[${account.accountId}] zalouser typing start failed for ${chatId}: ${String(err)}`,
644
+ );
436
645
  logVerbose(core, runtime, `zalouser typing failed for ${chatId}: ${String(err)}`);
437
646
  },
438
647
  });
@@ -469,6 +678,13 @@ async function processMessage(
469
678
  onModelSelected,
470
679
  },
471
680
  });
681
+ if (isGroup && historyKey) {
682
+ clearHistoryEntriesIfEnabled({
683
+ historyMap: historyState.groupHistories,
684
+ historyKey,
685
+ limit: historyState.historyLimit,
686
+ });
687
+ }
472
688
  }
473
689
 
474
690
  async function deliverZalouserReply(params: {
@@ -534,43 +750,60 @@ export async function monitorZalouserProvider(
534
750
  const { abortSignal, statusSink, runtime } = options;
535
751
 
536
752
  const core = getZalouserRuntime();
753
+ const inboundQueue = new KeyedAsyncQueue();
754
+ const historyLimit = Math.max(
755
+ 0,
756
+ account.config.historyLimit ??
757
+ config.messages?.groupChat?.historyLimit ??
758
+ DEFAULT_GROUP_HISTORY_LIMIT,
759
+ );
760
+ const groupHistories = new Map<string, HistoryEntry[]>();
537
761
 
538
762
  try {
539
763
  const profile = account.profile;
540
764
  const allowFromEntries = (account.config.allowFrom ?? [])
541
765
  .map((entry) => normalizeZalouserEntry(String(entry)))
542
766
  .filter((entry) => entry && entry !== "*");
767
+ const groupAllowFromEntries = (account.config.groupAllowFrom ?? [])
768
+ .map((entry) => normalizeZalouserEntry(String(entry)))
769
+ .filter((entry) => entry && entry !== "*");
543
770
 
544
- if (allowFromEntries.length > 0) {
771
+ if (allowFromEntries.length > 0 || groupAllowFromEntries.length > 0) {
545
772
  const friends = await listZaloFriends(profile);
546
773
  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);
563
- }
774
+ if (allowFromEntries.length > 0) {
775
+ const { additions, mapping, unresolved } = resolveUserAllowlistEntries(
776
+ allowFromEntries,
777
+ byName,
778
+ );
779
+ const allowFrom = mergeAllowlist({ existing: account.config.allowFrom, additions });
780
+ account = {
781
+ ...account,
782
+ config: {
783
+ ...account.config,
784
+ allowFrom,
785
+ },
786
+ };
787
+ summarizeMapping("zalouser users", mapping, unresolved, runtime);
788
+ }
789
+ if (groupAllowFromEntries.length > 0) {
790
+ const { additions, mapping, unresolved } = resolveUserAllowlistEntries(
791
+ groupAllowFromEntries,
792
+ byName,
793
+ );
794
+ const groupAllowFrom = mergeAllowlist({
795
+ existing: account.config.groupAllowFrom,
796
+ additions,
797
+ });
798
+ account = {
799
+ ...account,
800
+ config: {
801
+ ...account.config,
802
+ groupAllowFrom,
803
+ },
804
+ };
805
+ summarizeMapping("zalouser group users", mapping, unresolved, runtime);
564
806
  }
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);
574
807
  }
575
808
 
576
809
  const groupsConfig = account.config.groups ?? {};
@@ -627,40 +860,92 @@ export async function monitorZalouserProvider(
627
860
  listenerStop = null;
628
861
  };
629
862
 
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
- });
863
+ let settled = false;
864
+ const { promise: waitForExit, resolve: resolveRun, reject: rejectRun } = createDeferred<void>();
651
865
 
652
- listenerStop = listener.stop;
866
+ const settleSuccess = () => {
867
+ if (settled) {
868
+ return;
869
+ }
870
+ settled = true;
871
+ stop();
872
+ resolveRun();
873
+ };
653
874
 
654
- await new Promise<void>((resolve) => {
655
- abortSignal.addEventListener(
656
- "abort",
657
- () => {
658
- stop();
659
- resolve();
875
+ const settleFailure = (error: unknown) => {
876
+ if (settled) {
877
+ return;
878
+ }
879
+ settled = true;
880
+ stop();
881
+ rejectRun(error instanceof Error ? error : new Error(String(error)));
882
+ };
883
+
884
+ const onAbort = () => {
885
+ settleSuccess();
886
+ };
887
+ abortSignal.addEventListener("abort", onAbort, { once: true });
888
+
889
+ let listener: Awaited<ReturnType<typeof startZaloListener>>;
890
+ try {
891
+ listener = await startZaloListener({
892
+ accountId: account.accountId,
893
+ profile: account.profile,
894
+ abortSignal,
895
+ onMessage: (msg) => {
896
+ if (stopped) {
897
+ return;
898
+ }
899
+ logVerbose(core, runtime, `[${account.accountId}] inbound message`);
900
+ statusSink?.({ lastInboundAt: Date.now() });
901
+ const queueKey = resolveInboundQueueKey(msg);
902
+ void inboundQueue
903
+ .enqueue(queueKey, async () => {
904
+ if (stopped || abortSignal.aborted) {
905
+ return;
906
+ }
907
+ await processMessage(
908
+ msg,
909
+ account,
910
+ config,
911
+ core,
912
+ runtime,
913
+ { historyLimit, groupHistories },
914
+ statusSink,
915
+ );
916
+ })
917
+ .catch((err) => {
918
+ runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`);
919
+ });
660
920
  },
661
- { once: true },
662
- );
663
- });
921
+ onError: (err) => {
922
+ if (stopped || abortSignal.aborted) {
923
+ return;
924
+ }
925
+ runtime.error(`[${account.accountId}] Zalo listener error: ${String(err)}`);
926
+ settleFailure(err);
927
+ },
928
+ });
929
+ } catch (error) {
930
+ abortSignal.removeEventListener("abort", onAbort);
931
+ throw error;
932
+ }
933
+
934
+ listenerStop = listener.stop;
935
+ if (stopped) {
936
+ listenerStop();
937
+ listenerStop = null;
938
+ }
939
+
940
+ if (abortSignal.aborted) {
941
+ settleSuccess();
942
+ }
943
+
944
+ try {
945
+ await waitForExit;
946
+ } finally {
947
+ abortSignal.removeEventListener("abort", onAbort);
948
+ }
664
949
 
665
950
  return { stop };
666
951
  }
@@ -671,14 +956,27 @@ export const __testing = {
671
956
  account: ResolvedZalouserAccount;
672
957
  config: OpenClawConfig;
673
958
  runtime: RuntimeEnv;
959
+ historyState?: {
960
+ historyLimit?: number;
961
+ groupHistories?: Map<string, HistoryEntry[]>;
962
+ };
674
963
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
675
964
  }) => {
965
+ const historyLimit = Math.max(
966
+ 0,
967
+ params.historyState?.historyLimit ??
968
+ params.account.config.historyLimit ??
969
+ params.config.messages?.groupChat?.historyLimit ??
970
+ DEFAULT_GROUP_HISTORY_LIMIT,
971
+ );
972
+ const groupHistories = params.historyState?.groupHistories ?? new Map<string, HistoryEntry[]>();
676
973
  await processMessage(
677
974
  params.message,
678
975
  params.account,
679
976
  params.config,
680
977
  getZalouserRuntime(),
681
978
  params.runtime,
979
+ { historyLimit, groupHistories },
682
980
  params.statusSink,
683
981
  );
684
982
  },