@openclaw/zalouser 2026.3.12 → 2026.5.1-beta.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.
Files changed (67) hide show
  1. package/README.md +4 -3
  2. package/api.ts +9 -0
  3. package/channel-plugin-api.ts +3 -0
  4. package/contract-api.ts +2 -0
  5. package/doctor-contract-api.ts +1 -0
  6. package/index.ts +29 -24
  7. package/openclaw.plugin.json +288 -1
  8. package/package.json +38 -11
  9. package/runtime-api.ts +67 -0
  10. package/secret-contract-api.ts +4 -0
  11. package/setup-entry.ts +9 -0
  12. package/setup-plugin-api.ts +2 -0
  13. package/src/accounts.runtime.ts +1 -0
  14. package/src/accounts.test-mocks.ts +14 -0
  15. package/src/accounts.test.ts +53 -1
  16. package/src/accounts.ts +52 -37
  17. package/src/channel-api.ts +20 -0
  18. package/src/channel.adapters.ts +390 -0
  19. package/src/channel.directory.test.ts +48 -61
  20. package/src/channel.runtime.ts +12 -0
  21. package/src/channel.sendpayload.test.ts +42 -37
  22. package/src/channel.setup.test.ts +33 -0
  23. package/src/channel.setup.ts +12 -0
  24. package/src/channel.test.ts +258 -56
  25. package/src/channel.ts +176 -692
  26. package/src/config-schema.ts +5 -5
  27. package/src/directory.ts +54 -0
  28. package/src/doctor-contract.ts +156 -0
  29. package/src/doctor.test.ts +77 -0
  30. package/src/doctor.ts +37 -0
  31. package/src/group-policy.test.ts +4 -4
  32. package/src/group-policy.ts +4 -2
  33. package/src/monitor.account-scope.test.ts +4 -10
  34. package/src/monitor.group-gating.test.ts +319 -190
  35. package/src/monitor.ts +233 -182
  36. package/src/probe.ts +3 -2
  37. package/src/qr-temp-file.ts +1 -1
  38. package/src/reaction.ts +5 -2
  39. package/src/runtime.ts +6 -3
  40. package/src/security-audit.test.ts +80 -0
  41. package/src/security-audit.ts +71 -0
  42. package/src/send.test.ts +2 -2
  43. package/src/send.ts +3 -3
  44. package/src/session-route.ts +121 -0
  45. package/src/setup-core.ts +33 -0
  46. package/src/setup-surface.test.ts +363 -0
  47. package/src/setup-surface.ts +470 -0
  48. package/src/setup-test-helpers.ts +42 -0
  49. package/src/shared.ts +92 -0
  50. package/src/status-issues.test.ts +5 -17
  51. package/src/status-issues.ts +18 -30
  52. package/src/test-helpers.ts +26 -0
  53. package/src/text-styles.test.ts +1 -1
  54. package/src/text-styles.ts +5 -2
  55. package/src/tool.test.ts +66 -3
  56. package/src/tool.ts +76 -14
  57. package/src/types.ts +3 -3
  58. package/src/zalo-js.credentials.test.ts +465 -0
  59. package/src/zalo-js.test-mocks.ts +89 -0
  60. package/src/zalo-js.ts +491 -274
  61. package/src/zca-client.test.ts +24 -0
  62. package/src/zca-client.ts +24 -58
  63. package/src/zca-constants.ts +55 -0
  64. package/test-api.ts +21 -0
  65. package/tsconfig.json +16 -0
  66. package/CHANGELOG.md +0 -101
  67. package/src/onboarding.ts +0 -340
