@ardennguyen/zalo-agent-cli 1.0.7

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 (52) hide show
  1. package/DISCLAIMER.md +27 -0
  2. package/LICENSE +21 -0
  3. package/README.md +205 -0
  4. package/package.json +64 -0
  5. package/src/cli.test.js +98 -0
  6. package/src/commands/account.js +178 -0
  7. package/src/commands/auto-reply.js +83 -0
  8. package/src/commands/catalog.js +143 -0
  9. package/src/commands/conv.js +264 -0
  10. package/src/commands/friend.js +384 -0
  11. package/src/commands/group.js +606 -0
  12. package/src/commands/label.js +39 -0
  13. package/src/commands/listen.js +293 -0
  14. package/src/commands/login.js +165 -0
  15. package/src/commands/mcp.js +219 -0
  16. package/src/commands/msg.js +608 -0
  17. package/src/commands/oa-init.js +503 -0
  18. package/src/commands/oa-listen.js +215 -0
  19. package/src/commands/oa.js +657 -0
  20. package/src/commands/poll.js +159 -0
  21. package/src/commands/profile.js +274 -0
  22. package/src/commands/quick-msg.js +55 -0
  23. package/src/commands/reminder.js +195 -0
  24. package/src/core/accounts.js +93 -0
  25. package/src/core/credentials.js +63 -0
  26. package/src/core/oa-client.js +407 -0
  27. package/src/core/zalo-client.js +201 -0
  28. package/src/index.js +95 -0
  29. package/src/mcp/mcp-config.js +81 -0
  30. package/src/mcp/mcp-http-transport.js +84 -0
  31. package/src/mcp/mcp-server.js +40 -0
  32. package/src/mcp/mcp-tools.js +353 -0
  33. package/src/mcp/media-downloader.js +191 -0
  34. package/src/mcp/media-downloader.test.js +45 -0
  35. package/src/mcp/message-buffer.js +127 -0
  36. package/src/mcp/message-buffer.test.js +308 -0
  37. package/src/mcp/notifier.js +141 -0
  38. package/src/mcp/notifier.test.js +269 -0
  39. package/src/mcp/thread-filter.js +79 -0
  40. package/src/mcp/thread-filter.test.js +206 -0
  41. package/src/mcp/thread-name-cache.js +162 -0
  42. package/src/mcp/thread-name-cache.test.js +139 -0
  43. package/src/utils/bank-helpers.js +233 -0
  44. package/src/utils/bank-helpers.test.js +66 -0
  45. package/src/utils/extract-message-text.js +21 -0
  46. package/src/utils/output.js +21 -0
  47. package/src/utils/proxy-helpers.js +14 -0
  48. package/src/utils/proxy-helpers.test.js +36 -0
  49. package/src/utils/qr-display.js +91 -0
  50. package/src/utils/qr-display.test.js +97 -0
  51. package/src/utils/qr-http-server.js +190 -0
  52. package/src/utils/update-check.js +56 -0
