@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/onboarding.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  formatResolvedUnresolvedNote,
10
10
  mergeAllowFromEntries,
11
11
  normalizeAccountId,
12
+ patchScopedAccountConfig,
12
13
  promptChannelAccessConfig,
13
14
  resolveAccountIdForConfigure,
14
15
  setTopLevelChannelDmPolicyWithAllowFrom,
@@ -36,37 +37,13 @@ function setZalouserAccountScopedConfig(
36
37
  defaultPatch: Record<string, unknown>,
37
38
  accountPatch: Record<string, unknown> = defaultPatch,
38
39
  ): OpenClawConfig {
39
- if (accountId === DEFAULT_ACCOUNT_ID) {
40
- return {
41
- ...cfg,
42
- channels: {
43
- ...cfg.channels,
44
- zalouser: {
45
- ...cfg.channels?.zalouser,
46
- enabled: true,
47
- ...defaultPatch,
48
- },
49
- },
50
- } as OpenClawConfig;
51
- }
52
- return {
53
- ...cfg,
54
- channels: {
55
- ...cfg.channels,
56
- zalouser: {
57
- ...cfg.channels?.zalouser,
58
- enabled: true,
59
- accounts: {
60
- ...cfg.channels?.zalouser?.accounts,
61
- [accountId]: {
62
- ...cfg.channels?.zalouser?.accounts?.[accountId],
63
- enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
64
- ...accountPatch,
65
- },
66
- },
67
- },
68
- },
69
- } as OpenClawConfig;
40
+ return patchScopedAccountConfig({
41
+ cfg,
42
+ channelKey: channel,
43
+ accountId,
44
+ patch: defaultPatch,
45
+ accountPatch,
46
+ }) as OpenClawConfig;
70
47
  }
71
48
 