package/src/monitor.ts CHANGED
@@ -1,36 +1,45 @@
1
+ import { mergeAllowlist, summarizeMapping } from "openclaw/plugin-sdk/allow-from";
2
+ import {
3
+ implicitMentionKindWhen,
4
+ resolveInboundMentionDecision,
5
+ } from "openclaw/plugin-sdk/channel-inbound";
6
+ import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
1
7
  import {
2
8
  DM_GROUP_ACCESS_REASON,
9
+ resolveDmGroupAccessWithLists,
10
+ } from "openclaw/plugin-sdk/channel-policy";
11
+ import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
12
+ import { resolveSenderCommandAuthorization } from "openclaw/plugin-sdk/command-auth";
13
+ import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-types";
14
+ import { KeyedAsyncQueue } from "openclaw/plugin-sdk/core";
15
+ import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
16
+ import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
17
+ import {
18
+ evaluateGroupRouteAccessForPolicy,
19
+ resolveSenderScopedGroupPolicy,
20
+ } from "openclaw/plugin-sdk/group-access";
21
+ import {
3
22
  DEFAULT_GROUP_HISTORY_LIMIT,
4
23
  type HistoryEntry,
5
- KeyedAsyncQueue,
6
24
  buildPendingHistoryContextFromMap,
7
25
  clearHistoryEntriesIfEnabled,
8
26
  recordPendingHistoryEntryIfEnabled,
9
- resolveDmGroupAccessWithLists,
10
- } from "openclaw/plugin-sdk/compat";
11
- import type {
12
- MarkdownTableMode,
13
- OpenClawConfig,
14
- OutboundReplyPayload,
15
- RuntimeEnv,
16
- } from "openclaw/plugin-sdk/zalouser";
27
+ } from "openclaw/plugin-sdk/reply-history";
28
+ import {
29
+ deliverTextOrMediaReply,
30
+ resolveSendableOutboundReplyParts,
31
+ type OutboundReplyPayload,
32
+ } from "openclaw/plugin-sdk/reply-payload";
33
+ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
17
34
  import {
18
- createTypingCallbacks,
19
- createScopedPairingAccess,
20
- createReplyPrefixOptions,
21
- evaluateGroupRouteAccessForPolicy,
22
- isDangerousNameMatchingEnabled,
23
- issuePairingChallenge,
24
- resolveOutboundMediaUrls,
25
- mergeAllowlist,
26
- resolveMentionGatingWithBypass,
27
- resolveOpenProviderRuntimeGroupPolicy,
28
35
  resolveDefaultGroupPolicy,
29
- resolveSenderCommandAuthorization,
30
- sendMediaWithLeadingCaption,
31
- summarizeMapping,
36
+ resolveOpenProviderRuntimeGroupPolicy,
32
37
  warnMissingProviderGroupPolicyFallbackOnce,
33
- } from "openclaw/plugin-sdk/zalouser";
38
+ } from "openclaw/plugin-sdk/runtime-group-policy";
39
+ import {
40
+ normalizeLowercaseStringOrEmpty,
41
+ normalizeOptionalLowercaseString,
42
+ } from "openclaw/plugin-sdk/text-runtime";
34
43
  import {
35
44
  buildZalouserGroupCandidates,
36
45
  findZalouserGroupEntry,
@@ -73,7 +82,7 @@ function normalizeZalouserEntry(entry: string): string {
73
82
  function buildNameIndex<T>(items: T[], nameFn: (item: T) => string | undefined): Map<string, T[]> {
74
83
  const index = new Map<string, T[]>();
75
84
  for (const item of items) {
76
- const name = nameFn(item)?.trim().toLowerCase();
85
+ const name = normalizeOptionalLowercaseString(nameFn(item));
77
86
  if (!name) {
78
87
  continue;
79
88
  }
@@ -100,9 +109,9 @@ function resolveUserAllowlistEntries(
100
109
  additions.push(entry);
101
110
  continue;
102
111
  }
103
- const matches = byName.get(entry.toLowerCase()) ?? [];
112
+ const matches = byName.get(normalizeLowercaseStringOrEmpty(entry)) ?? [];
104
113
  const match = matches[0];
105
- const id = match?.userId ? String(match.userId) : undefined;
114
+ const id = match?.userId;
106
115
  if (id) {
107
116
  additions.push(id);
108
117
  mapping.push(`${entry}->${id}`);
@@ -129,16 +138,6 @@ function resolveInboundQueueKey(message: ZaloInboundMessage): string {
129
138
  return `direct:${senderId || threadId}`;
130
139
  }
131
140
 
132
- function createDeferred<T>() {
133
- let resolve!: (value: T | PromiseLike<T>) => void;
134
- let reject!: (reason?: unknown) => void;
135
- const promise = new Promise<T>((res, rej) => {
136
- resolve = res;
137
- reject = rej;
138
- });
139
- return { promise, resolve, reject };
140
- }
141
-
142
141
  function resolveZalouserDmSessionScope(config: OpenClawConfig) {
143
142
  const configured = config.session?.dmScope;
144
143
  return configured === "main" || !configured ? "per-channel-peer" : configured;
@@ -156,24 +155,24 @@ function resolveZalouserInboundSessionKey(params: {
156
155
  return params.route.sessionKey;
157
156
  }
158
157
 
159
- const directSessionKey = params.core.channel.routing
160
- .buildAgentSessionKey({
158
+ const directSessionKey = normalizeLowercaseStringOrEmpty(
159
+ params.core.channel.routing.buildAgentSessionKey({
161
160
  agentId: params.route.agentId,
162
161
  channel: "zalouser",
163
162
  accountId: params.route.accountId,
164
163
  peer: { kind: "direct", id: params.senderId },
165
164
  dmScope: resolveZalouserDmSessionScope(params.config),
166
165
  identityLinks: params.config.session?.identityLinks,
167
- })
168
- .toLowerCase();
169
- const legacySessionKey = params.core.channel.routing
170
- .buildAgentSessionKey({
166
+ }),
167
+ );
168
+ const legacySessionKey = normalizeLowercaseStringOrEmpty(
169
+ params.core.channel.routing.buildAgentSessionKey({
171
170
  agentId: params.route.agentId,
172
171
  channel: "zalouser",
173
172
  accountId: params.route.accountId,
174
173
  peer: { kind: "group", id: params.senderId },
175
- })
176
- .toLowerCase();
174
+ }),
175
+ );
177
176
  const hasDirectSession =
178
177
  params.core.channel.session.readSessionUpdatedAt({
179
178
  storePath: params.storePath,
@@ -199,12 +198,12 @@ function isSenderAllowed(senderId: string | undefined, allowFrom: string[]): boo
199
198
  if (allowFrom.includes("*")) {
200
199
  return true;
201
200
  }
202
- const normalizedSenderId = senderId?.trim().toLowerCase();
201
+ const normalizedSenderId = normalizeOptionalLowercaseString(senderId);
203
202
  if (!normalizedSenderId) {
204
203
  return false;
205
204
  }
206
205
  return allowFrom.some((entry) => {
207
- const normalized = entry.toLowerCase().replace(/^(zalouser|zlu):/i, "");
206
+ const normalized = normalizeLowercaseStringOrEmpty(entry).replace(/^(zalouser|zlu):/i, "");
208
207
  return normalized === normalizedSenderId;
209
208
  });
210
209
  }
@@ -212,7 +211,7 @@ function isSenderAllowed(senderId: string | undefined, allowFrom: string[]): boo
212
211
  function resolveGroupRequireMention(params: {
213
212
  groupId: string;
214
213
  groupName?: string | null;
215
- groups: Record<string, { allow?: boolean; enabled?: boolean; requireMention?: boolean }>;
214
+ groups: Record<string, { enabled?: boolean; requireMention?: boolean }>;
216
215
  allowNameMatching?: boolean;
217
216
  }): boolean {
218
217
  const entry = findZalouserGroupEntry(
@@ -258,7 +257,7 @@ async function processMessage(
258
257
  historyState: ZalouserGroupHistoryState,
259
258
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
260
259
  ): Promise<void> {
261
- const pairing = createScopedPairingAccess({
260
+ const pairing = createChannelPairingController({
262
261
  core,
263
262
  channel: "zalouser",
264
263
  accountId: account.accountId,
@@ -319,6 +318,7 @@ async function processMessage(
319
318
  });
320
319
 
321
320
  const groups = account.config.groups ?? {};
321
+ const routeAllowlistConfigured = Object.keys(groups).length > 0;
322
322
  const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
323
323
  if (isGroup) {
324
324
  const groupEntry = findZalouserGroupEntry(
@@ -333,7 +333,7 @@ async function processMessage(
333
333
  );
334
334
  const routeAccess = evaluateGroupRouteAccessForPolicy({
335
335
  groupPolicy,
336
- routeAllowlistConfigured: Object.keys(groups).length > 0,
336
+ routeAllowlistConfigured,
337
337
  routeMatched: Boolean(groupEntry),
338
338
  routeEnabled: isZalouserGroupEntryAllowed(groupEntry),
339
339
  });
@@ -358,21 +358,25 @@ async function processMessage(
358
358
  const dmPolicy = account.config.dmPolicy ?? "pairing";
359
359
  const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
360
360
  const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((v) => String(v));
361
- const shouldComputeCommandAuth = core.channel.commands.shouldComputeCommandAuthorized(
362
- commandBody,
363
- config,
364
- );
361
+ const senderGroupPolicy =
362
+ routeAllowlistConfigured && configGroupAllowFrom.length === 0
363
+ ? groupPolicy
364
+ : resolveSenderScopedGroupPolicy({
365
+ groupPolicy,
366
+ groupAllowFrom: configGroupAllowFrom,
367
+ });
365
368
  const storeAllowFrom =
366
- !isGroup && dmPolicy !== "allowlist" && (dmPolicy !== "open" || shouldComputeCommandAuth)
369
+ !isGroup && dmPolicy !== "allowlist" && dmPolicy !== "open"
367
370
  ? await pairing.readAllowFromStore().catch(() => [])
368
371
  : [];
369
372
  const accessDecision = resolveDmGroupAccessWithLists({
370
373
  isGroup,
371
374
  dmPolicy,
372
- groupPolicy,
375
+ groupPolicy: senderGroupPolicy,
373
376
  allowFrom: configAllowFrom,
374
377
  groupAllowFrom: configGroupAllowFrom,
375
378
  storeAllowFrom,
379
+ groupAllowFromFallbackToAllowFrom: false,
376
380
  isSenderAllowed: (allowFrom) => isSenderAllowed(senderId, allowFrom),
377
381
  });
378
382
  if (isGroup && accessDecision.decision !== "allow") {
@@ -390,12 +394,10 @@ async function processMessage(
390
394
 
391
395
  if (!isGroup && accessDecision.decision !== "allow") {
392
396
  if (accessDecision.decision === "pairing") {
393
- await issuePairingChallenge({
394
- channel: "zalouser",
397
+ await pairing.issueChallenge({
395
398
  senderId,
396
399
  senderIdLine: `Your Zalo user id: ${senderId}`,
397
400
  meta: { name: senderName || undefined },
398
- upsertPairingRequest: pairing.upsertPairingRequest,
399
401
  onCreated: () => {
400
402
  logVerbose(core, runtime, `zalouser pairing request sender=${senderId}`);
401
403
  },
@@ -434,6 +436,8 @@ async function processMessage(
434
436
  configuredGroupAllowFrom: configGroupAllowFrom,
435
437
  senderId,
436
438
  isSenderAllowed,
439
+ channel: "zalouser",
440
+ accountId: account.accountId,
437
441
  readAllowFromStore: async () => storeAllowFrom,
438
442
  shouldComputeCommandAuthorized: (body, cfg) =>
439
443
  core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
@@ -488,28 +492,32 @@ async function processMessage(
488
492
  })
489
493
  : true;
490
494
  const canDetectMention = mentionRegexes.length > 0 || explicitMention.canResolveExplicit;
491
- const mentionGate = resolveMentionGatingWithBypass({
492
- isGroup,
493
- requireMention,
494
- canDetectMention,
495
- wasMentioned,
496
- implicitMention: message.implicitMention === true,
497
- hasAnyMention: explicitMention.hasAnyMention,
498
- allowTextCommands: core.channel.commands.shouldHandleTextCommands({
499
- cfg: config,
500
- surface: "zalouser",
501
- }),
502
- hasControlCommand,
503
- commandAuthorized: commandAuthorized === true,
495
+ const mentionDecision = resolveInboundMentionDecision({
496
+ facts: {
497
+ canDetectMention,
498
+ wasMentioned,
499
+ hasAnyMention: explicitMention.hasAnyMention,
500
+ implicitMentionKinds: implicitMentionKindWhen("quoted_bot", message.implicitMention === true),
501
+ },
502
+ policy: {
503
+ isGroup,
504
+ requireMention,
505
+ allowTextCommands: core.channel.commands.shouldHandleTextCommands({
506
+ cfg: config,
507
+ surface: "zalouser",
508
+ }),
509
+ hasControlCommand,
510
+ commandAuthorized: commandAuthorized === true,
511
+ },
504
512
  });
505
- if (isGroup && requireMention && !canDetectMention && !mentionGate.effectiveWasMentioned) {
513
+ if (isGroup && requireMention && !canDetectMention && !mentionDecision.effectiveWasMentioned) {
506
514
  runtime.error?.(
507
515
  `[${account.accountId}] zalouser mention required but detection unavailable ` +
508
516
  `(missing mention regexes and bot self id); dropping group ${chatId}`,
509
517
  );
510
518
  return;
511
519
  }
512
- if (isGroup && mentionGate.shouldSkip) {
520
+ if (isGroup && mentionDecision.shouldSkip) {
513
521
  recordPendingHistoryEntryIfEnabled({
514
522
  historyMap: historyState.groupHistories,
515
523
  historyKey: historyKey ?? "",
@@ -586,102 +594,144 @@ async function processMessage(
586
594
  : undefined;
587
595
 
588
596
  const normalizedTo = isGroup ? `zalouser:group:${chatId}` : `zalouser:${chatId}`;
589
-
590
- const ctxPayload = core.channel.reply.finalizeInboundContext({
591
- Body: combinedBody,
592
- BodyForAgent: rawBody,
593
- InboundHistory: inboundHistory,
594
- RawBody: rawBody,
595
- CommandBody: commandBody,
596
- BodyForCommands: commandBody,
597
- From: isGroup ? `zalouser:group:${chatId}` : `zalouser:${senderId}`,
598
- To: normalizedTo,
599
- SessionKey: inboundSessionKey,
600
- AccountId: route.accountId,
601
- ChatType: isGroup ? "group" : "direct",
602
- ConversationLabel: fromLabel,
603
- GroupSubject: isGroup ? groupName || undefined : undefined,
604
- GroupChannel: isGroup ? groupName || undefined : undefined,
605
- GroupMembers: isGroup ? groupMembers : undefined,
606
- SenderName: senderName || undefined,
607
- SenderId: senderId,
608
- WasMentioned: isGroup ? mentionGate.effectiveWasMentioned : undefined,
609
- CommandAuthorized: commandAuthorized,
610
- Provider: "zalouser",
611
- Surface: "zalouser",
612
- MessageSid: resolveZalouserMessageSid({
613
- msgId: message.msgId,
614
- cliMsgId: message.cliMsgId,
615
- fallback: `${message.timestampMs}`,
616
- }),
617
- MessageSidFull: formatZalouserMessageSidFull({
618
- msgId: message.msgId,
619
- cliMsgId: message.cliMsgId,
620
- }),
621
- OriginatingChannel: "zalouser",
622
- OriginatingTo: normalizedTo,
597
+ const messageSid = resolveZalouserMessageSid({
598
+ msgId: message.msgId,
599
+ cliMsgId: message.cliMsgId,
600
+ fallback: `${message.timestampMs}`,
623
601
  });
624
-
625
- await core.channel.session.recordInboundSession({
626
- storePath,
627
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
628
- ctx: ctxPayload,
629
- onRecordError: (err) => {
630
- runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
631
- },
602
+ const messageSidFull = formatZalouserMessageSidFull({
603
+ msgId: message.msgId,
604
+ cliMsgId: message.cliMsgId,
632
605
  });
633
606
 
634
- const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
635
- cfg: config,
636
- agentId: route.agentId,
607
+ const ctxPayload = core.channel.turn.buildContext({
637
608
  channel: "zalouser",
638
- accountId: account.accountId,
639
- });
640
- const typingCallbacks = createTypingCallbacks({
641
- start: async () => {
642
- await sendTypingZalouser(chatId, {
643
- profile: account.profile,
644
- isGroup,
645
- });
609
+ accountId: route.accountId,
610
+ messageId: messageSid,
611
+ messageIdFull: messageSidFull,
612
+ timestamp: message.timestampMs,
613
+ from: isGroup ? `zalouser:group:${chatId}` : `zalouser:${senderId}`,
614
+ sender: {
615
+ id: senderId,
616
+ name: senderName || undefined,
646
617
  },
647
- onStartError: (err) => {
648
- runtime.error?.(
649
- `[${account.accountId}] zalouser typing start failed for ${chatId}: ${String(err)}`,
650
- );
651
- logVerbose(core, runtime, `zalouser typing failed for ${chatId}: ${String(err)}`);
618
+ conversation: {
619
+ kind: isGroup ? "group" : "direct",
620
+ id: chatId,
621
+ label: fromLabel,
622
+ routePeer: {
623
+ kind: isGroup ? "group" : "direct",
624
+ id: chatId,
625
+ },
626
+ },
627
+ route: {
628
+ agentId: route.agentId,
629
+ accountId: route.accountId,
630
+ routeSessionKey: route.sessionKey,
631
+ dispatchSessionKey: inboundSessionKey,
632
+ },
633
+ reply: {
634
+ to: normalizedTo,
635
+ originatingTo: normalizedTo,
636
+ },
637
+ message: {
638
+ body: combinedBody,
639
+ bodyForAgent: rawBody,
640
+ rawBody,
641
+ commandBody,
642
+ inboundHistory,
643
+ envelopeFrom: fromLabel,
644
+ },
645
+ extra: {
646
+ BodyForCommands: commandBody,
647
+ GroupSubject: isGroup ? groupName || undefined : undefined,
648
+ GroupChannel: isGroup ? groupName || undefined : undefined,
649
+ GroupMembers: isGroup ? groupMembers : undefined,
650
+ WasMentioned: isGroup ? mentionDecision.effectiveWasMentioned : undefined,
651
+ CommandAuthorized: commandAuthorized,
652
652
  },
653
653
  });
654
654
 
655
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
656
- ctx: ctxPayload,
655
+ const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
657
656
  cfg: config,
658
- dispatcherOptions: {
659
- ...prefixOptions,
660
- typingCallbacks,
661
- deliver: async (payload) => {
662
- await deliverZalouserReply({
663
- payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string },
657
+ agentId: route.agentId,
658
+ channel: "zalouser",
659
+ accountId: account.accountId,
660
+ typing: {
661
+ start: async () => {
662
+ await sendTypingZalouser(chatId, {
664
663
  profile: account.profile,
665
- chatId,
666
664
  isGroup,
667
- runtime,
668
- core,
669
- config,
670
- accountId: account.accountId,
671
- statusSink,
672
- tableMode: core.channel.text.resolveMarkdownTableMode({
673
- cfg: config,
674
- channel: "zalouser",
675
- accountId: account.accountId,
676
- }),
677
665
  });
678
666
  },
679
- onError: (err, info) => {
680
- runtime.error(`[${account.accountId}] Zalouser ${info.kind} reply failed: ${String(err)}`);
667
+ onStartError: (err) => {
668
+ runtime.error?.(
669
+ `[${account.accountId}] zalouser typing start failed for ${chatId}: ${String(err)}`,
670
+ );
671
+ logVerbose(core, runtime, `zalouser typing failed for ${chatId}: ${String(err)}`);
681
672
  },
682
673
  },
683
- replyOptions: {
684
- onModelSelected,
674
+ });
675
+
676
+ await core.channel.turn.run({
677
+ channel: "zalouser",
678
+ accountId: account.accountId,
679
+ raw: message,
680
+ adapter: {
681
+ ingest: () => ({
682
+ id: messageSid ?? `${message.timestampMs}`,
683
+ timestamp: message.timestampMs,
684
+ rawText: rawBody,
685
+ textForAgent: rawBody,
686
+ textForCommands: commandBody,
687
+ raw: message,
688
+ }),
689
+ resolveTurn: () => ({
690
+ cfg: config,
691
+ channel: "zalouser",
692
+ accountId: account.accountId,
693
+ agentId: route.agentId,
694
+ routeSessionKey: route.sessionKey,
695
+ storePath,
696
+ ctxPayload,
697
+ recordInboundSession: core.channel.session.recordInboundSession,
698
+ dispatchReplyWithBufferedBlockDispatcher:
699
+ core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
700
+ delivery: {
701
+ deliver: async (payload) => {
702
+ await deliverZalouserReply({
703
+ payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string },
704
+ profile: account.profile,
705
+ chatId,
706
+ isGroup,
707
+ runtime,
708
+ core,
709
+ config,
710
+ accountId: account.accountId,
711
+ statusSink,
712
+ tableMode: core.channel.text.resolveMarkdownTableMode({
713
+ cfg: config,
714
+ channel: "zalouser",
715
+ accountId: account.accountId,
716
+ }),
717
+ });
718
+ },
719
+ onError: (err, info) => {
720
+ runtime.error(
721
+ `[${account.accountId}] Zalouser ${info.kind} reply failed: ${String(err)}`,
722
+ );
723
+ },
724
+ },
725
+ dispatcherOptions: replyPipeline,
726
+ replyOptions: {
727
+ onModelSelected,
728
+ },
729
+ record: {
730
+ onRecordError: (err) => {
731
+ runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
732
+ },
733
+ },
734
+ }),
685
735
  },
686
736
  });
687
737
  if (isGroup && historyKey) {
@@ -708,16 +758,31 @@ async function deliverZalouserReply(params: {
708
758
  const { payload, profile, chatId, isGroup, runtime, core, config, accountId, statusSink } =
709
759
  params;
710
760
  const tableMode = params.tableMode ?? "code";
711
- const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
761
+ const reply = resolveSendableOutboundReplyParts(payload, {
762
+ text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
763
+ });
712
764
  const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId);
713
765
  const textChunkLimit = core.channel.text.resolveTextChunkLimit(config, "zalouser", accountId, {
714
766
  fallbackLimit: ZALOUSER_TEXT_LIMIT,
715
767
  });
716
-
717
- const sentMedia = await sendMediaWithLeadingCaption({
718
- mediaUrls: resolveOutboundMediaUrls(payload),
719
- caption: text,
720
- send: async ({ mediaUrl, caption }) => {
768
+ await deliverTextOrMediaReply({
769
+ payload,
770
+ text: reply.text,
771
+ sendText: async (chunk) => {
772
+ try {
773
+ await sendMessageZalouser(chatId, chunk, {
774
+ profile,
775
+ isGroup,
776
+ textMode: "markdown",
777
+ textChunkMode: chunkMode,
778
+ textChunkLimit,
779
+ });
780
+ statusSink?.({ lastOutboundAt: Date.now() });
781
+ } catch (err) {
782
+ runtime.error(`Zalouser message send failed: ${String(err)}`);
783
+ }
784
+ },
785
+ sendMedia: async ({ mediaUrl, caption }) => {
721
786
  logVerbose(core, runtime, `Sending media to ${chatId}`);
722
787
  await sendMessageZalouser(chatId, caption ?? "", {
723
788
  profile,
@@ -729,28 +794,14 @@ async function deliverZalouserReply(params: {
729
794
  });
730
795
  statusSink?.({ lastOutboundAt: Date.now() });
731
796
  },
732
- onError: (error) => {
733
- runtime.error(`Zalouser media send failed: ${String(error)}`);
797
+ onMediaError: (error) => {
798
+ runtime.error(
799
+ `Zalouser media send failed: ${
800
+ error instanceof Error ? error.message : JSON.stringify(error)
801
+ }`,
802
+ );
734
803
  },
735
804
  });
736
- if (sentMedia) {
737
- return;
738
- }
739
-
740
- if (text) {
741
- try {
742
- await sendMessageZalouser(chatId, text, {
743
- profile,
744
- isGroup,
745
- textMode: "markdown",
746
- textChunkMode: chunkMode,
747
- textChunkLimit,
748
- });
749
- statusSink?.({ lastOutboundAt: Date.now() });
750
- } catch (err) {
751
- runtime.error(`Zalouser message send failed: ${String(err)}`);
752
- }
753
- }
754
805
  }
755
806
 
756
807
  export async function monitorZalouserProvider(
@@ -833,9 +884,9 @@ export async function monitorZalouserProvider(
833
884
  mapping.push(`${entry}→${cleaned}`);
834
885
  continue;
835
886
  }
836
- const matches = byName.get(cleaned.toLowerCase()) ?? [];
887
+ const matches = byName.get(normalizeLowercaseStringOrEmpty(cleaned)) ?? [];
837
888
  const match = matches[0];
838
- const id = match?.groupId ? String(match.groupId) : undefined;
889
+ const id = match?.groupId;
839
890
  if (id) {
840
891
  if (!nextGroups[id]) {
841
892
  nextGroups[id] = groupsConfig[entry];
package/src/probe.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { BaseProbeResult } from "openclaw/plugin-sdk/zalouser";
1
+ import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
2
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
2
3
  import type { ZcaUserInfo } from "./types.js";
3
4
  import { getZaloUserInfo } from "./zalo-js.js";
4
5
 
@@ -28,7 +29,7 @@ export async function probeZalouser(
28
29
  } catch (error) {
29
30
  return {
30
31
  ok: false,
31
- error: error instanceof Error ? error.message : String(error),
32
+ error: formatErrorMessage(error),
32
33
  };
33
34
  }
34
35
  }
@@ -1,6 +1,6 @@
1
1
  import fsp from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/zalouser";
3
+ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
4
4
 
5
5
  export async function writeQrDataUrlToTempFile(
6
6
  qrDataUrl: string,
package/src/reaction.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { Reactions } from "./zca-client.js";
1
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
2
+ import { Reactions } from "./zca-constants.js";
2
3
 
3
4
  const REACTION_ALIAS_MAP = new Map<string, string>([
4
5
  ["like", Reactions.LIKE],
@@ -24,6 +25,8 @@ export function normalizeZaloReactionIcon(raw: string): string {
24
25
  return Reactions.LIKE;
25
26
  }
26
27
  return (
27
- REACTION_ALIAS_MAP.get(trimmed.toLowerCase()) ?? REACTION_ALIAS_MAP.get(trimmed) ?? trimmed
28
+ REACTION_ALIAS_MAP.get(normalizeLowercaseStringOrEmpty(trimmed)) ??
29
+ REACTION_ALIAS_MAP.get(trimmed) ??
30
+ trimmed
28
31
  );
29
32
  }
package/src/runtime.ts CHANGED
@@ -1,6 +1,9 @@
1
- import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
2
- import type { PluginRuntime } from "openclaw/plugin-sdk/zalouser";
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
2
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
3
3
 
4
4
  const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } =
5
- createPluginRuntimeStore<PluginRuntime>("Zalouser runtime not initialized");
5
+ createPluginRuntimeStore<PluginRuntime>({
6
+ pluginId: "zalouser",
7
+ errorMessage: "Zalouser runtime not initialized",
8
+ });
6
9
  export { getZalouserRuntime, setZalouserRuntime };