@@ -0,0 +1,608 @@
1
+ /**
2
+ * Message commands — send text, images, files, cards, bank cards, QR transfers,
3
+ * stickers, reactions, delete, forward.
4
+ */
5
+
6
+ import { resolve } from "path";
7
+ import { getApi } from "../core/zalo-client.js";
8
+ import { success, error, info, output } from "../utils/output.js";
9
+ import { extractMessageText } from "../utils/extract-message-text.js";
10
+
11
+ /**
12
+ * TextStyle codes matching zca-js TextStyle enum.
13
+ * Used for --style option and markdown parsing.
14
+ */
15
+ const TEXT_STYLES = {
16
+ bold: "b",
17
+ b: "b",
18
+ italic: "i",
19
+ i: "i",
20
+ underline: "u",
21
+ u: "u",
22
+ strikethrough: "s",
23
+ s: "s",
24
+ red: "c_db342e",
25
+ orange: "c_f27806",
26
+ yellow: "c_f7b503",
27
+ green: "c_15a85f",
28
+ small: "f_13",
29
+ big: "f_18",
30
+ };
31
+
32
+ /**
33
+ * Parse markdown-like syntax from message text into plain text + styles array.
34
+ * Supports: **bold**, *italic*, __underline__, ~~strikethrough~~,
35
+ * {red:text}, {orange:text}, {green:text}, {yellow:text},
36
+ * {big:text}, {small:text}
37
+ */
38
+ function parseMarkdownStyles(input) {
39
+ const styles = [];
40
+ let plain = input;
41
+
42
+ // Process markdown patterns (order matters: ** before *)
43
+ const patterns = [
44
+ { regex: /\*\*(.+?)\*\*/g, st: "b" },
45
+ { regex: /\*(.+?)\*/g, st: "i" },
46
+ { regex: /__(.+?)__/g, st: "u" },
47
+ { regex: /~~(.+?)~~/g, st: "s" },
48
+ { regex: /\{(red|orange|yellow|green|big|small):(.+?)\}/g, st: null },
49
+ ];
50
+
51
+ for (const p of patterns) {
52
+ let match;
53
+ // Re-run from scratch each time since offsets shift
54
+ while ((match = p.regex.exec(plain)) !== null) {
55
+ const fullMatch = match[0];
56
+ const start = match.index;
57
+ let content, st;
58
+ if (p.st === null) {
59
+ // Color/size pattern: {color:text}
60
+ st = TEXT_STYLES[match[1]];
61
+ content = match[2];
62
+ } else {
63
+ st = p.st;
64
+ content = match[1];
65
+ }
66
+ // Replace the markdown syntax with plain content
67
+ plain = plain.slice(0, start) + content + plain.slice(start + fullMatch.length);
68
+ styles.push({ start, len: content.length, st });
69
+ // Reset regex since string changed
70
+ p.regex.lastIndex = start + content.length;
71
+ }
72
+ }
73
+
74
+ return { plain, styles };
75
+ }
76
+
77
+ /**
78
+ * Parse manual style specs: "start:len:style" → { start, len, st }
79
+ * Style names: bold, italic, underline, strikethrough, red, orange, yellow, green, big, small
80
+ */
81
+ function parseStyleSpecs(specs) {
82
+ return specs
83
+ .map((spec) => {
84
+ const [start, len, style] = spec.split(":");
85
+ const st = TEXT_STYLES[style];
86
+ if (!st) return null;
87
+ return { start: Number(start), len: Number(len), st };
88
+ })
89
+ .filter(Boolean);
90
+ }
91
+
92
+ export function registerMsgCommands(program) {
93
+ const msg = program.command("msg").description("Send and manage messages");
94
+
95
+ msg.command("send <threadId> <message>")
96
+ .description("Send a text message with optional formatting")
97
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
98
+ .option(
99
+ "--mention <specs...>",
100
+ "Mention users in group message. Format: pos:userId:len (e.g. 0:USER_ID:5). Use userId=-1 for @All.",
101
+ )
102
+ .option("--style <specs...>", "Text styles. Format: start:len:style (e.g. 0:5:bold 6:5:italic)")
103
+ .option("--md", "Parse markdown-like formatting: **bold** *italic* __underline__ ~~strike~~ {red:text}")
104
+ .option(
105
+ "--react <icon>",
106
+ "Auto-react to sent message. Codes: :> (haha), /-heart (heart), /-strong (like), :o (wow), :-(( (cry), :-h (angry)",
107
+ )
108
+ .action(async (threadId, message, opts) => {
109
+ try {
110
+ // Parse mention specs: "pos:uid:len" → { pos, uid, len }
111
+ const mentions = (opts.mention || []).map((spec) => {
112
+ const [pos, uid, len] = spec.split(":");
113
+ return { pos: Number(pos), uid, len: Number(len) };
114
+ });
115
+
116
+ // Parse text styles
117
+ let styles = [];
118
+ let finalMsg = message;
119
+
120
+ if (opts.md) {
121
+ // Markdown-like parsing: **bold** *italic* __underline__ ~~strike~~
122
+ const parsed = parseMarkdownStyles(message);
123
+ finalMsg = parsed.plain;
124
+ styles = parsed.styles;
125
+ }
126
+
127
+ if (opts.style) {
128
+ // Manual style specs: start:len:style
129
+ styles = styles.concat(parseStyleSpecs(opts.style));
130
+ }
131
+
132
+ // Build message content
133
+ const hasExtras = mentions.length > 0 || styles.length > 0;
134
+ const msgContent = hasExtras
135
+ ? { msg: finalMsg, ...(mentions.length > 0 && { mentions }), ...(styles.length > 0 && { styles }) }
136
+ : finalMsg;
137
+
138
+ const cliMsgId = String(Date.now());
139
+ const result = await getApi().sendMessage(msgContent, threadId, Number(opts.type));
140
+ result.cliMsgId = cliMsgId;
141
+ output(result, program.opts().json, () => success("Message sent"));
142
+
143
+ // Auto-react if --react flag provided
144
+ if (opts.react && result.message?.msgId) {
145
+ const dest = {
146
+ data: { msgId: String(result.message.msgId), cliMsgId },
147
+ threadId,
148
+ type: Number(opts.type),
149
+ };
150
+ await getApi().addReaction(opts.react, dest);
151
+ success(`Auto-reacted with '${opts.react}'`);
152
+ }
153
+ } catch (e) {
154
+ error(e.message);
155
+ }
156
+ });
157
+
158
+ msg.command("send-image <threadId> <paths...>")
159
+ .description("Send one or more images")
160
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
161
+ .option("-m, --caption <text>", "Caption text", "")
162
+ .action(async (threadId, paths, opts) => {
163
+ try {
164
+ const absPaths = paths.map((p) => resolve(p));
165
+ const result = await getApi().sendMessage(
166
+ { msg: opts.caption, attachments: absPaths },
167
+ threadId,
168
+ Number(opts.type),
169
+ );
170
+ output(result, program.opts().json, () => success(`Image(s) sent to ${threadId}`));
171
+ } catch (e) {
172
+ error(e.message);
173
+ }
174
+ });
175
+
176
+ msg.command("send-file <threadId> <paths...>")
177
+ .description("Send files (docx, pdf, zip, etc.)")
178
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
179
+ .option("-m, --caption <text>", "Caption text", "")
180
+ .action(async (threadId, paths, opts) => {
181
+ try {
182
+ const absPaths = paths.map((p) => resolve(p));
183
+ const result = await getApi().sendMessage(
184
+ { msg: opts.caption, attachments: absPaths },
185
+ threadId,
186
+ Number(opts.type),
187
+ );
188
+ output(result, program.opts().json, () => success(`File(s) sent to ${threadId}`));
189
+ } catch (e) {
190
+ error(e.message);
191
+ }
192
+ });
193
+
194
+ msg.command("send-card <threadId> <userId>")
195
+ .description("Send a contact card (danh thiếp)")
196
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
197
+ .option("--phone <num>", "Phone number (auto-fetched if omitted)")
198
+ .action(async (threadId, userId, opts) => {
199
+ try {
200
+ const api = getApi();
201
+ let phone = opts.phone;
202
+ if (!phone) {
203
+ const userInfo = await api.getUserInfo(userId);
204
+ const profiles = userInfo?.changed_profiles || {};
205
+ phone = profiles[userId]?.phoneNumber || "";
206
+ if (phone) info(`Auto-detected phone: ${phone}`);
207
+ }
208
+ const cardOpts = { userId };
209
+ if (phone) cardOpts.phoneNumber = phone;
210
+ const result = await api.sendCard(cardOpts, threadId, Number(opts.type));
211
+ output(result, program.opts().json, () => success("Card sent"));
212
+ } catch (e) {
213
+ error(e.message);
214
+ }
215
+ });
216
+
217
+ msg.command("send-bank <threadId> <accountNumber>")
218
+ .description("Send a bank card (số tài khoản)")
219
+ .requiredOption("-b, --bank <name>", "Bank name (ocb, vcb, bidv) or BIN code")
220
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
221
+ .option("-n, --name <holder>", "Account holder name")
222
+ .action(async (threadId, accountNumber, opts) => {
223
+ try {
224
+ const { resolveBankBin, BIN_TO_DISPLAY } = await import("../utils/bank-helpers.js");
225
+ const bin = resolveBankBin(opts.bank);
226
+ if (!bin) {
227
+ error(`Unknown bank: '${opts.bank}'`);
228
+ return;
229
+ }
230
+ info(`Bank: ${BIN_TO_DISPLAY[bin] || bin} (BIN ${bin})`);
231
+
232
+ const payload = { binBank: bin, numAccBank: accountNumber };
233
+ if (opts.name) payload.nameAccBank = opts.name;
234
+ const result = await getApi().sendBankCard(payload, threadId, Number(opts.type));
235
+ output(result, program.opts().json, () =>
236
+ success(`Bank card sent: ${BIN_TO_DISPLAY[bin]} / ${accountNumber}`),
237
+ );
238
+ } catch (e) {
239
+ error(e.message);
240
+ }
241
+ });
242
+
243
+ msg.command("send-qr-transfer <threadId> <accountNumber>")
244
+ .description("Generate VietQR and send as image")
245
+ .requiredOption("-b, --bank <name>", "Bank name or BIN code")
246
+ .option("-a, --amount <n>", "Transfer amount in VND", parseInt)
247
+ .option("-m, --content <text>", "Transfer content (max 50 chars)")
248
+ .option("--template <tpl>", "QR style: compact, print, qronly", "compact")
249
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
250
+ .action(async (threadId, accountNumber, opts) => {
251
+ try {
252
+ const { resolveBankBin, BIN_TO_DISPLAY, generateQrTransferImage } =
253
+ await import("../utils/bank-helpers.js");
254
+ const bin = resolveBankBin(opts.bank);
255
+ if (!bin) {
256
+ error(`Unknown bank: '${opts.bank}'`);
257
+ return;
258
+ }
259
+ if (opts.content && opts.content.length > 50) {
260
+ error(`Content too long (${opts.content.length} chars). VietQR max is 50.`);
261
+ return;
262
+ }
263
+ info(
264
+ `Generating QR: ${BIN_TO_DISPLAY[bin]} / ${accountNumber}${opts.amount ? ` / ${opts.amount.toLocaleString()}đ` : ""}`,
265
+ );
266
+
267
+ const qrPath = await generateQrTransferImage(
268
+ bin,
269
+ accountNumber,
270
+ opts.amount,
271
+ opts.content,
272
+ opts.template,
273
+ );
274
+ if (!qrPath) {
275
+ error("Failed to generate QR image");
276
+ return;
277
+ }
278
+
279
+ const caption = [
280
+ `QR chuyển khoản ${BIN_TO_DISPLAY[bin]} - ${accountNumber}`,
281
+ opts.amount ? `${opts.amount.toLocaleString()}đ` : null,
282
+ opts.content || null,
283
+ ]
284
+ .filter(Boolean)
285
+ .join(" - ");
286
+
287
+ const result = await getApi().sendMessage(
288
+ { msg: caption, attachments: [qrPath] },
289
+ threadId,
290
+ Number(opts.type),
291
+ );
292
+
293
+ // Cleanup temp file
294
+ try {
295
+ (await import("fs")).unlinkSync(qrPath);
296
+ } catch {}
297
+
298
+ output(result, program.opts().json, () => success(`QR transfer sent to ${threadId}`));
299
+ } catch (e) {
300
+ error(e.message);
301
+ }
302
+ });
303
+
304
+ msg.command("sticker <threadId> <keyword>")
305
+ .description("Search and send a sticker")
306
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
307
+ .action(async (threadId, keyword, opts) => {
308
+ try {
309
+ const api = getApi();
310
+ const search = await api.searchSticker(keyword);
311
+ const first = search?.[0];
312
+ if (!first) {
313
+ error("No sticker found");
314
+ return;
315
+ }
316
+ // sendSticker expects {id, cateId, type} object
317
+ const stickerObj = {
318
+ id: first.sticker_id || first.stickerId || first.id,
319
+ cateId: first.cate_id || first.cateId,
320
+ type: first.type || 7,
321
+ };
322
+ const result = await api.sendSticker(stickerObj, threadId, Number(opts.type));
323
+ output(result, program.opts().json, () => success("Sticker sent"));
324
+ } catch (e) {
325
+ error(e.message);
326
+ }
327
+ });
328
+
329
+ msg.command("send-voice <threadId> <voiceUrl>")
330
+ .description("Send a voice message from URL")
331
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
332
+ .option("--ttl <ms>", "Time to live in milliseconds", parseInt, 0)
333
+ .action(async (threadId, voiceUrl, opts) => {
334
+ try {
335
+ info(`Sending voice: ${voiceUrl}`);
336
+ const result = await getApi().sendVoice({ voiceUrl, ttl: opts.ttl }, threadId, Number(opts.type));
337
+ output(result, program.opts().json, () => success(`Voice sent to ${threadId}`));
338
+ } catch (e) {
339
+ error(`Send voice failed: ${e.message}`);
340
+ }
341
+ });
342
+
343
+ msg.command("send-link <threadId> <url>")
344
+ .description("Send a link with auto-preview (title, description, thumbnail)")
345
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
346
+ .option("-m, --caption <text>", "Caption text")
347
+ .action(async (threadId, url, opts) => {
348
+ try {
349
+ info(`Sending link: ${url}`);
350
+ const result = await getApi().sendLink({ link: url, msg: opts.caption }, threadId, Number(opts.type));
351
+ output(result, program.opts().json, () => success(`Link sent to ${threadId}`));
352
+ } catch (e) {
353
+ error(`Send link failed: ${e.message}`);
354
+ }
355
+ });
356
+
357
+ msg.command("send-video <threadId> <videoUrl>")
358
+ .description("Send a video from URL")
359
+ .requiredOption("--thumb <url>", "Thumbnail image URL")
360
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
361
+ .option("-m, --caption <text>", "Caption text", "")
362
+ .option("-d, --duration <ms>", "Video duration in milliseconds", parseInt)
363
+ .option("-W, --width <px>", "Video width", parseInt, 1280)
364
+ .option("-H, --height <px>", "Video height", parseInt, 720)
365
+ .action(async (threadId, videoUrl, opts) => {
366
+ try {
367
+ info(`Sending video: ${videoUrl}`);
368
+ const result = await getApi().sendVideo(
369
+ {
370
+ videoUrl,
371
+ thumbnailUrl: opts.thumb,
372
+ msg: opts.caption,
373
+ duration: opts.duration,
374
+ width: opts.width,
375
+ height: opts.height,
376
+ },
377
+ threadId,
378
+ Number(opts.type),
379
+ );
380
+ output(result, program.opts().json, () => success(`Video sent to ${threadId}`));
381
+ } catch (e) {
382
+ error(`Send video failed: ${e.message}`);
383
+ }
384
+ });
385
+
386
+ msg.command("sticker-list <keyword>")
387
+ .description("Search stickers by keyword (returns sticker IDs)")
388
+ .action(async (keyword) => {
389
+ try {
390
+ const result = await getApi().getStickers(keyword);
391
+ output(result, program.opts().json, () => {
392
+ const ids = Array.isArray(result) ? result : [];
393
+ info(`${ids.length} sticker(s) found for "${keyword}"`);
394
+ for (const id of ids) console.log(` ${id}`);
395
+ });
396
+ } catch (e) {
397
+ error(`Sticker search failed: ${e.message}`);
398
+ }
399
+ });
400
+
401
+ msg.command("sticker-detail <stickerIds...>")
402
+ .description("Get sticker details by IDs")
403
+ .action(async (stickerIds) => {
404
+ try {
405
+ const ids = stickerIds.map(Number);
406
+ const result = await getApi().getStickersDetail(ids);
407
+ output(result, program.opts().json);
408
+ } catch (e) {
409
+ error(`Sticker detail failed: ${e.message}`);
410
+ }
411
+ });
412
+
413
+ msg.command("sticker-category <categoryId>")
414
+ .description("Get sticker category details")
415
+ .action(async (categoryId) => {
416
+ try {
417
+ const result = await getApi().getStickerCategoryDetail(Number(categoryId));
418
+ output(result, program.opts().json);
419
+ } catch (e) {
420
+ error(`Sticker category failed: ${e.message}`);
421
+ }
422
+ });
423
+
424
+ msg.command("react <msgId> <threadId> <reaction>")
425
+ .description(
426
+ "React to a message. Reaction codes: :> (haha), /-heart (heart), /-strong (like), :o (wow), :-(( (cry), :-h (angry)",
427
+ )
428
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
429
+ .option("-c, --cli-msg-id <id>", "Client message ID (required for reaction to appear, get from listen --json)")
430
+ .action(async (msgId, threadId, reaction, opts) => {
431
+ try {
432
+ // zca-js addReaction(icon, dest) — dest needs msgId + cliMsgId
433
+ const dest = {
434
+ data: { msgId, cliMsgId: opts.cliMsgId || msgId },
435
+ threadId,
436
+ type: Number(opts.type),
437
+ };
438
+ const result = await getApi().addReaction(reaction, dest);
439
+ output(result, program.opts().json, () => success(`Reacted with '${reaction}'`));
440
+ } catch (e) {
441
+ error(`React failed: ${e.message}`);
442
+ }
443
+ });
444
+
445
+ msg.command("delete <msgId> <threadId>")
446
+ .description("Delete a message you sent")
447
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
448
+ .action(async (msgId, threadId, opts) => {
449
+ try {
450
+ const result = await getApi().deleteMessage(msgId, threadId, Number(opts.type));
451
+ output(result, program.opts().json, () => success("Message deleted"));
452
+ } catch (e) {
453
+ error(e.message);
454
+ }
455
+ });
456
+
457
+ msg.command("undo <msgId> <threadId>")
458
+ .description("Recall/undo a message for both sides (like Zalo app recall). Requires cliMsgId.")
459
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
460
+ .option("-c, --cli-msg-id <id>", "Client message ID (required, get from listen --json or send --json)")
461
+ .action(async (msgId, threadId, opts) => {
462
+ try {
463
+ if (!opts.cliMsgId) {
464
+ error("cliMsgId is required for undo. Get it from: listen --json or send --json output.");
465
+ return;
466
+ }
467
+ const payload = { msgId, cliMsgId: opts.cliMsgId };
468
+ const result = await getApi().undo(payload, threadId, Number(opts.type));
469
+ output(result, program.opts().json, () => success("Message recalled (undone)"));
470
+ } catch (e) {
471
+ error(`Undo failed: ${e.message}`);
472
+ }
473
+ });
474
+
475
+ msg.command("forward <msgId> <threadId>")
476
+ .description("Forward a message to another thread")
477
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
478
+ .action(async (msgId, threadId, opts) => {
479
+ try {
480
+ const result = await getApi().forwardMessage(msgId, threadId, Number(opts.type));
481
+ output(result, program.opts().json, () => success("Message forwarded"));
482
+ } catch (e) {
483
+ error(e.message);
484
+ }
485
+ });
486
+
487
+ msg.command("history <threadId>")
488
+ .description("Fetch message history from a DM or group conversation via WebSocket")
489
+ .option("-t, --type <n>", "Thread type: 0=User(DM), 1=Group", "0")
490
+ .option("-n, --limit <n>", "Max messages to fetch (fetches in pages until limit)", "50")
491
+ .option("--timeout <ms>", "Timeout in milliseconds waiting for response", "15000")
492
+ .action(async (threadId, opts) => {
493
+ const jsonMode = program.opts().json;
494
+ const threadType = Number(opts.type);
495
+ const limit = Number(opts.limit);
496
+ const timeout = Number(opts.timeout);
497
+
498
+ try {
499
+ if (!jsonMode && limit > 100) {
500
+ info(
501
+ `Warning: fetching up to ${limit} messages. Large history may use significant memory and bandwidth.`,
502
+ );
503
+ }
504
+
505
+ const api = getApi();
506
+ const allMessages = [];
507
+ let lastMsgId = null;
508
+ let done = false;
509
+
510
+ // Start listener (required for WebSocket requestOldMessages)
511
+ await new Promise((resolve, reject) => {
512
+ const timer = setTimeout(() => reject(new Error("Listener connection timeout")), 10000);
513
+ api.listener.on("connected", () => {
514
+ clearTimeout(timer);
515
+ resolve();
516
+ });
517
+ api.listener.on("error", (err) => {
518
+ clearTimeout(timer);
519
+ reject(err);
520
+ });
521
+ api.listener.start({ retryOnClose: false });
522
+ });
523
+
524
+ // Fetch pages until limit reached or no more messages
525
+ while (!done && allMessages.length < limit) {
526
+ const pageMessages = await new Promise((resolve) => {
527
+ const handler = (messages) => {
528
+ clearTimeout(timer);
529
+ api.listener.removeListener("old_messages", handler);
530
+ resolve(messages);
531
+ };
532
+ const timer = setTimeout(() => {
533
+ api.listener.removeListener("old_messages", handler);
534
+ resolve([]);
535
+ }, timeout);
536
+
537
+ api.listener.on("old_messages", handler);
538
+ api.listener.requestOldMessages(threadType, lastMsgId);
539
+ });
540
+
541
+ if (!pageMessages || pageMessages.length === 0) {
542
+ done = true;
543
+ break;
544
+ }
545
+
546
+ for (const msg of pageMessages) {
547
+ if (allMessages.length >= limit) break;
548
+ // API returns messages globally — filter to requested thread
549
+ const msgThread = String(msg.threadId || "");
550
+ const msgSender = String(msg.data?.uidFrom || "");
551
+ const target = String(threadId);
552
+ if (msgThread !== target && msgSender !== target) continue;
553
+ allMessages.push({
554
+ msgId: msg.data?.msgId,
555
+ threadId: msg.threadId,
556
+ senderId: msg.data?.uidFrom || null,
557
+ senderName: msg.data?.dName || null,
558
+ text:
559
+ typeof msg.data?.content === "string"
560
+ ? msg.data.content
561
+ : extractMessageText(msg.data?.content, msg.data?.msgType),
562
+ timestamp: msg.data?.ts ? Number(msg.data.ts) : null,
563
+ type: typeof msg.data?.content === "string" ? "text" : msg.data?.msgType || "attachment",
564
+ });
565
+ }
566
+
567
+ // Use last message's actionId for pagination
568
+ const lastMsg = pageMessages[pageMessages.length - 1];
569
+ const nextId = lastMsg?.data?.actionId || lastMsg?.data?.msgId;
570
+ if (!nextId || nextId === lastMsgId) {
571
+ done = true;
572
+ }
573
+ lastMsgId = nextId;
574
+ }
575
+
576
+ // Sort by timestamp (oldest first)
577
+ allMessages.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
578
+
579
+ output(
580
+ {
581
+ threadId,
582
+ threadType: threadType === 0 ? "dm" : "group",
583
+ count: allMessages.length,
584
+ messages: allMessages,
585
+ },
586
+ jsonMode,
587
+ () => {
588
+ success(`${allMessages.length} message(s) from ${threadId}`);
589
+ for (const m of allMessages) {
590
+ const date = m.timestamp ? new Date(m.timestamp).toLocaleString() : "?";
591
+ const name = m.senderName || m.senderId || "?";
592
+ console.log(` [${date}] ${name}: ${(m.text || "").slice(0, 200)}`);
593
+ }
594
+ },
595
+ );
596
+
597
+ // Clean up listener
598
+ api.listener.stop();
599
+ process.exit(0);
600
+ } catch (e) {
601
+ try {
602
+ api.listener.stop();
603
+ } catch {}
604
+ error(`History fetch failed: ${e.message}`);
605
+ process.exit(1);
606
+ }
607
+ });
608
+ }