72
49
  function setZalouserDmPolicy(
package/src/runtime.ts CHANGED
@@ -1,14 +1,6 @@
1
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
1
2
  import type { PluginRuntime } from "openclaw/plugin-sdk/zalouser";
2
3
 
3
- let runtime: PluginRuntime | null = null;
4
-
5
- export function setZalouserRuntime(next: PluginRuntime): void {
6
- runtime = next;
7
- }
8
-
9
- export function getZalouserRuntime(): PluginRuntime {
10
- if (!runtime) {
11
- throw new Error("Zalouser runtime not initialized");
12
- }
13
- return runtime;
14
- }
4
+ const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } =
5
+ createPluginRuntimeStore<PluginRuntime>("Zalouser runtime not initialized");
6
+ export { getZalouserRuntime, setZalouserRuntime };
package/src/types.ts CHANGED
@@ -35,6 +35,7 @@ export type ZaloInboundMessage = {
35
35
  senderName?: string;
36
36
  groupName?: string;
37
37
  content: string;
38
+ commandContent?: string;
38
39
  timestampMs: number;
39
40
  msgId?: string;
40
41
  cliMsgId?: string;
@@ -92,6 +93,8 @@ type ZalouserSharedConfig = {
92
93
  profile?: string;
93
94
  dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
94
95
  allowFrom?: Array<string | number>;
96
+ historyLimit?: number;
97
+ groupAllowFrom?: Array<string | number>;
95
98
  groupPolicy?: "open" | "allowlist" | "disabled";
96
99
  groups?: Record<string, ZalouserGroupConfig>;
97
100
  messagePrefix?: string;
package/src/zalo-js.ts CHANGED
@@ -37,6 +37,8 @@ const DEFAULT_QR_WAIT_TIMEOUT_MS = 120_000;
37
37
  const GROUP_INFO_CHUNK_SIZE = 80;
38
38
  const GROUP_CONTEXT_CACHE_TTL_MS = 5 * 60_000;
39
39
  const GROUP_CONTEXT_CACHE_MAX_ENTRIES = 500;
40
+ const LISTENER_WATCHDOG_INTERVAL_MS = 30_000;
41
+ const LISTENER_WATCHDOG_MAX_GAP_MS = 35_000;
40
42
 
41
43
  const apiByProfile = new Map<string, API>();
42
44
  const apiInitByProfile = new Map<string, Promise<API>>();
@@ -63,6 +65,8 @@ type ActiveZaloListener = {
63
65
  const activeListeners = new Map<string, ActiveZaloListener>();
64
66
  const groupContextCache = new Map<string, { value: ZaloGroupContext; expiresAt: number }>();
65
67
 
68
+ type AccountInfoResponse = Awaited<ReturnType<API["fetchAccountInfo"]>>;
69
+
66
70
  type ApiTypingCapability = {
67
71
  sendTypingEvent: (
68
72
  threadId: string,
@@ -155,6 +159,20 @@ function toStringValue(value: unknown): string {
155
159
  return "";
156
160
  }
157
161
 
162
+ function normalizeAccountInfoUser(info: AccountInfoResponse): User | null {
163
+ if (!info || typeof info !== "object") {
164
+ return null;
165
+ }
166
+ if ("profile" in info) {
167
+ const profile = (info as { profile?: unknown }).profile;
168
+ if (profile && typeof profile === "object") {
169
+ return profile as User;
170
+ }
171
+ return null;
172
+ }
173
+ return info as User;
174
+ }
175
+
158
176
  function toInteger(value: unknown, fallback = 0): number {
159
177
  if (typeof value === "number" && Number.isFinite(value)) {
160
178
  return Math.trunc(value);
@@ -199,18 +217,128 @@ function resolveInboundTimestamp(rawTs: unknown): number {
199
217
  return parsed > 1_000_000_000_000 ? parsed : parsed * 1000;
200
218
  }
201
219
 
202
- function extractMentionIds(raw: unknown): string[] {
203
- if (!Array.isArray(raw)) {
220
+ function extractMentionIds(rawMentions: unknown): string[] {
221
+ if (!Array.isArray(rawMentions)) {
204
222
  return [];
205
223
  }
206
- return raw
207
- .map((entry) => {
208
- if (!entry || typeof entry !== "object") {
209
- return "";
210
- }
211
- return toNumberId((entry as { uid?: unknown }).uid);
212
- })
213
- .filter(Boolean);
224
+ const sink = new Set<string>();
225
+ for (const entry of rawMentions) {
226
+ if (!entry || typeof entry !== "object") {
227
+ continue;
228
+ }
229
+ const record = entry as { uid?: unknown };
230
+ const id = toNumberId(record.uid);
231
+ if (id) {
232
+ sink.add(id);
233
+ }
234
+ }
235
+ return Array.from(sink);
236
+ }
237
+
238
+ type MentionSpan = {
239
+ start: number;
240
+ end: number;
241
+ };
242
+
243
+ function toNonNegativeInteger(value: unknown): number | null {
244
+ if (typeof value === "number" && Number.isFinite(value)) {
245
+ const normalized = Math.trunc(value);
246
+ return normalized >= 0 ? normalized : null;
247
+ }
248
+ if (typeof value === "string" && value.trim().length > 0) {
249
+ const parsed = Number.parseInt(value.trim(), 10);
250
+ if (Number.isFinite(parsed)) {
251
+ return parsed >= 0 ? parsed : null;
252
+ }
253
+ }
254
+ return null;
255
+ }
256
+
257
+ function extractOwnMentionSpans(
258
+ rawMentions: unknown,
259
+ ownUserId: string,
260
+ contentLength: number,
261
+ ): MentionSpan[] {
262
+ if (!Array.isArray(rawMentions) || !ownUserId || contentLength <= 0) {
263
+ return [];
264
+ }
265
+ const spans: MentionSpan[] = [];
266
+ for (const entry of rawMentions) {
267
+ if (!entry || typeof entry !== "object") {
268
+ continue;
269
+ }
270
+ const record = entry as {
271
+ uid?: unknown;
272
+ pos?: unknown;
273
+ start?: unknown;
274
+ offset?: unknown;
275
+ len?: unknown;
276
+ length?: unknown;
277
+ };
278
+ const uid = toNumberId(record.uid);
279
+ if (!uid || uid !== ownUserId) {
280
+ continue;
281
+ }
282
+ const startRaw = toNonNegativeInteger(record.pos ?? record.start ?? record.offset);
283
+ const lengthRaw = toNonNegativeInteger(record.len ?? record.length);
284
+ if (startRaw === null || lengthRaw === null || lengthRaw <= 0) {
285
+ continue;
286
+ }
287
+ const start = Math.min(startRaw, contentLength);
288
+ const end = Math.min(start + lengthRaw, contentLength);
289
+ if (end <= start) {
290
+ continue;
291
+ }
292
+ spans.push({ start, end });
293
+ }
294
+ if (spans.length <= 1) {
295
+ return spans;
296
+ }
297
+ spans.sort((a, b) => a.start - b.start);
298
+ const merged: MentionSpan[] = [];
299
+ for (const span of spans) {
300
+ const last = merged[merged.length - 1];
301
+ if (!last || span.start > last.end) {
302
+ merged.push({ ...span });
303
+ continue;
304
+ }
305
+ last.end = Math.max(last.end, span.end);
306
+ }
307
+ return merged;
308
+ }
309
+
310
+ function stripOwnMentionsForCommandBody(
311
+ content: string,
312
+ rawMentions: unknown,
313
+ ownUserId: string,
314
+ ): string {
315
+ if (!content || !ownUserId) {
316
+ return content;
317
+ }
318
+ const spans = extractOwnMentionSpans(rawMentions, ownUserId, content.length);
319
+ if (spans.length === 0) {
320
+ return stripLeadingAtMentionForCommand(content);
321
+ }
322
+ let cursor = 0;
323
+ let output = "";
324
+ for (const span of spans) {
325
+ if (span.start > cursor) {
326
+ output += content.slice(cursor, span.start);
327
+ }
328
+ cursor = Math.max(cursor, span.end);
329
+ }
330
+ if (cursor < content.length) {
331
+ output += content.slice(cursor);
332
+ }
333
+ return output.replace(/\s+/g, " ").trim();
334
+ }
335
+
336
+ function stripLeadingAtMentionForCommand(content: string): string {
337
+ const fallbackMatch = content.match(/^\s*@[^\s]+(?:\s+|[:,-]\s*)([/!][\s\S]*)$/);
338
+ if (!fallbackMatch) {
339
+ return content;
340
+ }
341
+ return fallbackMatch[1].trim();
214
342
  }
215
343
 
216
344
  function resolveGroupNameFromMessageData(data: Record<string, unknown>): string | undefined {
@@ -250,9 +378,14 @@ function extractSendMessageId(result: unknown): string | undefined {
250
378
  return undefined;
251
379
  }
252
380
  const payload = result as {
381
+ msgId?: string | number;
253
382
  message?: { msgId?: string | number } | null;
254
383
  attachment?: Array<{ msgId?: string | number }>;
255
384
  };
385
+ const direct = payload.msgId;
386
+ if (direct !== undefined && direct !== null) {
387
+ return String(direct);
388
+ }
256
389
  const primary = payload.message?.msgId;
257
390
  if (primary !== undefined && primary !== null) {
258
391
  return String(primary);
@@ -311,6 +444,35 @@ function resolveMediaFileName(params: {
311
444
  return `upload.${ext}`;
312
445
  }
313
446
 
447
+ function resolveUploadedVoiceAsset(
448
+ uploaded: Array<{
449
+ fileType?: string;
450
+ fileUrl?: string;
451
+ fileName?: string;
452
+ }>,
453
+ ): { fileUrl: string; fileName?: string } | undefined {
454
+ for (const item of uploaded) {
455
+ if (!item || typeof item !== "object") {
456
+ continue;
457
+ }
458
+ const fileType = item.fileType?.toLowerCase();
459
+ const fileUrl = item.fileUrl?.trim();
460
+ if (!fileUrl) {
461
+ continue;
462
+ }
463
+ if (fileType === "others" || fileType === "video") {
464
+ return { fileUrl, fileName: item.fileName?.trim() || undefined };
465
+ }
466
+ }
467
+ return undefined;
468
+ }
469
+
470
+ function buildZaloVoicePlaybackUrl(asset: { fileUrl: string; fileName?: string }): string {
471
+ // zca-js uses uploadAttachment(...).fileUrl directly for sendVoice.
472
+ // Appending filename can produce URLs that play only in the local session.
473
+ return asset.fileUrl.trim();
474
+ }
475
+
314
476
  function mapFriend(friend: User): ZcaFriend {
315
477
  return {
316
478
  userId: String(friend.userId),
@@ -602,6 +764,11 @@ function toInboundMessage(message: Message, ownUserId?: string): ZaloInboundMess
602
764
  const wasExplicitlyMentioned = Boolean(
603
765
  normalizedOwnUserId && mentionIds.some((id) => id === normalizedOwnUserId),
604
766
  );
767
+ const commandContent = wasExplicitlyMentioned
768
+ ? stripOwnMentionsForCommandBody(content, data.mentions, normalizedOwnUserId)
769
+ : hasAnyMention && !canResolveExplicitMention
770
+ ? stripLeadingAtMentionForCommand(content)
771
+ : content;
605
772
  const implicitMention = Boolean(
606
773
  normalizedOwnUserId && quoteOwnerId && quoteOwnerId === normalizedOwnUserId,
607
774
  );
@@ -613,6 +780,7 @@ function toInboundMessage(message: Message, ownUserId?: string): ZaloInboundMess
613
780
  senderName: typeof data.dName === "string" ? data.dName.trim() || undefined : undefined,
614
781
  groupName: isGroup ? resolveGroupNameFromMessageData(data) : undefined,
615
782
  content,
783
+ commandContent,
616
784
  timestampMs: resolveInboundTimestamp(data.ts),
617
785
  msgId: typeof data.msgId === "string" ? data.msgId : undefined,
618
786
  cliMsgId: typeof data.cliMsgId === "string" ? data.cliMsgId : undefined,
@@ -649,8 +817,7 @@ export async function getZaloUserInfo(profileInput?: string | null): Promise<Zca
649
817
  const profile = normalizeProfile(profileInput);
650
818
  const api = await ensureApi(profile);
651
819
  const info = await api.fetchAccountInfo();
652
- const user =
653
- info && typeof info === "object" && "profile" in info ? (info.profile as User) : (info as User);
820
+ const user = normalizeAccountInfoUser(info);
654
821
  if (!user?.userId) {
655
822
  return null;
656
823
  }
@@ -851,6 +1018,40 @@ export async function sendZaloTextMessage(
851
1018
  kind: media.kind,
852
1019
  });
853
1020
  const payloadText = (text || options.caption || "").slice(0, 2000);
1021
+
1022
+ if (media.kind === "audio") {
1023
+ let textMessageId: string | undefined;
1024
+ if (payloadText) {
1025
+ const textResponse = await api.sendMessage(payloadText, trimmedThreadId, type);
1026
+ textMessageId = extractSendMessageId(textResponse);
1027
+ }
1028
+
1029
+ const attachmentFileName = fileName.includes(".") ? fileName : `${fileName}.bin`;
1030
+ const uploaded = await api.uploadAttachment(
1031
+ [
1032
+ {
1033
+ data: media.buffer,
1034
+ filename: attachmentFileName as `${string}.${string}`,
1035
+ metadata: {
1036
+ totalSize: media.buffer.length,
1037
+ },
1038
+ },
1039
+ ],
1040
+ trimmedThreadId,
1041
+ type,
1042
+ );
1043
+ const voiceAsset = resolveUploadedVoiceAsset(uploaded);
1044
+ if (!voiceAsset) {
1045
+ throw new Error("Failed to resolve uploaded audio URL for voice message");
1046
+ }
1047
+ const voiceUrl = buildZaloVoicePlaybackUrl(voiceAsset);
1048
+ const response = await api.sendVoice({ voiceUrl }, trimmedThreadId, type);
1049
+ return {
1050
+ ok: true,
1051
+ messageId: extractSendMessageId(response) ?? textMessageId,
1052
+ };
1053
+ }
1054
+
854
1055
  const response = await api.sendMessage(
855
1056
  {
856
1057
  msg: payloadText,
@@ -890,13 +1091,32 @@ export async function sendZaloTypingEvent(
890
1091
  const type = options.isGroup ? ThreadType.Group : ThreadType.User;
891
1092
  if ("sendTypingEvent" in api && typeof api.sendTypingEvent === "function") {
892
1093
  await (api as API & ApiTypingCapability).sendTypingEvent(trimmedThreadId, type);
1094
+ return;
893
1095
  }
1096
+ throw new Error("Zalo typing indicator is not supported by current API session");
894
1097
  }
895
1098
 
896
1099
  async function resolveOwnUserId(api: API): Promise<string> {
897
- const info = await api.fetchAccountInfo();
898
- const profile = "profile" in info ? info.profile : info;
899
- return toNumberId(profile.userId);
1100
+ try {
1101
+ const info = await api.fetchAccountInfo();
1102
+ const resolved = toNumberId(normalizeAccountInfoUser(info)?.userId);
1103
+ if (resolved) {
1104
+ return resolved;
1105
+ }
1106
+ } catch {
1107
+ // Fall back to getOwnId when account info shape changes.
1108
+ }
1109
+
1110
+ try {
1111
+ const ownId = toNumberId(api.getOwnId());
1112
+ if (ownId) {
1113
+ return ownId;
1114
+ }
1115
+ } catch {
1116
+ // Ignore fallback probe failures and keep mention detection conservative.
1117
+ }
1118
+
1119
+ return "";
900
1120
  }
901
1121
 
902
1122
  export async function sendZaloReaction(params: {
@@ -1244,12 +1464,18 @@ export async function startZaloListener(params: {
1244
1464
  const api = await ensureApi(profile);
1245
1465
  const ownUserId = await resolveOwnUserId(api);
1246
1466
  let stopped = false;
1467
+ let watchdogTimer: ReturnType<typeof setInterval> | null = null;
1468
+ let lastWatchdogTickAt = Date.now();
1247
1469
 
1248
1470
  const cleanup = () => {
1249
1471
  if (stopped) {
1250
1472
  return;
1251
1473
  }
1252
1474
  stopped = true;
1475
+ if (watchdogTimer) {
1476
+ clearInterval(watchdogTimer);
1477
+ watchdogTimer = null;
1478
+ }
1253
1479
  try {
1254
1480
  api.listener.off("message", onMessage);
1255
1481
  api.listener.off("error", onError);
@@ -1276,19 +1502,22 @@ export async function startZaloListener(params: {
1276
1502
  params.onMessage(normalized);
1277
1503
  };
1278
1504
 
1279
- const onError = (error: unknown) => {
1505
+ const failListener = (error: Error) => {
1280
1506
  if (stopped || params.abortSignal.aborted) {
1281
1507
  return;
1282
1508
  }
1509
+ cleanup();
1510
+ invalidateApi(profile);
1511
+ params.onError(error);
1512
+ };
1513
+
1514
+ const onError = (error: unknown) => {
1283
1515
  const wrapped = error instanceof Error ? error : new Error(String(error));
1284
- params.onError(wrapped);
1516
+ failListener(wrapped);
1285
1517
  };
1286
1518
 
1287
1519
  const onClosed = (code: number, reason: string) => {
1288
- if (stopped || params.abortSignal.aborted) {
1289
- return;
1290
- }
1291
- params.onError(new Error(`Zalo listener closed (${code}): ${reason || "no reason"}`));
1520
+ failListener(new Error(`Zalo listener closed (${code}): ${reason || "no reason"}`));
1292
1521
  };
1293
1522
 
1294
1523
  api.listener.on("message", onMessage);
@@ -1296,12 +1525,30 @@ export async function startZaloListener(params: {
1296
1525
  api.listener.on("closed", onClosed);
1297
1526
 
1298
1527
  try {
1299
- api.listener.start({ retryOnClose: true });
1528
+ api.listener.start({ retryOnClose: false });
1300
1529
  } catch (error) {
1301
1530
  cleanup();
1302
1531
  throw error;
1303
1532
  }
1304
1533
 
1534
+ watchdogTimer = setInterval(() => {
1535
+ if (stopped || params.abortSignal.aborted) {
1536
+ return;
1537
+ }
1538
+ const now = Date.now();
1539
+ const gapMs = now - lastWatchdogTickAt;
1540
+ lastWatchdogTickAt = now;
1541
+ if (gapMs <= LISTENER_WATCHDOG_MAX_GAP_MS) {
1542
+ return;
1543
+ }
1544
+ failListener(
1545
+ new Error(
1546
+ `Zalo listener watchdog gap detected (${Math.round(gapMs / 1000)}s): forcing reconnect`,
1547
+ ),
1548
+ );
1549
+ }, LISTENER_WATCHDOG_INTERVAL_MS);
1550
+ watchdogTimer.unref?.();
1551
+
1305
1552
  params.abortSignal.addEventListener(
1306
1553
  "abort",
1307
1554
  () => {
package/src/zca-client.ts CHANGED
@@ -152,7 +152,7 @@ export type API = {
152
152
  cookies: unknown[];
153
153
  };
154
154
  };
155
- fetchAccountInfo(): Promise<{ profile: User } | User>;
155
+ fetchAccountInfo(): Promise<User | { profile: User }>;
156
156
  getAllFriends(): Promise<User[]>;
157
157
  getOwnId(): string;
158
158
  getAllGroups(): Promise<{
@@ -177,9 +177,53 @@ export type API = {
177
177
  threadId: string,
178
178
  type?: number,
179
179
  ): Promise<{
180
+ msgId?: string | number;
180
181
  message?: { msgId?: string | number } | null;
181
182
  attachment?: Array<{ msgId?: string | number }>;
182
183
  }>;
184
+ uploadAttachment(
185
+ sources:
186
+ | string
187
+ | {
188
+ data: Buffer;
189
+ filename: `${string}.${string}`;
190
+ metadata: {
191
+ totalSize: number;
192
+ width?: number;
193
+ height?: number;
194
+ };
195
+ }
196
+ | Array<
197
+ | string
198
+ | {
199
+ data: Buffer;
200
+ filename: `${string}.${string}`;
201
+ metadata: {
202
+ totalSize: number;
203
+ width?: number;
204
+ height?: number;
205
+ };
206
+ }
207
+ >,
208
+ threadId: string,
209
+ type?: number,
210
+ ): Promise<
211
+ Array<{
212
+ fileType: "image" | "video" | "others";
213
+ fileUrl?: string;
214
+ msgId?: string | number;
215
+ fileId?: string;
216
+ fileName?: string;
217
+ }>
218
+ >;
219
+ sendVoice(
220
+ options: {
221
+ voiceUrl: string;
222
+ ttl?: number;
223
+ },
224
+ threadId: string,
225
+ type?: number,
226
+ ): Promise<{ msgId?: string | number }>;
183
227
  sendLink(
184
228
  payload: { link: string; msg?: string },
185
229
  threadId: string,