@ardennguyen/zalo-agent-cli 2.5.1 → 2.5.3
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/README.md +41 -33
- package/node_modules/zca-js/dist/apis.js +0 -8
- package/node_modules/zca-js/dist/cjs/apis.cjs +0 -8
- package/package.json +3 -3
- package/patches/zca-js+2.2.0.patch +4 -236
- package/src/commands/bank.js +126 -0
- package/src/commands/catalog.js +14 -0
- package/src/commands/conv.js +19 -0
- package/src/commands/friend.js +66 -21
- package/src/commands/group.js +38 -2
- package/src/commands/listen.js +51 -2
- package/src/commands/mcp.js +232 -2
- package/src/commands/msg.js +60 -0
- package/src/commands/profile.js +39 -0
- package/src/commands/sync.js +1 -68
- package/src/core/bank-accounts.js +52 -0
- package/src/core/daemon-channel.js +191 -2
- package/src/core/friend-requests.js +142 -0
- package/src/core/friend-search.js +41 -0
- package/src/core/group-admin.js +116 -0
- package/src/core/group-invite.js +53 -0
- package/src/core/sync.js +4 -235
- package/src/index.js +2 -0
- package/src/mcp/mcp-config.js +1 -0
- package/src/mcp/mcp-tools.js +302 -2
- package/src/utils/cli-args.js +84 -0
- package/src/utils/secrets.js +69 -0
- package/node_modules/zca-js/dist/apis/deleteSnapshotMobileMsg.js +0 -18
- package/node_modules/zca-js/dist/apis/getCrossDB.js +0 -18
- package/node_modules/zca-js/dist/apis/pullMobileMsg.js +0 -22
- package/node_modules/zca-js/dist/apis/requestSyncMessage.js +0 -18
- package/node_modules/zca-js/dist/cjs/apis/deleteSnapshotMobileMsg.cjs +0 -22
- package/node_modules/zca-js/dist/cjs/apis/getCrossDB.cjs +0 -22
- package/node_modules/zca-js/dist/cjs/apis/pullMobileMsg.cjs +0 -26
- package/node_modules/zca-js/dist/cjs/apis/requestSyncMessage.cjs +0 -22
package/src/core/sync.js
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
1
|
import { resolve } from "path";
|
|
3
|
-
import crypto from "crypto";
|
|
4
2
|
import { getAccountDir } from "./credentials.js";
|
|
5
3
|
import {
|
|
6
4
|
initDb,
|
|
7
|
-
insertMessage,
|
|
8
|
-
upsertThread,
|
|
9
5
|
getSyncState,
|
|
10
6
|
setSyncState,
|
|
11
7
|
recordSyncGap,
|
|
@@ -42,95 +38,14 @@ export const MAX_GAP_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
|
|
|
42
38
|
*/
|
|
43
39
|
export const SYNC_FRESHNESS_MS = 60 * 60 * 1000; // 1 hour
|
|
44
40
|
|
|
45
|
-
/**
|
|
46
|
-
* Best-effort field aliases for a single synced message, mirroring the shape
|
|
47
|
-
* `src/commands/listen.js` already normalizes live WS messages into before
|
|
48
|
-
* calling insertMessage()/upsertThread(). The exact shape of a `get_crossdb`
|
|
49
|
-
* message record hasn't been confirmed against real data yet (see
|
|
50
|
-
* agent/docs/mobile-sync-live-test-results.md) — this stays defensive and
|
|
51
|
-
* falls back to the raw dump file whenever a record doesn't look recognizable,
|
|
52
|
-
* rather than silently dropping or mis-storing it.
|
|
53
|
-
*/
|
|
54
|
-
function normalizeSyncedMessage(raw) {
|
|
55
|
-
if (!raw || typeof raw !== "object") return null;
|
|
56
|
-
|
|
57
|
-
const msgId = raw.msgId ?? raw.msg_id ?? raw.cliMsgId ?? raw.cli_msg_id;
|
|
58
|
-
const threadId = raw.threadId ?? raw.toId ?? raw.to_uid ?? raw.groupId ?? raw.uidTo;
|
|
59
|
-
if (!msgId || !threadId) return null;
|
|
60
|
-
|
|
61
|
-
const senderId = raw.uidFrom ?? raw.fromUid ?? raw.senderId ?? raw.from_uid ?? "";
|
|
62
|
-
const senderName = raw.dName ?? raw.displayName ?? raw.senderName ?? "";
|
|
63
|
-
const rawContent = raw.content ?? raw.text ?? raw.msg ?? null;
|
|
64
|
-
const isText = typeof rawContent === "string";
|
|
65
|
-
const msgType = raw.msgType ?? raw.msg_type ?? null;
|
|
66
|
-
const timestamp = raw.ts ? Number(raw.ts) : raw.timestamp ? Number(raw.timestamp) : Date.now();
|
|
67
|
-
const isGroup = raw.type === 1 || raw.isGroup === true || !!raw.groupId;
|
|
68
|
-
|
|
69
|
-
return {
|
|
70
|
-
msgId: String(msgId),
|
|
71
|
-
threadId: String(threadId),
|
|
72
|
-
senderId: String(senderId),
|
|
73
|
-
senderName: String(senderName),
|
|
74
|
-
text: isText ? rawContent : rawContent ? JSON.stringify(rawContent) : "",
|
|
75
|
-
timestamp,
|
|
76
|
-
type: isText ? "text" : msgType || "attachment",
|
|
77
|
-
raw_data: rawContent,
|
|
78
|
-
threadType: isGroup ? "group" : "dm",
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Pull out a plausible array of message-like records from whatever shape
|
|
83
|
-
* getCrossDB's decrypted payload turns out to have (object/array/JSON string). */
|
|
84
|
-
function extractMessageRecords(payload) {
|
|
85
|
-
let parsed = payload;
|
|
86
|
-
if (typeof parsed === "string") {
|
|
87
|
-
try {
|
|
88
|
-
parsed = JSON.parse(parsed);
|
|
89
|
-
} catch {
|
|
90
|
-
return []; // not JSON — nothing we can map into rows
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
if (Array.isArray(parsed)) return parsed;
|
|
94
|
-
if (parsed && typeof parsed === "object") {
|
|
95
|
-
for (const key of ["msgs", "messages", "data", "list", "items"]) {
|
|
96
|
-
if (Array.isArray(parsed[key])) return parsed[key];
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
return [];
|
|
100
|
-
}
|
|
101
|
-
|
|
102
41
|
export class SyncManager {
|
|
103
42
|
constructor(api, accountName) {
|
|
104
43
|
this.api = api;
|
|
105
44
|
this.accountName = accountName;
|
|
106
45
|
this.accountDir = resolveAccountDir(accountName);
|
|
107
|
-
this.syncSessionDir = resolve(this.accountDir, "sync");
|
|
108
|
-
if (!fs.existsSync(this.syncSessionDir)) {
|
|
109
|
-
fs.mkdirSync(this.syncSessionDir, { recursive: true });
|
|
110
|
-
}
|
|
111
|
-
this.keys = this._loadOrGenerateKeys();
|
|
112
46
|
this.dbInitialized = false;
|
|
113
47
|
}
|
|
114
48
|
|
|
115
|
-
_loadOrGenerateKeys() {
|
|
116
|
-
const keyFile = resolve(this.syncSessionDir, "rsa_keys.json");
|
|
117
|
-
if (fs.existsSync(keyFile)) {
|
|
118
|
-
return JSON.parse(fs.readFileSync(keyFile, "utf8"));
|
|
119
|
-
}
|
|
120
|
-
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
|
|
121
|
-
modulusLength: 2048,
|
|
122
|
-
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
123
|
-
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
124
|
-
});
|
|
125
|
-
const pubKeyRaw = publicKey
|
|
126
|
-
.replace(/-----BEGIN PUBLIC KEY-----/g, "")
|
|
127
|
-
.replace(/-----END PUBLIC KEY-----/g, "")
|
|
128
|
-
.replace(/\n/g, "");
|
|
129
|
-
const keys = { publicKey: pubKeyRaw, privateKey };
|
|
130
|
-
fs.writeFileSync(keyFile, JSON.stringify(keys), "utf8");
|
|
131
|
-
return keys;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
49
|
/** Lazily open the same zalo.db the `listen` daemon uses, so synced
|
|
135
50
|
* messages land in the exact same queryable cache as live-listened ones,
|
|
136
51
|
* and so gap/state bookkeeping is shared between `listen` and `sync-mobile`
|
|
@@ -163,8 +78,8 @@ export class SyncManager {
|
|
|
163
78
|
}
|
|
164
79
|
|
|
165
80
|
/** Called whenever we're confidently caught up (e.g. right after the WS
|
|
166
|
-
* reports "connected", or right after startup with no prior gap) so
|
|
167
|
-
*
|
|
81
|
+
* reports "connected", or right after startup with no prior gap) so
|
|
82
|
+
* sync freshness checks have an accurate picture. */
|
|
168
83
|
markConnected() {
|
|
169
84
|
this._ensureDb();
|
|
170
85
|
setSyncState("lastConnectedAt", Date.now());
|
|
@@ -197,7 +112,7 @@ export class SyncManager {
|
|
|
197
112
|
* This single marker is what {@link checkSyncFreshness} reads, so every
|
|
198
113
|
* sync path that reaches the server should call it on success.
|
|
199
114
|
*
|
|
200
|
-
* @param {string} [kind] - which path completed ("backfill" | "
|
|
115
|
+
* @param {string} [kind] - which path completed ("backfill" | "transfer").
|
|
201
116
|
* @param {{coveredFrom?: number}} [opts] - ms epoch this run reached back to,
|
|
202
117
|
* for the paths that know their window (transfer-sync does; the socket
|
|
203
118
|
* backfill does not). Recorded so a later, WIDER sync is not suppressed
|
|
@@ -213,7 +128,7 @@ export class SyncManager {
|
|
|
213
128
|
|
|
214
129
|
/**
|
|
215
130
|
* ms epoch the last successful sync reached back to, or null when that
|
|
216
|
-
* path never recorded a window (socket backfill,
|
|
131
|
+
* path never recorded a window (socket backfill, or a db written
|
|
217
132
|
* before this marker existed).
|
|
218
133
|
*
|
|
219
134
|
* @returns {number|null}
|
|
@@ -279,90 +194,6 @@ export class SyncManager {
|
|
|
279
194
|
return out(false, "stale");
|
|
280
195
|
}
|
|
281
196
|
|
|
282
|
-
/**
|
|
283
|
-
* One full sync cycle:
|
|
284
|
-
* 1. pullMobileMsg() — nudges the phone and gets back a short opaque
|
|
285
|
-
* token (confirmed live: 64 raw bytes, NOT message content — see
|
|
286
|
-
* agent/docs/mobile-sync-live-test-results.md). Earlier code treated
|
|
287
|
-
* this token itself as "the sync data" and stopped here, which meant
|
|
288
|
-
* it never actually retrieved anything.
|
|
289
|
-
* 2. getCrossDB(token) — the actual retrieval call. Only call this once
|
|
290
|
-
* we have a token from step 1.
|
|
291
|
-
* 3. Parse + store whatever comes back into zalo.db via the same
|
|
292
|
-
* insertMessage()/upsertThread() helpers the `listen` daemon uses.
|
|
293
|
-
* 4. deleteSnapshotMobileMsg() to ack completion, regardless of whether
|
|
294
|
-
* step 3 found anything, so the pending server-side request is
|
|
295
|
-
* cleaned up either way.
|
|
296
|
-
*
|
|
297
|
-
* By default this is gap-aware and debounced (task #3): if we have no
|
|
298
|
-
* recorded pending gap and our last known connection state was healthy,
|
|
299
|
-
* it returns `{status: "already-synced", cached: true}` immediately
|
|
300
|
-
* instead of calling the mobile API — mirroring what Zalo Web itself does
|
|
301
|
-
* (confirmed live: its own IndexedDB `missing_message_range` table is
|
|
302
|
-
* what gates whether clicking "Sync" re-pings the phone or just reports
|
|
303
|
-
* success instantly). Pass `{force: true}` to bypass this and always hit
|
|
304
|
-
* the API, e.g. for the explicit `sync-mobile` CLI command.
|
|
305
|
-
*/
|
|
306
|
-
async pollSync(fromSeqId = 0, isRetry = 0, opts = {}) {
|
|
307
|
-
const force = opts === true || opts?.force === true; // tolerate old boolean-ish callers
|
|
308
|
-
this._ensureDb();
|
|
309
|
-
|
|
310
|
-
if (!force) {
|
|
311
|
-
const pending = getPendingSyncGaps();
|
|
312
|
-
const connectionOk = getSyncState("lastConnectionOk");
|
|
313
|
-
if (pending.length === 0 && connectionOk === "true") {
|
|
314
|
-
console.log(`[Sync] No known coverage gap and connection state is healthy — skipping API call.`);
|
|
315
|
-
return { status: "already-synced", cached: true };
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
console.log(`[Sync] Requesting mobile push (pullMobileMsg)...`);
|
|
320
|
-
const pullRes = await this.api.pullMobileMsg(this.keys.publicKey, fromSeqId, isRetry, "");
|
|
321
|
-
const token = this._unwrap(pullRes);
|
|
322
|
-
if (!token) {
|
|
323
|
-
// MEASURED 2026-09-20 against the live Zalo Web client: this is
|
|
324
|
-
// not "the phone hasn't answered yet", it is the endpoint being
|
|
325
|
-
// retired. `/api/message/pull_mobile_msg` and `get_crossdb` still
|
|
326
|
-
// exist in Zalo Web's bundle but NOTHING calls them — 0 call sites
|
|
327
|
-
// across all 4,642 loaded modules. Current Zalo syncs over the
|
|
328
|
-
// WebSocket instead (cmd 590/591, "transfer-sync-v2"). See
|
|
329
|
-
// agent/work/transfer-sync-v2/NOTES.md § Mobile sync.
|
|
330
|
-
//
|
|
331
|
-
// Retrying therefore cannot help, and every retry used to push a
|
|
332
|
-
// notification to a real person's phone. One attempt, then stop.
|
|
333
|
-
console.log(`[Sync] pullMobileMsg returned no session token — the legacy mobile-sync endpoint is retired.`);
|
|
334
|
-
return { status: "legacy-retired", raw: pullRes };
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
console.log(`[Sync] Got sync session token, calling getCrossDB...`);
|
|
338
|
-
let crossDbRes;
|
|
339
|
-
try {
|
|
340
|
-
crossDbRes = await this.api.getCrossDB(token);
|
|
341
|
-
} catch (e) {
|
|
342
|
-
console.error(`[Sync] getCrossDB failed:`, e.message);
|
|
343
|
-
return { status: "crossdb-error", error: e.message, token };
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
const payload = this._unwrap(crossDbRes);
|
|
347
|
-
const result = await this.processSyncData(payload, token);
|
|
348
|
-
|
|
349
|
-
try {
|
|
350
|
-
await this.api.deleteSnapshotMobileMsg(this.keys.publicKey);
|
|
351
|
-
} catch (e) {
|
|
352
|
-
console.error(`[Sync] deleteSnapshotMobileMsg failed (non-fatal):`, e.message);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// A completed round-trip (even one that found nothing new) means
|
|
356
|
-
// we've genuinely asked the server "what did I miss?" and gotten an
|
|
357
|
-
// answer, so whatever gaps we knew about are covered now.
|
|
358
|
-
resolveAllPendingSyncGaps();
|
|
359
|
-
setSyncState("lastFullSyncAt", Date.now());
|
|
360
|
-
setSyncState("lastConnectionOk", "true");
|
|
361
|
-
this.markSyncSuccess("legacy");
|
|
362
|
-
|
|
363
|
-
return result;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
197
|
/**
|
|
367
198
|
* Backfill recent history straight from Zalo's servers over an already-open
|
|
368
199
|
* WebSocket, writing it into the same zalo.db the `listen` daemon uses.
|
|
@@ -475,66 +306,4 @@ export class SyncManager {
|
|
|
475
306
|
return false;
|
|
476
307
|
}
|
|
477
308
|
}
|
|
478
|
-
|
|
479
|
-
/** zca-js's apiFactory convention isn't 100% consistent about whether a
|
|
480
|
-
* raw method return is the payload itself or `{data: payload, ...}` —
|
|
481
|
-
* handle both without guessing wrong silently. */
|
|
482
|
-
_unwrap(res) {
|
|
483
|
-
if (res && typeof res === "object" && "data" in res && Object.keys(res).length <= 2) {
|
|
484
|
-
return res.data;
|
|
485
|
-
}
|
|
486
|
-
return res;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
async processSyncData(payload, token) {
|
|
490
|
-
// Always keep the raw dump — cheap forensic safety net regardless of
|
|
491
|
-
// whether structured parsing below succeeds.
|
|
492
|
-
const dumpFile = resolve(this.syncSessionDir, `sync_dump_${Date.now()}.json`);
|
|
493
|
-
fs.writeFileSync(dumpFile, JSON.stringify({ token, payload }), "utf8");
|
|
494
|
-
|
|
495
|
-
const records = extractMessageRecords(payload);
|
|
496
|
-
if (records.length === 0) {
|
|
497
|
-
console.log(
|
|
498
|
-
`[Sync] getCrossDB returned no recognizable message records (dumped raw to ${dumpFile}). ` +
|
|
499
|
-
`This is expected if there's no real offline gap right now, or means the payload shape ` +
|
|
500
|
-
`needs re-checking against agent/docs/mobile-sync-live-test-results.md.`,
|
|
501
|
-
);
|
|
502
|
-
return { status: "empty-or-unrecognized", dumpFile };
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
const threadsTouched = new Map();
|
|
506
|
-
let saved = 0;
|
|
507
|
-
for (const raw of records) {
|
|
508
|
-
const msg = normalizeSyncedMessage(raw);
|
|
509
|
-
if (!msg) continue;
|
|
510
|
-
try {
|
|
511
|
-
insertMessage({
|
|
512
|
-
msgId: msg.msgId,
|
|
513
|
-
threadId: msg.threadId,
|
|
514
|
-
senderId: msg.senderId,
|
|
515
|
-
senderName: msg.senderName,
|
|
516
|
-
text: msg.text,
|
|
517
|
-
timestamp: msg.timestamp,
|
|
518
|
-
type: msg.type,
|
|
519
|
-
raw_data: msg.raw_data,
|
|
520
|
-
});
|
|
521
|
-
if (!threadsTouched.has(msg.threadId) || threadsTouched.get(msg.threadId) < msg.timestamp) {
|
|
522
|
-
threadsTouched.set(msg.threadId, msg.timestamp);
|
|
523
|
-
upsertThread({
|
|
524
|
-
threadId: msg.threadId,
|
|
525
|
-
type: msg.threadType,
|
|
526
|
-
name: msg.senderName,
|
|
527
|
-
lastUpdate: msg.timestamp,
|
|
528
|
-
sync_timestamp: Date.now(),
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
|
-
saved++;
|
|
532
|
-
} catch (e) {
|
|
533
|
-
console.error(`[Sync] Failed to save message ${msg.msgId}:`, e.message);
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
console.log(`[Sync] Saved ${saved}/${records.length} synced messages to zalo.db (dump: ${dumpFile}).`);
|
|
538
|
-
return { status: "saved", saved, total: records.length, dumpFile };
|
|
539
|
-
}
|
|
540
309
|
}
|
package/src/index.js
CHANGED
|
@@ -25,6 +25,7 @@ import { registerAutoReplyCommands } from "./commands/auto-reply.js";
|
|
|
25
25
|
import { registerQuickMsgCommands } from "./commands/quick-msg.js";
|
|
26
26
|
import { registerLabelCommands } from "./commands/label.js";
|
|
27
27
|
import { registerCatalogCommands } from "./commands/catalog.js";
|
|
28
|
+
import { registerBankCommands } from "./commands/bank.js";
|
|
28
29
|
import { registerListenCommand } from "./commands/listen.js";
|
|
29
30
|
import { registerOACommands } from "./commands/oa.js";
|
|
30
31
|
import { registerMCPCommands } from "./commands/mcp.js";
|
|
@@ -147,6 +148,7 @@ registerAutoReplyCommands(program);
|
|
|
147
148
|
registerQuickMsgCommands(program);
|
|
148
149
|
registerLabelCommands(program);
|
|
149
150
|
registerCatalogCommands(program);
|
|
151
|
+
registerBankCommands(program);
|
|
150
152
|
registerListenCommand(program);
|
|
151
153
|
registerOACommands(program);
|
|
152
154
|
registerMCPCommands(program);
|
package/src/mcp/mcp-config.js
CHANGED
package/src/mcp/mcp-tools.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP tool registrations for Zalo message access, sending, user lookup, and group productivity.
|
|
3
|
-
* Registers
|
|
3
|
+
* Registers 25 tools: zalo_get_messages, zalo_get_history, zalo_send_message, zalo_list_threads,
|
|
4
4
|
* zalo_search_threads, zalo_mark_read, zalo_view_media, zalo_react, zalo_undo, zalo_get_group_members,
|
|
5
5
|
* zalo_list_conversations, zalo_coverage, zalo_sync, zalo_sync_status, zalo_search_messages,
|
|
6
6
|
* zalo_lookup_user, zalo_forward_message, zalo_delete_message, zalo_reminder, zalo_note, zalo_poll,
|
|
7
|
-
* zalo_wait_for_message, zalo_label.
|
|
7
|
+
* zalo_wait_for_message, zalo_label, zalo_friend, zalo_group.
|
|
8
8
|
*
|
|
9
9
|
* A tool that mirrors a CLI command calls the same code the command does (src/core/cached-message.js,
|
|
10
10
|
* forward.js, pin.js, group-members.js, recent-conversations.js, src/utils/my-documents.js, urgency.js),
|
|
@@ -47,6 +47,23 @@ import { expandMentions, ALL_MENTION_UID } from "../utils/mentions.js";
|
|
|
47
47
|
import { resolveSelfThread } from "../utils/my-documents.js";
|
|
48
48
|
import { buildQuote, resolveQuoteSender } from "../utils/quote.js";
|
|
49
49
|
import { urgencyLevel } from "../utils/urgency.js";
|
|
50
|
+
import {
|
|
51
|
+
describeFriendAddError,
|
|
52
|
+
extractErrorCode,
|
|
53
|
+
friendStatusFrom,
|
|
54
|
+
friendsFrom,
|
|
55
|
+
receivedRequestsFrom,
|
|
56
|
+
sentRequestsFrom,
|
|
57
|
+
} from "../core/friend-requests.js";
|
|
58
|
+
import {
|
|
59
|
+
CONFIRMED_ACTIONS,
|
|
60
|
+
NOT_A_MEMBER_CODES,
|
|
61
|
+
REMOVE_NOT_IN_GROUP,
|
|
62
|
+
createdGroupFrom,
|
|
63
|
+
groupConfirmRequired,
|
|
64
|
+
groupInfoFrom,
|
|
65
|
+
memberChangeFrom,
|
|
66
|
+
} from "../core/group-admin.js";
|
|
50
67
|
import { resolveCurrentUserNames, withCurrentSenderNames } from "../core/current-user-names.js";
|
|
51
68
|
|
|
52
69
|
/** Repeat mode codes matching zca-js ReminderRepeatMode enum and src/commands/reminder.js. */
|
|
@@ -2549,4 +2566,287 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
2549
2566
|
}
|
|
2550
2567
|
},
|
|
2551
2568
|
);
|
|
2569
|
+
|
|
2570
|
+
// --- zalo_friend ---
|
|
2571
|
+
server.registerTool(
|
|
2572
|
+
"zalo_friend",
|
|
2573
|
+
{
|
|
2574
|
+
title: "Manage Zalo Friends and Friend Requests",
|
|
2575
|
+
description:
|
|
2576
|
+
"Check, list and answer friend requests, and list friends. Actions: " +
|
|
2577
|
+
"'status' (the relationship with userId: is_friend, and is_requested / is_requesting, Zalo's two flags " +
|
|
2578
|
+
"for a request pending between you and them; to tell which direction a request goes, use the two lists), " +
|
|
2579
|
+
"'sent_requests' (requests you sent that are still pending), " +
|
|
2580
|
+
"'received_requests' (requests others sent you, newest first, with their message), " +
|
|
2581
|
+
"'send' (friend request to userId, with an optional message), " +
|
|
2582
|
+
"'accept' / 'reject' (the request from userId), " +
|
|
2583
|
+
"'undo' (cancel the request you sent to userId), " +
|
|
2584
|
+
"'list' (your friends, most recently active first, capped by limit). " +
|
|
2585
|
+
"userId is the numeric Zalo id (zalo_lookup_user finds one from a phone number). " +
|
|
2586
|
+
"Zalo restricts accounts that send many friend requests, so send sparingly.",
|
|
2587
|
+
inputSchema: z.object({
|
|
2588
|
+
action: z
|
|
2589
|
+
.enum(["status", "sent_requests", "received_requests", "send", "accept", "reject", "undo", "list"])
|
|
2590
|
+
.describe("Friend operation to perform"),
|
|
2591
|
+
userId: z
|
|
2592
|
+
.string()
|
|
2593
|
+
.min(1)
|
|
2594
|
+
.optional()
|
|
2595
|
+
.describe("Numeric Zalo user id (required for status, send, accept, reject and undo)"),
|
|
2596
|
+
message: z
|
|
2597
|
+
.string()
|
|
2598
|
+
.optional()
|
|
2599
|
+
.describe("Message sent with the friend request (only for 'send'; default: none)"),
|
|
2600
|
+
limit: z
|
|
2601
|
+
.number()
|
|
2602
|
+
.int()
|
|
2603
|
+
.min(1)
|
|
2604
|
+
.max(1000)
|
|
2605
|
+
.optional()
|
|
2606
|
+
.describe(
|
|
2607
|
+
"Most friends to return for 'list' (default 200; the reply says how many there are in all)",
|
|
2608
|
+
),
|
|
2609
|
+
}),
|
|
2610
|
+
},
|
|
2611
|
+
async ({ action, userId, message, limit }) => {
|
|
2612
|
+
const needsUser = ["status", "send", "accept", "reject", "undo"].includes(action);
|
|
2613
|
+
const target = userId === undefined ? "" : String(userId).trim();
|
|
2614
|
+
if (needsUser) {
|
|
2615
|
+
if (!target) return err(`userId is required for action='${action}'.`);
|
|
2616
|
+
if (!/^\d{1,30}$/.test(target)) {
|
|
2617
|
+
return err(
|
|
2618
|
+
`userId must be a numeric Zalo id (got "${target}"). zalo_lookup_user finds one from a phone number.`,
|
|
2619
|
+
);
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
try {
|
|
2623
|
+
switch (action) {
|
|
2624
|
+
case "status":
|
|
2625
|
+
return ok({ action, ...friendStatusFrom(await api.getFriendRequestStatus(target), target) });
|
|
2626
|
+
case "sent_requests": {
|
|
2627
|
+
let res = null;
|
|
2628
|
+
try {
|
|
2629
|
+
res = await api.getSentFriendRequest();
|
|
2630
|
+
} catch (e) {
|
|
2631
|
+
// Zalo answers code 112 when there are none, which is an empty list, not a failure.
|
|
2632
|
+
if (!/\b112\b/.test(String(e?.message))) throw e;
|
|
2633
|
+
}
|
|
2634
|
+
const requests = sentRequestsFrom(res);
|
|
2635
|
+
return ok({ action, count: requests.length, requests });
|
|
2636
|
+
}
|
|
2637
|
+
case "received_requests": {
|
|
2638
|
+
const requests = receivedRequestsFrom(await api.getFriendRecommendations());
|
|
2639
|
+
return ok({ action, count: requests.length, requests });
|
|
2640
|
+
}
|
|
2641
|
+
case "send":
|
|
2642
|
+
await api.sendFriendRequest(message ?? "", target);
|
|
2643
|
+
return ok({ action, success: true, userId: target });
|
|
2644
|
+
case "accept":
|
|
2645
|
+
await api.acceptFriendRequest(target);
|
|
2646
|
+
return ok({ action, success: true, userId: target });
|
|
2647
|
+
case "reject":
|
|
2648
|
+
await api.rejectFriendRequest(target);
|
|
2649
|
+
return ok({ action, success: true, userId: target });
|
|
2650
|
+
case "undo":
|
|
2651
|
+
await api.undoFriendRequest(target);
|
|
2652
|
+
return ok({ action, success: true, userId: target });
|
|
2653
|
+
case "list": {
|
|
2654
|
+
const { total, friends } = friendsFrom(await api.getAllFriends(), limit ?? 200);
|
|
2655
|
+
return ok({ action, total, count: friends.length, truncated: friends.length < total, friends });
|
|
2656
|
+
}
|
|
2657
|
+
default:
|
|
2658
|
+
return err(`Unsupported friend action: ${action}`);
|
|
2659
|
+
}
|
|
2660
|
+
} catch (e) {
|
|
2661
|
+
console.error("[mcp-tools] zalo_friend error:", e.message);
|
|
2662
|
+
// 'send' keeps the CLI's wording for Zalo's refusal codes (already friends, blocked, ...).
|
|
2663
|
+
const known =
|
|
2664
|
+
action === "send" ? describeFriendAddError(e.code ?? extractErrorCode(e.message), target) : null;
|
|
2665
|
+
return err(known || e.message);
|
|
2666
|
+
}
|
|
2667
|
+
},
|
|
2668
|
+
);
|
|
2669
|
+
|
|
2670
|
+
// --- zalo_group ---
|
|
2671
|
+
server.registerTool(
|
|
2672
|
+
"zalo_group",
|
|
2673
|
+
{
|
|
2674
|
+
title: "Manage Zalo Groups",
|
|
2675
|
+
description:
|
|
2676
|
+
"Leave, inspect, create and change the membership of a group (zalo_get_group_members lists who is " +
|
|
2677
|
+
"in one). Actions: " +
|
|
2678
|
+
"'info' (the group's name, description, creator, admins, member count and limit, creation time and " +
|
|
2679
|
+
"settings), " +
|
|
2680
|
+
"'create' (a new group named `name` with the members in userIds), " +
|
|
2681
|
+
"'add_members' (add userIds to the group), " +
|
|
2682
|
+
"'remove_member' (remove the one member userId from the group), " +
|
|
2683
|
+
"'leave' (leave the group; `silent` hides the usual notice from ordinary members; the owner still sees it). " +
|
|
2684
|
+
"groupId is the group's thread id (zalo_search_threads or zalo_list_conversations finds it); " +
|
|
2685
|
+
"user ids are numeric Zalo ids (zalo_lookup_user finds one from a phone number). " +
|
|
2686
|
+
"'leave' and 'remove_member' cannot be undone from here, so they need confirm: true (a plain " +
|
|
2687
|
+
"parameter, no prompt; a deployment can turn the check off with groupConfirm: false in " +
|
|
2688
|
+
"mcp-config.json or ZALO_MCP_GROUP_CONFIRM=0). Zalo can refuse some members " +
|
|
2689
|
+
"of a create or add_members: the reply lists the members it did and did not take.",
|
|
2690
|
+
inputSchema: z.object({
|
|
2691
|
+
action: z
|
|
2692
|
+
.enum(["leave", "info", "create", "add_members", "remove_member"])
|
|
2693
|
+
.describe("Group operation to perform"),
|
|
2694
|
+
groupId: z
|
|
2695
|
+
.string()
|
|
2696
|
+
.min(1)
|
|
2697
|
+
.optional()
|
|
2698
|
+
.describe("The group's id (required for leave, info, add_members and remove_member)"),
|
|
2699
|
+
name: z.string().optional().describe("The new group's name (required for 'create')"),
|
|
2700
|
+
userIds: z
|
|
2701
|
+
.array(z.string().min(1))
|
|
2702
|
+
.min(1)
|
|
2703
|
+
.max(100)
|
|
2704
|
+
.optional()
|
|
2705
|
+
.describe("Numeric Zalo user ids (required for 'create' and 'add_members')"),
|
|
2706
|
+
userId: z.string().min(1).optional().describe("Numeric Zalo user id (required for 'remove_member')"),
|
|
2707
|
+
silent: z
|
|
2708
|
+
.boolean()
|
|
2709
|
+
.optional()
|
|
2710
|
+
.describe(
|
|
2711
|
+
"Only for 'leave': hide the usual notice from ordinary members; the owner still sees it (default false)",
|
|
2712
|
+
),
|
|
2713
|
+
confirm: z
|
|
2714
|
+
.boolean()
|
|
2715
|
+
.optional()
|
|
2716
|
+
.describe(
|
|
2717
|
+
"Must be true for 'leave' and 'remove_member', which cannot be undone from here " +
|
|
2718
|
+
"(ignored when the deployment has switched the check off)",
|
|
2719
|
+
),
|
|
2720
|
+
}),
|
|
2721
|
+
},
|
|
2722
|
+
async ({ action, groupId, name, userIds, userId, silent, confirm }) => {
|
|
2723
|
+
const gid = groupId === undefined ? "" : String(groupId).trim();
|
|
2724
|
+
if (["leave", "info", "add_members", "remove_member"].includes(action)) {
|
|
2725
|
+
if (!gid) return err(`groupId is required for action='${action}'.`);
|
|
2726
|
+
if (!/^\d{1,30}$/.test(gid)) {
|
|
2727
|
+
return err(
|
|
2728
|
+
`groupId must be a numeric Zalo group id (got "${gid}"). zalo_search_threads finds one by name.`,
|
|
2729
|
+
);
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
const badId = (id) => !/^\d{1,30}$/.test(id);
|
|
2733
|
+
let members = [];
|
|
2734
|
+
if (action === "create" || action === "add_members") {
|
|
2735
|
+
members = [...new Set((userIds ?? []).map((u) => String(u).trim()))];
|
|
2736
|
+
if (members.length === 0) return err(`userIds is required for action='${action}'.`);
|
|
2737
|
+
const bad = members.find(badId);
|
|
2738
|
+
if (bad !== undefined) {
|
|
2739
|
+
return err(
|
|
2740
|
+
`userIds must be numeric Zalo ids (got "${bad}"). zalo_lookup_user finds one from a phone number.`,
|
|
2741
|
+
);
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
const target = userId === undefined ? "" : String(userId).trim();
|
|
2745
|
+
if (action === "remove_member") {
|
|
2746
|
+
if (!target) return err("userId is required for action='remove_member'.");
|
|
2747
|
+
if (badId(target)) {
|
|
2748
|
+
return err(
|
|
2749
|
+
`userId must be a numeric Zalo id (got "${target}"). zalo_get_group_members lists a group's members.`,
|
|
2750
|
+
);
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
const groupName = name === undefined ? "" : String(name).trim();
|
|
2754
|
+
if (action === "create" && !groupName) return err("name is required for action='create'.");
|
|
2755
|
+
|
|
2756
|
+
// After validation, before Zalo: a refused call changes nothing and says exactly how to retry.
|
|
2757
|
+
if (CONFIRMED_ACTIONS.includes(action) && confirm !== true && groupConfirmRequired({ config })) {
|
|
2758
|
+
const what = action === "leave" ? `leave group ${gid}` : `remove ${target} from group ${gid}`;
|
|
2759
|
+
return err(
|
|
2760
|
+
`Not done: this would ${what}, which cannot be undone from here. Repeat the call with ` +
|
|
2761
|
+
"confirm: true to proceed. (An automatic or headless deployment can turn this check off " +
|
|
2762
|
+
"with groupConfirm: false in mcp-config.json, or ZALO_MCP_GROUP_CONFIRM=0 in the environment.)",
|
|
2763
|
+
);
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
try {
|
|
2767
|
+
switch (action) {
|
|
2768
|
+
case "info": {
|
|
2769
|
+
const info = groupInfoFrom(await api.getGroupInfo([gid]), gid);
|
|
2770
|
+
if (!info) {
|
|
2771
|
+
return err(
|
|
2772
|
+
`Zalo's answer does not include group ${gid}: this account is not in it, or it is not ` +
|
|
2773
|
+
"a group id (a 1-1 conversation's id is the other person's uid).",
|
|
2774
|
+
);
|
|
2775
|
+
}
|
|
2776
|
+
return ok({ action, ...info });
|
|
2777
|
+
}
|
|
2778
|
+
case "create": {
|
|
2779
|
+
const made = createdGroupFrom(await api.createGroup({ name: groupName, members }), members);
|
|
2780
|
+
if (!made.groupId)
|
|
2781
|
+
return err("Zalo did not return a group id; the group may not have been created.");
|
|
2782
|
+
return ok({
|
|
2783
|
+
action,
|
|
2784
|
+
groupId: made.groupId,
|
|
2785
|
+
name: groupName,
|
|
2786
|
+
added: made.added,
|
|
2787
|
+
failed: made.failed,
|
|
2788
|
+
});
|
|
2789
|
+
}
|
|
2790
|
+
case "add_members": {
|
|
2791
|
+
const change = memberChangeFrom(await api.addUserToGroup(members, gid), members);
|
|
2792
|
+
if (change.done.length === 0) {
|
|
2793
|
+
const why = change.errorData
|
|
2794
|
+
? ` (Zalo's error codes: ${JSON.stringify(change.errorData)})`
|
|
2795
|
+
: "";
|
|
2796
|
+
return err(
|
|
2797
|
+
`Zalo did not add any of the ${members.length} member(s) to group ${gid}${why}.`,
|
|
2798
|
+
);
|
|
2799
|
+
}
|
|
2800
|
+
return ok({
|
|
2801
|
+
action,
|
|
2802
|
+
groupId: gid,
|
|
2803
|
+
added: change.done,
|
|
2804
|
+
failed: change.failed,
|
|
2805
|
+
...(change.errorData && { errorData: change.errorData }),
|
|
2806
|
+
});
|
|
2807
|
+
}
|
|
2808
|
+
case "remove_member": {
|
|
2809
|
+
const change = memberChangeFrom(await api.removeUserFromGroup([target], gid), [target]);
|
|
2810
|
+
if (change.failed.length > 0) {
|
|
2811
|
+
return err(
|
|
2812
|
+
`Zalo did not remove ${target} from group ${gid}: not a member, or this account is not ` +
|
|
2813
|
+
"allowed to remove members (a group's owner or an admin can).",
|
|
2814
|
+
);
|
|
2815
|
+
}
|
|
2816
|
+
return ok({ action, success: true, groupId: gid, userId: target });
|
|
2817
|
+
}
|
|
2818
|
+
case "leave": {
|
|
2819
|
+
const quiet = silent === true;
|
|
2820
|
+
const res = await api.leaveGroup(gid, quiet);
|
|
2821
|
+
const memberError = Array.isArray(res?.memberError) ? res.memberError : [];
|
|
2822
|
+
return ok({
|
|
2823
|
+
action,
|
|
2824
|
+
success: true,
|
|
2825
|
+
groupId: gid,
|
|
2826
|
+
silent: quiet,
|
|
2827
|
+
...(memberError.length > 0 && { memberError }),
|
|
2828
|
+
});
|
|
2829
|
+
}
|
|
2830
|
+
default:
|
|
2831
|
+
return err(`Unsupported group action: ${action}`);
|
|
2832
|
+
}
|
|
2833
|
+
} catch (e) {
|
|
2834
|
+
console.error("[mcp-tools] zalo_group error:", e.message);
|
|
2835
|
+
if (action === "leave" && NOT_A_MEMBER_CODES.includes(Number(e.code ?? extractErrorCode(e.message)))) {
|
|
2836
|
+
return err(
|
|
2837
|
+
`This account is not in group ${gid} (already left, or never joined), so there is nothing to leave.`,
|
|
2838
|
+
);
|
|
2839
|
+
}
|
|
2840
|
+
if (
|
|
2841
|
+
action === "remove_member" &&
|
|
2842
|
+
Number(e.code ?? extractErrorCode(e.message)) === REMOVE_NOT_IN_GROUP
|
|
2843
|
+
) {
|
|
2844
|
+
return err(
|
|
2845
|
+
`Zalo says ${target} is not in group ${gid} ("${e.message}", code ${REMOVE_NOT_IN_GROUP}).`,
|
|
2846
|
+
);
|
|
2847
|
+
}
|
|
2848
|
+
return err(e.message);
|
|
2849
|
+
}
|
|
2850
|
+
},
|
|
2851
|
+
);
|
|
2552
2852
|
}
|