@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.
- package/DISCLAIMER.md +27 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/package.json +64 -0
- package/src/cli.test.js +98 -0
- package/src/commands/account.js +178 -0
- package/src/commands/auto-reply.js +83 -0
- package/src/commands/catalog.js +143 -0
- package/src/commands/conv.js +264 -0
- package/src/commands/friend.js +384 -0
- package/src/commands/group.js +606 -0
- package/src/commands/label.js +39 -0
- package/src/commands/listen.js +293 -0
- package/src/commands/login.js +165 -0
- package/src/commands/mcp.js +219 -0
- package/src/commands/msg.js +608 -0
- package/src/commands/oa-init.js +503 -0
- package/src/commands/oa-listen.js +215 -0
- package/src/commands/oa.js +657 -0
- package/src/commands/poll.js +159 -0
- package/src/commands/profile.js +274 -0
- package/src/commands/quick-msg.js +55 -0
- package/src/commands/reminder.js +195 -0
- package/src/core/accounts.js +93 -0
- package/src/core/credentials.js +63 -0
- package/src/core/oa-client.js +407 -0
- package/src/core/zalo-client.js +201 -0
- package/src/index.js +95 -0
- package/src/mcp/mcp-config.js +81 -0
- package/src/mcp/mcp-http-transport.js +84 -0
- package/src/mcp/mcp-server.js +40 -0
- package/src/mcp/mcp-tools.js +353 -0
- package/src/mcp/media-downloader.js +191 -0
- package/src/mcp/media-downloader.test.js +45 -0
- package/src/mcp/message-buffer.js +127 -0
- package/src/mcp/message-buffer.test.js +308 -0
- package/src/mcp/notifier.js +141 -0
- package/src/mcp/notifier.test.js +269 -0
- package/src/mcp/thread-filter.js +79 -0
- package/src/mcp/thread-filter.test.js +206 -0
- package/src/mcp/thread-name-cache.js +162 -0
- package/src/mcp/thread-name-cache.test.js +139 -0
- package/src/utils/bank-helpers.js +233 -0
- package/src/utils/bank-helpers.test.js +66 -0
- package/src/utils/extract-message-text.js +21 -0
- package/src/utils/output.js +21 -0
- package/src/utils/proxy-helpers.js +14 -0
- package/src/utils/proxy-helpers.test.js +36 -0
- package/src/utils/qr-display.js +91 -0
- package/src/utils/qr-display.test.js +97 -0
- package/src/utils/qr-http-server.js +190 -0
- 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
|
+
}
|