@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/CHANGELOG.md +24 -0
- package/package.json +6 -1
- package/src/channel.directory.test.ts +72 -0
- package/src/channel.sendpayload.test.ts +72 -54
- package/src/channel.ts +122 -14
- package/src/config-schema.ts +12 -9
- package/src/monitor.group-gating.test.ts +379 -11
- package/src/monitor.ts +412 -114
- package/src/onboarding.ts +8 -31
- package/src/runtime.ts +4 -12
- package/src/types.ts +3 -0
- package/src/zalo-js.ts +269 -22
- package/src/zca-client.ts +45 -1
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
export
|
|
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(
|
|
203
|
-
if (!Array.isArray(
|
|
220
|
+
function extractMentionIds(rawMentions: unknown): string[] {
|
|
221
|
+
if (!Array.isArray(rawMentions)) {
|
|
204
222
|
return [];
|
|
205
223
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
|
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
|
-
|
|
1516
|
+
failListener(wrapped);
|
|
1285
1517
|
};
|
|
1286
1518
|
|
|
1287
1519
|
const onClosed = (code: number, reason: string) => {
|
|
1288
|
-
|
|
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:
|
|
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 }
|
|
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,
|