@ardennguyen/zalo-agent-cli 2.2.0 → 2.2.1

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 CHANGED
@@ -50,7 +50,7 @@ Xây dựng trên [zca-js](https://github.com/RFS-ADRENO/zca-js).
50
50
  > zalo-agent mcp start --account <ID> # Ghim tài khoản (chạy nhiều tài khoản live cùng lúc)
51
51
  > zalo-agent mcp start --http 3847 --auth your-secret # HTTP (VPS)
52
52
  > ```
53
- > 14 tools: `zalo_get_messages` · `zalo_send_message` · `zalo_list_threads` · `zalo_search_threads` · `zalo_mark_read` · `zalo_get_history` · `zalo_view_media` · `zalo_react` · `zalo_undo` · `zalo_get_group_members` · `zalo_list_conversations` · `zalo_coverage` · `zalo_sync` · `zalo_sync_status`
53
+ > 21 tools: `zalo_get_messages` · `zalo_send_message` · `zalo_list_threads` · `zalo_search_threads` · `zalo_mark_read` · `zalo_get_history` · `zalo_view_media` · `zalo_react` · `zalo_undo` · `zalo_get_group_members` · `zalo_list_conversations` · `zalo_coverage` · `zalo_sync` · `zalo_sync_status` · `zalo_search_messages` · `zalo_lookup_user` · `zalo_forward_message` · `zalo_delete_message` · `zalo_reminder` · `zalo_note` · `zalo_poll`
54
54
 
55
55
  [Sync qua MCP](docs/mcp-sync.vi.md) · [Ghim đa tài khoản live](docs/account-pinning.vi.md).
56
56
  > Auto-reconnect · thread filter · noise reduction · tự tải media · thông báo qua nhóm Zalo · chạy song song nhiều tài khoản (`--account <ID>`)
@@ -124,7 +124,7 @@ Tất cả lệnh hỗ trợ `--json`. Tài liệu đầy đủ: **[Wiki](https:
124
124
  | `label` | 2 | Nhãn hội thoại | [Nhãn](https://github.com/ardennguyen/zalo-agent-cli/wiki/Nh%C3%A3n) |
125
125
  | `catalog` | 9 | zBusiness — danh mục sản phẩm | [zBusiness](https://github.com/ardennguyen/zalo-agent-cli/wiki/zBusiness) |
126
126
  | `listen` | 1 | Lắng nghe real-time, webhook, JSONL, ghi vào cache cục bộ | [Lắng nghe](https://github.com/ardennguyen/zalo-agent-cli/wiki/L%E1%BA%AFng-Nghe) |
127
- | `mcp` | 1 | MCP server (stdio/HTTP) — 14 tools cho AI agent | [MCP Server](https://github.com/ardennguyen/zalo-agent-cli/wiki/MCP-Server-(VN)) |
127
+ | `mcp` | 1 | MCP server (stdio/HTTP) — 21 tools cho AI agent | [MCP Server](https://github.com/ardennguyen/zalo-agent-cli/wiki/MCP-Server-(VN)) |
128
128
  | **`oa`** | **32** | **Zalo Official Account API v3.0 — OAuth, tin nhắn, follower, tag, bài viết, cửa hàng, webhook** | **[Official Account](https://github.com/ardennguyen/zalo-agent-cli/wiki/Official-Account-(VN))** |
129
129
 
130
130
  Xem thêm: [Đa tài khoản & Proxy](https://github.com/ardennguyen/zalo-agent-cli/wiki/%C4%90a-T%C3%A0i-Kho%E1%BA%A3n-&-Proxy) · [Cài đặt VPS](https://github.com/ardennguyen/zalo-agent-cli/wiki/C%C3%A0i-%C4%90%E1%BA%B7t-VPS) · [Thẻ chuyển khoản & QR](https://github.com/ardennguyen/zalo-agent-cli/wiki/Th%E1%BA%BB-Chuy%E1%BB%83n-Kho%E1%BA%A3n-&-QR) · [Bộ nhớ đệm & Đồng bộ](https://github.com/ardennguyen/zalo-agent-cli/wiki/B%E1%BB%99-Nh%E1%BB%9B-%C4%90%E1%BB%87m-&-%C4%90%E1%BB%93ng-B%E1%BB%99) · [Bảo mật](https://github.com/ardennguyen/zalo-agent-cli/wiki/B%E1%BA%A3o-M%E1%BA%ADt)
@@ -137,7 +137,7 @@ Xem thêm: [Đa tài khoản & Proxy](https://github.com/ardennguyen/zalo-agent-
137
137
  - Đa tài khoản với proxy riêng biệt (1:1), device fingerprint riêng cho từng tài khoản; chạy **nhiều tài khoản live đồng thời** (mỗi tài khoản một tiến trình `mcp start` hoặc `listen` kèm `--account <ID>`, và truyền `--account <ID>` / `ZALO_ACCOUNT` trên mọi lệnh CLI mà không ghi đè `accounts.json`)
138
138
  - **190 lệnh** phủ hết tính năng Zalo cá nhân
139
139
  - **Zalo Official Account (OA) API v3.0** — OAuth login, gửi tin nhắn, follower, tag, bài viết, cửa hàng, webhook listener, multi-OA
140
- - **MCP server** (stdio + HTTP) — 14 tools cho Claude Code và các MCP client: đọc, gửi (kể cả vào My Documents, đánh dấu Quan trọng/Khẩn cấp), thả cảm xúc, thu hồi tin, xem thành viên nhóm, liệt kê hội thoại gần đây và kiểm tra độ đầy đủ của cache
140
+ - **MCP server** (stdio + HTTP) — 21 tools cho Claude Code và các MCP client: đọc, gửi (văn bản, link preview, danh thiếp, vào My Documents, đánh dấu Quan trọng/Khẩn cấp), tìm kiếm tin nhắn trong cache, tra cứu người dùng, thả cảm xúc, thu hồi, chuyển tiếp, xóa tin, nhắc hẹn, ghi chú/ghim tin, bình chọn, xem thành viên nhóm, liệt kê hội thoại gần đây và kiểm tra độ đầy đủ của cache
141
141
  - **Bộ nhớ đệm cục bộ (SQLite)** — `listen` ghi mọi tin nhắn vào `zalo.db`, `msg history` đọc từ cache, `sync` (hoặc `sync-mobile`) khôi phục toàn bộ lịch sử từ điện thoại vào `zalo.db` (giải mã transfer-sync-v2; xác nhận một lần trên điện thoại), mặc định lấy **toàn bộ** lịch sử (không còn mốc chặn 2024-01-01); thêm `--days <n>` hoặc `--from <YYYY-MM-DD>` nếu chỉ cần một khoảng. Dọn dẹp: `sync-media --prune <days|all>` giải phóng dung lượng nhưng giữ nguyên nội dung tin nhắn, `--prune-orphans` nhắm vào hội thoại tài khoản không còn, và `conv forget` xoá hẳn bản sao cục bộ của một hội thoại — không cái nào gửi gì tới Zalo. Luồng đồng bộ chỉ mang **liên kết** media chứ không mang file, nên sau khi lưu tin nhắn, `sync-mobile` **tự động tải media về** (thêm `--messages-only` nếu chỉ muốn tin nhắn); `sync-media` chạy lại/tiếp tục bước tải này, không cần xác nhận trên điện thoại. Ghi chú, tin ghim, bình chọn và nhắc hẹn nằm ngoài luồng tin nhắn — dùng `sync-boards`
142
142
  - Tự động tải media (ảnh/audio/video) — một downloader duy nhất cho `listen`, `msg history`, `mcp start` và các lệnh sync, lưu vào `~/.zalo-agent-cli/accounts/<id>/media/<threadId>/` (hoặc tách riêng credentials, database `zalo.db`, media và OA sang các ổ đĩa/mount khác nhau qua `--credentials-dir`, `--data-dir`, `--media-dir`, `--oa-dir` / biến môi trường `ZALO_*_DIR` trong `~/.zalo-agent-cli/.env`; nếu dùng kèm `zalo-mcp`, chỉ nên giữ cấu hình PORT trong `zalo-mcp/.env` và đặt toàn bộ đường dẫn lưu trữ vào `<CONFIG_DIR>/.env`)
143
143
  - Thẻ chuyển khoản (55+ ngân hàng VN) & QR VietQR
@@ -215,7 +215,7 @@ CLI tool for Zalo automation — multi-account, proxy support, bank transfers, Q
215
215
  > zalo-agent mcp start --account <ID> # Pin account (run multiple live accounts at once)
216
216
  > zalo-agent mcp start --http 3847 --auth your-secret # HTTP (VPS)
217
217
  > ```
218
- > 14 tools: `zalo_get_messages` · `zalo_send_message` · `zalo_list_threads` · `zalo_search_threads` · `zalo_mark_read` · `zalo_get_history` · `zalo_view_media` · `zalo_react` · `zalo_undo` · `zalo_get_group_members` · `zalo_list_conversations` · `zalo_coverage` · `zalo_sync` · `zalo_sync_status`
218
+ > 21 tools: `zalo_get_messages` · `zalo_send_message` · `zalo_list_threads` · `zalo_search_threads` · `zalo_mark_read` · `zalo_get_history` · `zalo_view_media` · `zalo_react` · `zalo_undo` · `zalo_get_group_members` · `zalo_list_conversations` · `zalo_coverage` · `zalo_sync` · `zalo_sync_status` · `zalo_search_messages` · `zalo_lookup_user` · `zalo_forward_message` · `zalo_delete_message` · `zalo_reminder` · `zalo_note` · `zalo_poll`
219
219
 
220
220
  [MCP sync](docs/mcp-sync.md) · [Multi-account pinning](docs/account-pinning.md).
221
221
  > Auto-reconnect · thread filter · noise reduction · media auto-download · Zalo-group notifications · concurrent multi-account (`--account <ID>`)
@@ -255,7 +255,7 @@ Full docs: **[Wiki](https://github.com/ardennguyen/zalo-agent-cli/wiki)** · [Fu
255
255
  | `label` | 2 | Conversation labels | [Labels](https://github.com/ardennguyen/zalo-agent-cli/wiki/Labels) |
256
256
  | `catalog` | 9 | zBusiness catalogs & products | [Catalog](https://github.com/ardennguyen/zalo-agent-cli/wiki/Catalog) |
257
257
  | `listen` | 1 | Real-time listener, webhook, JSONL, local cache writes | [Listener](https://github.com/ardennguyen/zalo-agent-cli/wiki/Listener) |
258
- | `mcp` | 1 | MCP server (stdio/HTTP) — 14 tools for AI agents | [MCP Server](https://github.com/ardennguyen/zalo-agent-cli/wiki/MCP-Server) |
258
+ | `mcp` | 1 | MCP server (stdio/HTTP) — 21 tools for AI agents | [MCP Server](https://github.com/ardennguyen/zalo-agent-cli/wiki/MCP-Server) |
259
259
  | **`oa`** | **32** | **Zalo Official Account API v3.0 — OAuth, messaging, followers, tags, articles, store, webhook** | **[Official Account](https://github.com/ardennguyen/zalo-agent-cli/wiki/Official-Account)** |
260
260
 
261
261
  See also: [Multi-Account & Proxy](https://github.com/ardennguyen/zalo-agent-cli/wiki/Multi-Account-&-Proxy) · [VPS Setup](https://github.com/ardennguyen/zalo-agent-cli/wiki/VPS-Setup) · [Bank Card & QR Payments](https://github.com/ardennguyen/zalo-agent-cli/wiki/Bank-Card-&-QR-Payments) · [Local Cache & Sync](https://github.com/ardennguyen/zalo-agent-cli/wiki/Local-Cache-&-Sync) · [Security](https://github.com/ardennguyen/zalo-agent-cli/wiki/Security)
@@ -266,7 +266,7 @@ See also: [Multi-Account & Proxy](https://github.com/ardennguyen/zalo-agent-cli/
266
266
  - Multi-account with a dedicated proxy (1:1) and a per-account device fingerprint; run **multiple live accounts simultaneously** (one `mcp start` or `listen` process per account with `--account <ID>`, plus `--account <ID>` / `ZALO_ACCOUNT` on any CLI command without mutating `accounts.json`)
267
267
  - **190 commands** covering the personal-account Zalo surface
268
268
  - **Zalo Official Account (OA) API v3.0** — OAuth login, messaging, followers, tags, articles, store, webhook listener, multi-OA
269
- - **MCP server** (stdio + HTTP) — 14 tools for Claude Code and other MCP clients: read, send (to My Documents too, marked Important/Urgent), react, recall, list a group's members, list recent conversations, and check how complete the cache is
269
+ - **MCP server** (stdio + HTTP) — 21 tools for Claude Code and other MCP clients: read, send (text, link previews, contact cards, to My Documents too, marked Important/Urgent), search cached messages, look up users, react, recall, forward, delete, reminders, group notes/pins, group polls, list a group's members, list recent conversations, and check how complete the cache is
270
270
  - **Local SQLite cache** — `listen` writes every message to `zalo.db`, `msg history` reads from it, and `sync` (or `sync-mobile`) restores full history from the phone into `zalo.db` (transfer-sync-v2 decrypt; one confirmation on the phone), defaulting to **all** history (the old 2024-01-01 floor is gone), with `--days <n>` or `--from <YYYY-MM-DD>` to narrow it. Housekeeping: `sync-media --prune <days|all>` reclaims disk while keeping message text, `--prune-orphans` targets conversations the account no longer has, and `conv forget` removes a conversation's local copy entirely — none of which contact Zalo. The sync stream carries media **links**, not files, so a `sync-mobile` run **downloads its attachments automatically** once the messages are stored (`--messages-only` skips it); `sync-media` re-runs or resumes that fetch on its own, with no phone confirmation. Notes, pinned messages, polls and reminders live outside the message stream — `sync-boards` fetches those
271
271
  - Automatic media download — one downloader shared by `listen`, `msg history`, `mcp start` and the sync commands, saving to `~/.zalo-agent-cli/accounts/<id>/media/<threadId>/` (or separate credentials, `zalo.db`, media, and OA credentials onto independent volumes/mounts via `--credentials-dir`, `--data-dir`, `--media-dir`, `--oa-dir` / `ZALO_*_DIR` in `~/.zalo-agent-cli/.env`; when using `zalo-mcp`, keep `zalo-mcp/.env` for PORT settings only and place all storage/engine settings in `<CONFIG_DIR>/.env`)
272
272
  - Bank cards (55+ VN banks) and VietQR payment images
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ardennguyen/zalo-agent-cli",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "CLI tool for Zalo automation — multi-account, proxy, bank transfers, QR payments, Official Account API v3.0",
5
5
  "type": "module",
6
6
  "bin": {
@@ -77,6 +77,6 @@
77
77
  },
78
78
  "allowScripts": {
79
79
  "better-sqlite3@13.0.3": true,
80
- "@ardennguyen/zalo-agent-cli@2.1.1": true
80
+ "@ardennguyen/zalo-agent-cli@2.2.1": true
81
81
  }
82
82
  }
package/src/core/db.js CHANGED
@@ -621,6 +621,50 @@ export function getMessageById(msgId) {
621
621
  return db.prepare("SELECT * FROM messages WHERE msgId = ?").get(String(msgId)) || null;
622
622
  }
623
623
 
624
+ /**
625
+ * Search cached messages by text substring across one or all conversations, newest first.
626
+ *
627
+ * Purely local SQLite query — never touches the Zalo network. Excludes recalled/deleted
628
+ * tombstones (`type = 'deleted'`) and escapes SQLite LIKE wildcards (`%`, `_`, `\`) so literal
629
+ * characters in `query` match as literals.
630
+ *
631
+ * @param {object} opts
632
+ * @param {string} opts.query - Substring to match in message text (case-insensitive for ASCII)
633
+ * @param {string} [opts.threadId] - Restrict to one conversation
634
+ * @param {string} [opts.senderId] - Restrict to one sender UID
635
+ * @param {number} [opts.since] - Only messages at or after this epoch-ms timestamp
636
+ * @param {number} [opts.until] - Only messages at or before this epoch-ms timestamp
637
+ * @param {number} [opts.limit=50] - Max messages to return
638
+ * @returns {Array<object>}
639
+ */
640
+ export function searchMessages(opts = {}) {
641
+ if (!db) throw new Error("Database not initialized");
642
+ const { query, threadId, senderId, since, until, limit = 50 } = opts;
643
+ const escaped = String(query ?? "").replace(/[\\%_]/g, "\\$&");
644
+ const where = ["COALESCE(type, '') != 'deleted'", "text IS NOT NULL", "text LIKE ? ESCAPE '\\'"];
645
+ const params = [`%${escaped}%`];
646
+ if (threadId) {
647
+ where.push("threadId = ?");
648
+ params.push(String(threadId));
649
+ }
650
+ if (senderId) {
651
+ where.push("senderId = ?");
652
+ params.push(String(senderId));
653
+ }
654
+ if (Number.isFinite(since)) {
655
+ where.push("timestamp >= ?");
656
+ params.push(Number(since));
657
+ }
658
+ if (Number.isFinite(until)) {
659
+ where.push("timestamp <= ?");
660
+ params.push(Number(until));
661
+ }
662
+ params.push(limit);
663
+ return db
664
+ .prepare(`SELECT * FROM messages WHERE ${where.join(" AND ")} ORDER BY timestamp DESC LIMIT ?`)
665
+ .all(...params);
666
+ }
667
+
624
668
  /**
625
669
  * Most recently active threads, newest first.
626
670
  *
@@ -1,20 +1,42 @@
1
1
  /**
2
- * MCP tool registrations for Zalo message access and sending.
3
- * Registers 14 tools: zalo_get_messages, zalo_get_history, zalo_send_message, zalo_list_threads,
2
+ * MCP tool registrations for Zalo message access, sending, user lookup, and group productivity.
3
+ * Registers 21 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
- * zalo_list_conversations, zalo_coverage, zalo_sync, zalo_sync_status.
5
+ * zalo_list_conversations, zalo_coverage, zalo_sync, zalo_sync_status, zalo_search_messages,
6
+ * zalo_lookup_user, zalo_forward_message, zalo_delete_message, zalo_reminder, zalo_note, zalo_poll.
6
7
  *
7
8
  * A tool that mirrors a CLI command calls the same code the command does (src/core/cached-message.js,
8
- * group-members.js, recent-conversations.js, src/utils/my-documents.js, urgency.js), so the two cannot
9
- * drift apart.
9
+ * forward.js, pin.js, group-members.js, recent-conversations.js, src/utils/my-documents.js, urgency.js),
10
+ * so the two cannot drift apart.
10
11
  */
11
12
 
13
+ import { existsSync } from "node:fs";
14
+ import { resolve } from "node:path";
12
15
  import { z } from "zod";
13
16
  import { openFile } from "../utils/open-file.js";
14
- import { getMessages, getMessageById, getDisplayName, getThreadType, getConvState, getReadStates } from "../core/db.js";
17
+ import {
18
+ getMessages,
19
+ getMessageById,
20
+ searchMessages,
21
+ getDisplayName,
22
+ getThreadType,
23
+ getThreadNames,
24
+ getConvState,
25
+ getReadStates,
26
+ } from "../core/db.js";
15
27
  import { downloadSyncedMedia } from "../core/sync-v2/media.js";
28
+ import { describeZaloError } from "../core/sync-v2/board.js";
16
29
  import { fetchAndCacheHistory } from "../core/history-fetch.js";
17
- import { reactionCliMsgId, recallCliMsgId } from "../core/cached-message.js";
30
+ import {
31
+ cachedCliMsgId,
32
+ cachedText,
33
+ resolveSenderUid,
34
+ cachedMessageById,
35
+ reactionCliMsgId,
36
+ recallCliMsgId,
37
+ } from "../core/cached-message.js";
38
+ import { buildForwardReference, sendForward, LOG_SRC_TYPE } from "../core/forward.js";
39
+ import { pinMessage, unpinMessage, textPinParams } from "../core/pin.js";
18
40
  import { coverageReport } from "../core/coverage.js";
19
41
  import { groupMemberUids, fetchMemberNames } from "../core/group-members.js";
20
42
  import { recentConversations } from "../core/recent-conversations.js";
@@ -26,6 +48,81 @@ import { buildQuote, resolveQuoteSender } from "../utils/quote.js";
26
48
  import { urgencyLevel } from "../utils/urgency.js";
27
49
  import { resolveCurrentUserNames, withCurrentSenderNames } from "../core/current-user-names.js";
28
50
 
51
+ /** Repeat mode codes matching zca-js ReminderRepeatMode enum and src/commands/reminder.js. */
52
+ const REMINDER_REPEAT_MODES = { none: 0, daily: 1, weekly: 2, monthly: 3 };
53
+ const REMINDER_REPEAT_LABELS = { 0: "none", 1: "daily", 2: "weekly", 3: "monthly" };
54
+
55
+ /**
56
+ * Parse a reminder time value (epoch-ms number, "YYYY-MM-DD HH:mm", or ISO string) into epoch ms.
57
+ *
58
+ * @param {number|string} val
59
+ * @returns {number|null}
60
+ */
61
+ function parseReminderTime(val) {
62
+ if (typeof val === "number") return Number.isFinite(val) && val > 0 ? Math.trunc(val) : null;
63
+ const str = String(val ?? "").trim();
64
+ if (/^\d+$/.test(str)) {
65
+ const n = Number(str);
66
+ return Number.isFinite(n) && n > 0 ? n : null;
67
+ }
68
+ const match = str.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})$/);
69
+ if (match) {
70
+ const [, y, mo, d, h, mi] = match;
71
+ return new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi)).getTime();
72
+ }
73
+ const ts = Date.parse(str);
74
+ return Number.isNaN(ts) ? null : ts;
75
+ }
76
+
77
+ /**
78
+ * Build the source descriptor `buildForwardReference` expects from a cached message row.
79
+ *
80
+ * @param {object} api - logged-in zca-js API instance
81
+ * @param {object} row - cached message row from zalo.db
82
+ * @returns {Promise<object>}
83
+ */
84
+ async function forwardSourceForMcp(api, row) {
85
+ const cliMsgId = cachedCliMsgId(row);
86
+ if (!cliMsgId) {
87
+ return {
88
+ error:
89
+ "its cliMsgId was never cached, and the forward reference is derived from it " +
90
+ "(`listen`/`mcp` caches messages as they arrive, `sync` restores older ones)",
91
+ };
92
+ }
93
+ const threadId = String(row.threadId || "");
94
+ let send2meId = null;
95
+ try {
96
+ send2meId = api.getContext?.()?.loginInfo?.send2me_id ? String(api.getContext().loginInfo.send2me_id) : null;
97
+ } catch {
98
+ /* ignore */
99
+ }
100
+ const isMyDocuments = Boolean(threadId) && threadId === send2meId;
101
+ let kind = null;
102
+ try {
103
+ kind = threadId ? getThreadType(threadId) : null;
104
+ } catch {
105
+ /* reported below */
106
+ }
107
+ if (!kind && !isMyDocuments) {
108
+ return {
109
+ error:
110
+ `the local cache does not say whether its conversation (${threadId || "unknown"}) is a group ` +
111
+ "or a 1-1, and the forward reference depends on it",
112
+ };
113
+ }
114
+ const sender = await resolveSenderUid(api, row.senderId, ownIdOf(api));
115
+ if (sender.error) return { error: sender.error };
116
+ return {
117
+ cliMsgId,
118
+ senderUid: sender.uid,
119
+ threadId,
120
+ isGroup: kind === "group" && !isMyDocuments,
121
+ ts: Number(row.timestamp) || Number(cliMsgId),
122
+ ...(isMyDocuments && { logSrcType: LOG_SRC_TYPE.MY_CLOUD }),
123
+ };
124
+ }
125
+
29
126
  /**
30
127
  * Resolve a thread's type the way the CLI does.
31
128
  *
@@ -362,7 +459,8 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
362
459
  {
363
460
  title: "Send Zalo Message",
364
461
  description:
365
- "Send a text message to a Zalo thread (DM or group). threadType: 0=DM(User), 1=Group. " +
462
+ "Send a text message, link preview card (`link`), or contact card (`cardUserId`) to a Zalo thread " +
463
+ "(DM or group). threadType: 0=DM(User), 1=Group. " +
366
464
  "To @-mention someone in a group, put `@[uid]` in the text where the tag belongs — " +
367
465
  "`@[123456]` becomes `@Their Name` and notifies them, and `@[-1]` is @All. " +
368
466
  "Use the `senderId` from zalo_get_messages or zalo_get_history as the uid; the display name " +
@@ -372,41 +470,72 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
372
470
  "to text messages only, and the quoted message must already be in the local cache. " +
373
471
  '`urgency` marks the message Important or Urgent, as the apps do. `threadId: "me"` sends to ' +
374
472
  "My Documents (your own cloud chat); the reply carries its real thread id.",
375
- inputSchema: z.object({
376
- threadId: z
377
- .string()
378
- .describe("Thread ID to send message to. `me` (or your own uid) is My Documents, a 1-1 thread"),
379
- text: z
380
- .string()
381
- .min(1)
382
- .describe("Message text to send. `@[uid]` tokens expand to @-mentions; `@[-1]` is @All."),
383
- threadType: z
384
- .number()
385
- .int()
386
- .min(0)
387
- .max(1)
388
- .optional()
389
- .describe(
390
- "Thread type: 0=DM(User), 1=Group. Omit it and the cached thread type is " +
391
- "used, which is what the CLI does — pass a value only to override that.",
392
- ),
393
- quoteMsgId: z
394
- .string()
395
- .optional()
396
- .describe(
397
- "msgId of a cached TEXT message in this thread to quote-reply to. " +
398
- "Get it from zalo_get_messages or zalo_get_history.",
399
- ),
400
- urgency: z
401
- .enum(["normal", "important", "urgent"])
402
- .optional()
403
- .describe(
404
- "Mark the message the way the apps' Important/Urgent option does. Omit it, or 'normal', " +
405
- "for an ordinary message",
406
- ),
407
- }),
473
+ inputSchema: z
474
+ .object({
475
+ threadId: z
476
+ .string()
477
+ .describe("Thread ID to send message to. `me` (or your own uid) is My Documents, a 1-1 thread"),
478
+ text: z
479
+ .string()
480
+ .min(1)
481
+ .optional()
482
+ .describe(
483
+ "Message text to send (required unless `link` or `cardUserId` is set; optional caption when `link` is set). " +
484
+ "`@[uid]` tokens expand to @-mentions; `@[-1]` is @All.",
485
+ ),
486
+ link: z
487
+ .string()
488
+ .url()
489
+ .optional()
490
+ .describe("Optional URL to send as a rich link preview card (uses `text` as optional caption)"),
491
+ cardUserId: z
492
+ .string()
493
+ .min(1)
494
+ .optional()
495
+ .describe("Optional Zalo user ID to share as a contact card in this thread"),
496
+ threadType: z
497
+ .number()
498
+ .int()
499
+ .min(0)
500
+ .max(1)
501
+ .optional()
502
+ .describe(
503
+ "Thread type: 0=DM(User), 1=Group. Omit it and the cached thread type is " +
504
+ "used, which is what the CLI does — pass a value only to override that.",
505
+ ),
506
+ quoteMsgId: z
507
+ .string()
508
+ .optional()
509
+ .describe(
510
+ "msgId of a cached TEXT message in this thread to quote-reply to. " +
511
+ "Get it from zalo_get_messages or zalo_get_history.",
512
+ ),
513
+ urgency: z
514
+ .enum(["normal", "important", "urgent"])
515
+ .optional()
516
+ .describe(
517
+ "Mark the message the way the apps' Important/Urgent option does. Omit it, or 'normal', " +
518
+ "for an ordinary message",
519
+ ),
520
+ })
521
+ .refine(
522
+ (val) => Boolean(val.text || val.link || val.cardUserId),
523
+ "Provide at least one of text, link, or cardUserId",
524
+ )
525
+ .refine(
526
+ (val) => !(val.link && val.cardUserId),
527
+ "Cannot send both link and cardUserId in a single call",
528
+ ),
408
529
  },
409
- async ({ threadId: threadIdIn, text, threadType: threadTypeIn, quoteMsgId, urgency: urgencyIn }) => {
530
+ async ({
531
+ threadId: threadIdIn,
532
+ text,
533
+ link,
534
+ cardUserId,
535
+ threadType: threadTypeIn,
536
+ quoteMsgId,
537
+ urgency: urgencyIn,
538
+ }) => {
410
539
  // `me` (or the own uid) is My Documents, always a 1-1 -- resolved
411
540
  // before anything else reads the thread id, as `msg send me` is.
412
541
  const thread = actionThread(api, threadIdIn, threadTypeIn);
@@ -416,6 +545,41 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
416
545
  }
417
546
  const { threadId, threadType } = thread;
418
547
  try {
548
+ if (cardUserId) {
549
+ const result = await api.sendCard({ userId: cardUserId }, threadId, Number(threadType));
550
+ const messageId = result?.msgId ?? result?.message?.msgId ?? null;
551
+ const cliMsgId = result?.cliMsgId ?? result?.message?.cliMsgId ?? null;
552
+ return ok({
553
+ success: true,
554
+ kind: "card",
555
+ cardUserId,
556
+ messageId,
557
+ ...(cliMsgId !== null && { cliMsgId: String(cliMsgId) }),
558
+ threadType,
559
+ ...(thread.notice && { threadId, notice: thread.notice }),
560
+ });
561
+ }
562
+
563
+ if (link) {
564
+ const linkOpts = text ? { link, msg: text } : { link };
565
+ const result = await api.sendLink(linkOpts, threadId, Number(threadType));
566
+ const messageId = result?.msgId ?? result?.message?.msgId ?? null;
567
+ const cliMsgId = result?.cliMsgId ?? result?.message?.cliMsgId ?? null;
568
+ return ok({
569
+ success: true,
570
+ kind: "link",
571
+ link,
572
+ messageId,
573
+ ...(cliMsgId !== null && { cliMsgId: String(cliMsgId) }),
574
+ threadType,
575
+ ...(thread.notice && { threadId, notice: thread.notice }),
576
+ });
577
+ }
578
+
579
+ if (!text) {
580
+ return err("Provide text, link, or cardUserId.");
581
+ }
582
+
419
583
  const warnings = [];
420
584
 
421
585
  // `@[uid]` -> `@Display Name`, with the mention offsets measured
@@ -1220,4 +1384,878 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1220
1384
  }
1221
1385
  },
1222
1386
  );
1387
+
1388
+ // --- zalo_search_messages ---
1389
+ server.registerTool(
1390
+ "zalo_search_messages",
1391
+ {
1392
+ title: "Search Zalo Messages",
1393
+ description:
1394
+ "Search message text across the local SQLite cache (`zalo.db`). Zero network calls. " +
1395
+ "Filter optionally by `threadId`, `senderId`, `since` (epoch ms), and `until` (epoch ms). " +
1396
+ "Results are ordered newest-first.",
1397
+ inputSchema: z.object({
1398
+ query: z.string().min(1).describe("Case-insensitive text substring to search for in cached messages"),
1399
+ threadId: z.string().min(1).optional().describe("Restrict search to a single thread ID"),
1400
+ senderId: z.string().min(1).optional().describe("Restrict search to a specific sender UID"),
1401
+ since: z
1402
+ .number()
1403
+ .int()
1404
+ .positive()
1405
+ .optional()
1406
+ .describe("Only messages with timestamp >= this epoch-ms value"),
1407
+ until: z
1408
+ .number()
1409
+ .int()
1410
+ .positive()
1411
+ .optional()
1412
+ .describe("Only messages with timestamp <= this epoch-ms value"),
1413
+ limit: z
1414
+ .number()
1415
+ .int()
1416
+ .min(1)
1417
+ .max(200)
1418
+ .default(50)
1419
+ .describe("Max matching messages to return (default 50, max 200)"),
1420
+ }),
1421
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1422
+ },
1423
+ async ({ query, threadId, senderId, since, until, limit }) => {
1424
+ try {
1425
+ const rows = searchMessages({ query, threadId, senderId, since, until, limit });
1426
+ const threadNames = getThreadNames();
1427
+ const messages = rows.map((r) => {
1428
+ let cliMsgId = null;
1429
+ try {
1430
+ cliMsgId = cachedCliMsgId(r);
1431
+ } catch {
1432
+ /* ignore */
1433
+ }
1434
+ return {
1435
+ msgId: r.msgId,
1436
+ ...(cliMsgId && { cliMsgId: String(cliMsgId) }),
1437
+ threadId: r.threadId,
1438
+ threadName:
1439
+ threadNames.get(String(r.threadId))?.name ?? nameCache?.get(r.threadId)?.name ?? null,
1440
+ senderId: r.senderId,
1441
+ senderName: r.senderDisplayName || r.senderName || null,
1442
+ text: r.text,
1443
+ timestamp: r.timestamp,
1444
+ type: r.type,
1445
+ hasAttachment: Boolean(r.has_attachment),
1446
+ localPath: r.localPath || null,
1447
+ };
1448
+ });
1449
+ return ok({ query, count: messages.length, messages });
1450
+ } catch (e) {
1451
+ console.error("[mcp-tools] zalo_search_messages error:", e.message);
1452
+ return err(e.message);
1453
+ }
1454
+ },
1455
+ );
1456
+
1457
+ // --- zalo_lookup_user ---
1458
+ server.registerTool(
1459
+ "zalo_lookup_user",
1460
+ {
1461
+ title: "Lookup Zalo User",
1462
+ description:
1463
+ "Look up a Zalo user by phone number (`phone`), username (`username`), user ID (`userId`), or inspect " +
1464
+ "this logged-in account's own profile (`self: true`). Useful for finding a contact's `userId` (which is " +
1465
+ "also their 1-1 DM `threadId`) before sending a message or contact card.",
1466
+ inputSchema: z
1467
+ .object({
1468
+ self: z
1469
+ .boolean()
1470
+ .optional()
1471
+ .describe("Set true to fetch the currently logged-in account's own profile (`whoami`)"),
1472
+ phone: z
1473
+ .string()
1474
+ .min(3)
1475
+ .optional()
1476
+ .describe("Phone number to search for on Zalo (e.g. '0901234567' or '+84901234567')"),
1477
+ username: z.string().min(1).optional().describe("Zalo username to search for"),
1478
+ userId: z.string().min(1).optional().describe("Zalo user ID to fetch full profile info for"),
1479
+ })
1480
+ .refine(
1481
+ (v) =>
1482
+ [v.self === true, Boolean(v.phone), Boolean(v.username), Boolean(v.userId)].filter(Boolean)
1483
+ .length === 1,
1484
+ "Specify exactly one of self: true, phone, username, or userId",
1485
+ ),
1486
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1487
+ },
1488
+ async ({ self, phone, username, userId }) => {
1489
+ try {
1490
+ if (self) {
1491
+ const info = await api.fetchAccountInfo();
1492
+ const p = info?.profile || info || {};
1493
+ return ok({
1494
+ mode: "self",
1495
+ ownId: ownIdOf(api) || p.userId || null,
1496
+ displayName: p.displayName || p.zaloName || null,
1497
+ zaloName: p.zaloName || null,
1498
+ phoneNumber: p.phoneNumber || null,
1499
+ avatar: p.avatar || null,
1500
+ profile: p,
1501
+ });
1502
+ }
1503
+ if (phone) {
1504
+ const phoneNumber = phone;
1505
+ const user = await api.findUser(phoneNumber);
1506
+ return ok({
1507
+ mode: "phone",
1508
+ query: phoneNumber,
1509
+ userId: user?.uid || user?.userId || null,
1510
+ displayName: user?.display_name || user?.zalo_name || null,
1511
+ zaloName: user?.zalo_name || null,
1512
+ avatar: user?.avatar || null,
1513
+ user: user || null,
1514
+ });
1515
+ }
1516
+ if (username) {
1517
+ const user = await api.findUserByUsername(username);
1518
+ return ok({
1519
+ mode: "username",
1520
+ query: username,
1521
+ userId: user?.uid || user?.userId || null,
1522
+ displayName: user?.display_name || user?.zalo_name || null,
1523
+ zaloName: user?.zalo_name || null,
1524
+ avatar: user?.avatar || null,
1525
+ user: user || null,
1526
+ });
1527
+ }
1528
+ const info = await api.getUserInfo(userId);
1529
+ const changed = info?.changed_profiles?.[userId] || info?.unchanged_profiles?.[userId] || null;
1530
+ return ok({
1531
+ mode: "userId",
1532
+ userId,
1533
+ displayName: changed?.displayName || changed?.zaloName || cachedDisplayName(userId) || null,
1534
+ zaloName: changed?.zaloName || null,
1535
+ avatar: changed?.avatar || null,
1536
+ profile: changed || info || null,
1537
+ });
1538
+ } catch (e) {
1539
+ console.error("[mcp-tools] zalo_lookup_user error:", e.message);
1540
+ return err(e.message);
1541
+ }
1542
+ },
1543
+ );
1544
+
1545
+ // --- zalo_forward_message ---
1546
+ server.registerTool(
1547
+ "zalo_forward_message",
1548
+ {
1549
+ title: "Forward Zalo Message",
1550
+ description:
1551
+ "Forward a cached message (`msgId`) to another Zalo conversation (`threadId`). Text messages are sent " +
1552
+ "through Zalo's forward API with the 'forwarded' badge; cards, stickers, links, videos, voices, and " +
1553
+ "locally downloaded media attachments are re-sent as a message of their own kind, matching `msg forward`.",
1554
+ inputSchema: z.object({
1555
+ msgId: z
1556
+ .string()
1557
+ .min(1)
1558
+ .describe("msgId of the cached message to forward (from zalo_get_messages or zalo_get_history)"),
1559
+ threadId: z
1560
+ .string()
1561
+ .min(1)
1562
+ .describe("Destination thread ID (`me` or your own uid resolves to My Documents)"),
1563
+ threadType: z
1564
+ .number()
1565
+ .int()
1566
+ .min(0)
1567
+ .max(1)
1568
+ .optional()
1569
+ .describe("Destination thread type: 0=DM(User), 1=Group. Inferred from cache when omitted."),
1570
+ }),
1571
+ },
1572
+ async ({ msgId, threadId: threadIdIn, threadType: threadTypeIn }) => {
1573
+ const thread = actionThread(api, threadIdIn, threadTypeIn);
1574
+ if (thread.error) {
1575
+ console.error("[mcp-tools] zalo_forward_message refused:", thread.error);
1576
+ return err(thread.error);
1577
+ }
1578
+ const { threadId, threadType } = thread;
1579
+ try {
1580
+ let row = null;
1581
+ try {
1582
+ row = getMessageById(msgId);
1583
+ } catch (e) {
1584
+ return err(`Local cache unavailable (${e.message}).`);
1585
+ }
1586
+ if (!row) {
1587
+ return err(
1588
+ `Message ${msgId} is not in the local cache. Call zalo_get_history on its conversation first.`,
1589
+ );
1590
+ }
1591
+
1592
+ let raw = {};
1593
+ try {
1594
+ raw = JSON.parse(row.raw_data || "{}");
1595
+ } catch {
1596
+ /* fall back to text forward */
1597
+ }
1598
+ const c = raw.content && typeof raw.content === "object" ? raw.content : null;
1599
+
1600
+ const resendLocal = async () => {
1601
+ if (!row.localPath || !existsSync(row.localPath)) {
1602
+ throw new Error(
1603
+ `Message ${msgId} is "${row.type}" but its media is not downloaded locally. Call zalo_view_media with messageId="${msgId}" first.`,
1604
+ );
1605
+ }
1606
+ return api.sendMessage(
1607
+ { msg: "", attachments: [resolve(row.localPath)] },
1608
+ threadId,
1609
+ Number(threadType),
1610
+ );
1611
+ };
1612
+
1613
+ const forwarders = {
1614
+ card: async () =>
1615
+ api.sendCard({ userId: String(c?.params ?? "").trim() }, threadId, Number(threadType)),
1616
+ sticker: async () =>
1617
+ api.sendSticker(
1618
+ { id: c?.id, cateId: c?.catId, type: c?.type ?? 3 },
1619
+ threadId,
1620
+ Number(threadType),
1621
+ ),
1622
+ link: async () => api.sendLink({ link: c?.href }, threadId, Number(threadType)),
1623
+ video: async () => {
1624
+ let p = {};
1625
+ try {
1626
+ p = typeof c?.params === "string" ? JSON.parse(c.params) : (c?.params ?? {});
1627
+ } catch {
1628
+ /* default geometry */
1629
+ }
1630
+ return api.sendVideo(
1631
+ {
1632
+ videoUrl: c?.href,
1633
+ thumbnailUrl: c?.thumb || "",
1634
+ duration: Number(p.duration) || 0,
1635
+ width: Number(p.video_width || p.video_original_width) || 1280,
1636
+ height: Number(p.video_height || p.video_original_height) || 720,
1637
+ },
1638
+ threadId,
1639
+ Number(threadType),
1640
+ );
1641
+ },
1642
+ voice: async () => api.sendVoice({ voiceUrl: c?.href }, threadId, Number(threadType)),
1643
+ photo: resendLocal,
1644
+ file: resendLocal,
1645
+ gif: resendLocal,
1646
+ doodle: resendLocal,
1647
+ };
1648
+
1649
+ const kind = c?.action === "recommened.user" ? "card" : row.type;
1650
+ if (kind !== "text") {
1651
+ const send = forwarders[kind];
1652
+ if (!send) {
1653
+ return err(`Cannot forward a "${row.type}" message — no send path reproduces it.`);
1654
+ }
1655
+ const res = await send();
1656
+ return ok({
1657
+ success: true,
1658
+ kind,
1659
+ threadId,
1660
+ threadType,
1661
+ ...(thread.notice && { notice: thread.notice }),
1662
+ result: res,
1663
+ });
1664
+ }
1665
+
1666
+ const text = cachedText(row);
1667
+ if (!text) {
1668
+ return err(`Message ${msgId} has no text to forward.`);
1669
+ }
1670
+ const source = await forwardSourceForMcp(api, row);
1671
+ if (source.error) {
1672
+ return err(`Cannot forward message ${msgId}: ${source.error}.`);
1673
+ }
1674
+ const { reference, decorLog } = buildForwardReference(source);
1675
+ const result = await sendForward(
1676
+ api,
1677
+ { message: text, reference, decorLog },
1678
+ [threadId],
1679
+ Number(threadType),
1680
+ );
1681
+ const rejected = result?.failed ?? result?.fail ?? [];
1682
+ if (rejected.length > 0) {
1683
+ return err(`Forward rejected for ${rejected.length} target(s): ${JSON.stringify(rejected)}`);
1684
+ }
1685
+ const shared = (result?.cliMsgId ?? null) === null ? null : String(result.cliMsgId);
1686
+ const sent = (Array.isArray(result?.success) ? result.success : [])
1687
+ .filter((x) => (x?.msgId ?? null) !== null)
1688
+ .map((x) => ({
1689
+ msgId: String(x.msgId),
1690
+ cliMsgId: (x.clientId ?? shared ?? null) === null ? null : String(x.clientId ?? shared),
1691
+ }));
1692
+ return ok({
1693
+ success: true,
1694
+ kind: "text",
1695
+ threadId,
1696
+ threadType,
1697
+ ...(thread.notice && { notice: thread.notice }),
1698
+ sent,
1699
+ });
1700
+ } catch (e) {
1701
+ console.error("[mcp-tools] zalo_forward_message error:", e.message);
1702
+ return err(e.message);
1703
+ }
1704
+ },
1705
+ );
1706
+
1707
+ // --- zalo_delete_message ---
1708
+ server.registerTool(
1709
+ "zalo_delete_message",
1710
+ {
1711
+ title: "Delete Zalo Message",
1712
+ description:
1713
+ "Delete a message from your own view (`everyone: false`, the default) or for everyone in a group " +
1714
+ "(`everyone: true`, only valid for someone else's message in a group — to recall your own sent " +
1715
+ "message for both sides, use `zalo_undo`). Resolves `cliMsgId` and `uidFrom` from the local cache when omitted.",
1716
+ inputSchema: z.object({
1717
+ msgId: z.string().min(1).describe("Message ID (`msgId`) to delete"),
1718
+ threadId: z.string().min(1).describe("Thread ID the message belongs to"),
1719
+ threadType: z
1720
+ .number()
1721
+ .int()
1722
+ .min(0)
1723
+ .max(1)
1724
+ .optional()
1725
+ .describe("Thread type: 0=DM(User), 1=Group. Inferred from cache when omitted."),
1726
+ cliMsgId: z
1727
+ .string()
1728
+ .min(1)
1729
+ .optional()
1730
+ .describe("Client message ID (`cliMsgId`). Looked up in the local cache when omitted."),
1731
+ uidFrom: z
1732
+ .string()
1733
+ .min(1)
1734
+ .optional()
1735
+ .describe(
1736
+ "Sender UID (`uidFrom`). Looked up in the local cache or defaults to own UID when omitted.",
1737
+ ),
1738
+ everyone: z
1739
+ .boolean()
1740
+ .default(false)
1741
+ .describe("Set true to delete someone else's message for everyone in a group (admin/owner)"),
1742
+ }),
1743
+ },
1744
+ async ({
1745
+ msgId,
1746
+ threadId: threadIdIn,
1747
+ threadType: threadTypeIn,
1748
+ cliMsgId: cliMsgIdIn,
1749
+ uidFrom: uidFromIn,
1750
+ everyone,
1751
+ }) => {
1752
+ const thread = actionThread(api, threadIdIn, threadTypeIn);
1753
+ if (thread.error) {
1754
+ console.error("[mcp-tools] zalo_delete_message refused:", thread.error);
1755
+ return err(thread.error);
1756
+ }
1757
+ const { threadId, threadType } = thread;
1758
+ try {
1759
+ let cliMsgId = cliMsgIdIn;
1760
+ let uidFrom = uidFromIn;
1761
+ if (!cliMsgId || !uidFrom) {
1762
+ const cached = cachedMessageById(threadId, msgId);
1763
+ cliMsgId = cliMsgId || cached?.cliMsgId;
1764
+ uidFrom = uidFrom || cached?.uidFrom;
1765
+ }
1766
+ uidFrom = uidFrom || ownIdOf(api);
1767
+ if (!cliMsgId) {
1768
+ return err(
1769
+ `Message ${msgId} is not in the local cache and cliMsgId was not provided. Pass ${CLI_MSG_ID_HINT}.`,
1770
+ );
1771
+ }
1772
+ const dest = {
1773
+ data: { cliMsgId: String(cliMsgId), msgId: String(msgId), uidFrom: String(uidFrom) },
1774
+ threadId,
1775
+ type: Number(threadType),
1776
+ };
1777
+ const onlyMe = Boolean(everyone) === false;
1778
+ const result = await api.deleteMessage(dest, onlyMe);
1779
+ return ok({
1780
+ success: true,
1781
+ msgId,
1782
+ cliMsgId: String(cliMsgId),
1783
+ threadId,
1784
+ threadType,
1785
+ everyone: Boolean(everyone),
1786
+ ...(thread.notice && { notice: thread.notice }),
1787
+ result,
1788
+ });
1789
+ } catch (e) {
1790
+ console.error("[mcp-tools] zalo_delete_message error:", e.message);
1791
+ return err(e.message);
1792
+ }
1793
+ },
1794
+ );
1795
+
1796
+ // --- zalo_reminder ---
1797
+ server.registerTool(
1798
+ "zalo_reminder",
1799
+ {
1800
+ title: "Manage Zalo Reminders",
1801
+ description:
1802
+ "Create, list, inspect, view responses, edit, or remove reminders in a Zalo DM or group thread. " +
1803
+ "Actions: 'create' (requires threadId, title), 'list' (requires threadId), 'info' (requires reminderId, group only), " +
1804
+ "'responses' (requires reminderId, group only), 'edit' (requires reminderId, threadId, title), " +
1805
+ "'remove' (requires reminderId, threadId).",
1806
+ inputSchema: z.object({
1807
+ action: z
1808
+ .enum(["create", "list", "info", "responses", "edit", "remove"])
1809
+ .describe("Reminder operation to perform"),
1810
+ threadId: z.string().min(1).optional().describe("Thread ID (required for create, list, edit, remove)"),
1811
+ threadType: z
1812
+ .number()
1813
+ .int()
1814
+ .min(0)
1815
+ .max(1)
1816
+ .optional()
1817
+ .describe("Thread type: 0=DM(User), 1=Group. Inferred from cache when omitted."),
1818
+ reminderId: z
1819
+ .string()
1820
+ .min(1)
1821
+ .optional()
1822
+ .describe("Reminder ID (required for info, responses, edit, remove)"),
1823
+ title: z.string().min(1).optional().describe("Reminder title (required for create and edit)"),
1824
+ time: z
1825
+ .union([z.number().int().positive(), z.string().min(1)])
1826
+ .optional()
1827
+ .describe(
1828
+ 'Reminder start time as epoch-ms, "YYYY-MM-DD HH:mm", or ISO-8601 string (defaults to now on create)',
1829
+ ),
1830
+ emoji: z.string().min(1).optional().describe("Emoji icon for the reminder (default '⏰' on create)"),
1831
+ repeat: z
1832
+ .enum(["none", "daily", "weekly", "monthly"])
1833
+ .optional()
1834
+ .describe("Repeat schedule: 'none', 'daily', 'weekly', or 'monthly'"),
1835
+ page: z.number().int().min(1).default(1).describe("Page number for 'list' (default 1)"),
1836
+ count: z
1837
+ .number()
1838
+ .int()
1839
+ .min(1)
1840
+ .max(100)
1841
+ .default(20)
1842
+ .describe("Max items per page for 'list' (default 20)"),
1843
+ }),
1844
+ },
1845
+ async ({
1846
+ action,
1847
+ threadId: threadIdIn,
1848
+ threadType: threadTypeIn,
1849
+ reminderId,
1850
+ title,
1851
+ time,
1852
+ emoji,
1853
+ repeat,
1854
+ page,
1855
+ count,
1856
+ }) => {
1857
+ try {
1858
+ if (action === "info") {
1859
+ if (!reminderId) return err("reminderId is required for action='info'.");
1860
+ const result = await api.getReminder(reminderId);
1861
+ return ok({ action, reminderId, reminder: result });
1862
+ }
1863
+ if (action === "responses") {
1864
+ if (!reminderId) return err("reminderId is required for action='responses'.");
1865
+ const result = await api.getReminderResponses(reminderId);
1866
+ return ok({ action, reminderId, responses: result });
1867
+ }
1868
+ if (!threadIdIn) {
1869
+ return err(`threadId is required for action='${action}'.`);
1870
+ }
1871
+ const thread = actionThread(api, threadIdIn, threadTypeIn);
1872
+ if (thread.error) return err(thread.error);
1873
+ const { threadId, threadType } = thread;
1874
+
1875
+ if (action === "create") {
1876
+ if (!title) return err("title is required for action='create'.");
1877
+ const repeatMode = REMINDER_REPEAT_MODES[repeat ?? "none"];
1878
+ const startTime = time !== undefined ? parseReminderTime(time) : Date.now();
1879
+ if (!startTime) {
1880
+ return err(`Invalid time format: "${time}". Use epoch-ms, "YYYY-MM-DD HH:mm", or ISO-8601.`);
1881
+ }
1882
+ const result = await api.createReminder(
1883
+ { title, emoji: emoji || "⏰", startTime, repeat: repeatMode },
1884
+ threadId,
1885
+ Number(threadType),
1886
+ );
1887
+ return ok({
1888
+ action,
1889
+ success: true,
1890
+ threadId,
1891
+ threadType,
1892
+ reminderId: result?.reminderId ?? result?.id ?? null,
1893
+ startTime,
1894
+ repeat: REMINDER_REPEAT_LABELS[repeatMode],
1895
+ result,
1896
+ });
1897
+ }
1898
+
1899
+ if (action === "list") {
1900
+ let result = null;
1901
+ try {
1902
+ result = await api.getListReminder({ page, count }, threadId, Number(threadType));
1903
+ } catch {
1904
+ result = null;
1905
+ }
1906
+ const reminders = Array.isArray(result) ? result : [];
1907
+ return ok({ action, threadId, threadType, count: reminders.length, reminders });
1908
+ }
1909
+
1910
+ if (action === "edit") {
1911
+ if (!reminderId) return err("reminderId is required for action='edit'.");
1912
+ if (!title) return err("title is required for action='edit'.");
1913
+ const editOpts = { title, topicId: reminderId };
1914
+ if (emoji) editOpts.emoji = emoji;
1915
+ if (time !== undefined) {
1916
+ const ts = parseReminderTime(time);
1917
+ if (!ts) {
1918
+ return err(
1919
+ `Invalid time format: "${time}". Use epoch-ms, "YYYY-MM-DD HH:mm", or ISO-8601.`,
1920
+ );
1921
+ }
1922
+ editOpts.startTime = ts;
1923
+ }
1924
+ if (repeat !== undefined) {
1925
+ editOpts.repeat = REMINDER_REPEAT_MODES[repeat];
1926
+ }
1927
+ const result = await api.editReminder(editOpts, threadId, Number(threadType));
1928
+ return ok({ action, success: true, reminderId, threadId, threadType, result });
1929
+ }
1930
+
1931
+ if (action === "remove") {
1932
+ if (!reminderId) return err("reminderId is required for action='remove'.");
1933
+ const result = await api.removeReminder(reminderId, threadId, Number(threadType));
1934
+ return ok({ action, success: true, reminderId, threadId, threadType, result });
1935
+ }
1936
+
1937
+ return err(`Unsupported reminder action: ${action}`);
1938
+ } catch (e) {
1939
+ console.error("[mcp-tools] zalo_reminder error:", e.message);
1940
+ return err(e.message);
1941
+ }
1942
+ },
1943
+ );
1944
+
1945
+ // --- zalo_note ---
1946
+ server.registerTool(
1947
+ "zalo_note",
1948
+ {
1949
+ title: "Manage Zalo Group Notes & Pins",
1950
+ description:
1951
+ "Create or edit group notes, list group board items (notes, pinned messages, polls), or pin/unpin " +
1952
+ "a cached text message at the top of a conversation. " +
1953
+ "Actions: 'create' (requires groupId, title), 'edit' (requires groupId, noteId, title), " +
1954
+ "'list' (requires groupId), 'pin_message' (requires msgId; threadId optional if cached), " +
1955
+ "'unpin_message' (requires msgId; threadId optional if cached).",
1956
+ inputSchema: z.object({
1957
+ action: z
1958
+ .enum(["create", "edit", "list", "pin_message", "unpin_message"])
1959
+ .describe("Note or pin operation to perform"),
1960
+ groupId: z
1961
+ .string()
1962
+ .min(1)
1963
+ .optional()
1964
+ .describe("Group ID (required for 'create', 'edit', 'list'; alias for threadId on pin/unpin)"),
1965
+ threadId: z
1966
+ .string()
1967
+ .min(1)
1968
+ .optional()
1969
+ .describe(
1970
+ "Thread ID for 'pin_message' / 'unpin_message' (defaults to the message's cached threadId)",
1971
+ ),
1972
+ threadType: z
1973
+ .number()
1974
+ .int()
1975
+ .min(0)
1976
+ .max(1)
1977
+ .optional()
1978
+ .describe(
1979
+ "Thread type for 'pin_message' / 'unpin_message': 0=DM(User), 1=Group. Inferred from cache when omitted.",
1980
+ ),
1981
+ noteId: z.string().min(1).optional().describe("Note topicId to update (required for 'edit')"),
1982
+ title: z.string().min(1).optional().describe("Note text/title (required for 'create' and 'edit')"),
1983
+ pin: z
1984
+ .boolean()
1985
+ .default(false)
1986
+ .describe("Whether to pin the note to the group board on 'create' or 'edit'"),
1987
+ msgId: z
1988
+ .string()
1989
+ .min(1)
1990
+ .optional()
1991
+ .describe("Message ID (`msgId`) to pin or unpin (required for 'pin_message' and 'unpin_message')"),
1992
+ page: z.number().int().min(1).default(1).describe("Page number for 'list' (default 1)"),
1993
+ count: z.number().int().min(1).max(100).default(20).describe("Items per page for 'list' (default 20)"),
1994
+ }),
1995
+ },
1996
+ async ({
1997
+ action,
1998
+ groupId,
1999
+ threadId: threadIdIn,
2000
+ threadType: threadTypeIn,
2001
+ noteId,
2002
+ title,
2003
+ pin,
2004
+ msgId,
2005
+ page,
2006
+ count,
2007
+ }) => {
2008
+ try {
2009
+ if (action === "create") {
2010
+ if (!groupId) return err("groupId is required for action='create'.");
2011
+ if (!title) return err("title is required for action='create'.");
2012
+ const pinAct = Boolean(pin);
2013
+ const result = await api.createNote({ title, pinAct }, groupId);
2014
+ return ok({ action, success: true, groupId, pinned: pinAct, result });
2015
+ }
2016
+
2017
+ if (action === "edit") {
2018
+ if (!groupId) return err("groupId is required for action='edit'.");
2019
+ if (!noteId) return err("noteId is required for action='edit'.");
2020
+ if (!title) return err("title is required for action='edit'.");
2021
+ const pinAct = Boolean(pin);
2022
+ const topicId = noteId;
2023
+ const result = await api.editNote({ title, topicId, pinAct }, groupId);
2024
+ return ok({ action, success: true, groupId, noteId, pinned: pinAct, result });
2025
+ }
2026
+
2027
+ if (action === "list") {
2028
+ if (!groupId) return err("groupId is required for action='list'.");
2029
+ const result = await api.getListBoard({ page, count }, groupId);
2030
+ return ok({
2031
+ action,
2032
+ groupId,
2033
+ items: result?.items ?? [],
2034
+ count: Array.isArray(result?.items) ? result.items.length : 0,
2035
+ });
2036
+ }
2037
+
2038
+ if (action === "pin_message") {
2039
+ if (!msgId) return err("msgId is required for action='pin_message'.");
2040
+ let row = null;
2041
+ try {
2042
+ row = getMessageById(msgId);
2043
+ } catch {
2044
+ row = null;
2045
+ }
2046
+ if (!row) {
2047
+ return err(
2048
+ `Message ${msgId} is not in the local cache, so it cannot be pinned (pinning requires cached cliMsgId, sender, and text).`,
2049
+ );
2050
+ }
2051
+ const targetThread = threadIdIn || groupId;
2052
+ if (targetThread && row.threadId && String(row.threadId) !== String(targetThread)) {
2053
+ return err(`Message ${msgId} belongs to thread ${row.threadId}, not ${targetThread}.`);
2054
+ }
2055
+ if (row.type !== "text") {
2056
+ return err(`Only text messages can be pinned; message ${msgId} is "${row.type || "unknown"}".`);
2057
+ }
2058
+ const cliMsgId = cachedCliMsgId(row);
2059
+ if (!cliMsgId) {
2060
+ return err(`Message ${msgId}'s cliMsgId was never cached, and a pin must name it.`);
2061
+ }
2062
+ const conversation = String(targetThread || row.threadId);
2063
+ const resolvedType = resolveThreadType(conversation, threadTypeIn);
2064
+ const isGroup = Number(resolvedType) === THREAD_GROUP;
2065
+ const sender = await resolveSenderUid(api, row.senderId, ownIdOf(api));
2066
+ if (sender.error) {
2067
+ return err(`Cannot pin message ${msgId}: ${sender.error}.`);
2068
+ }
2069
+ const params = textPinParams({
2070
+ cliMsgId,
2071
+ msgId,
2072
+ senderUid: sender.uid,
2073
+ senderName: row.senderName,
2074
+ text: cachedText(row),
2075
+ });
2076
+ const result = await pinMessage(api, { threadId: conversation, isGroup, params });
2077
+ return ok({ action, success: true, msgId, threadId: conversation, isGroup, result });
2078
+ }
2079
+
2080
+ if (action === "unpin_message") {
2081
+ if (!msgId) return err("msgId is required for action='unpin_message'.");
2082
+ let row = null;
2083
+ try {
2084
+ row = getMessageById(msgId);
2085
+ } catch {
2086
+ row = null;
2087
+ }
2088
+ const targetThread = threadIdIn || groupId;
2089
+ if (targetThread && row?.threadId && String(row.threadId) !== String(targetThread)) {
2090
+ return err(`Message ${msgId} belongs to thread ${row.threadId}, not ${targetThread}.`);
2091
+ }
2092
+ const conversation = targetThread || row?.threadId;
2093
+ if (!conversation) {
2094
+ return err(
2095
+ `Message ${msgId} is not in the local cache, so its thread is unknown. Pass threadId (or groupId).`,
2096
+ );
2097
+ }
2098
+ const resolvedType = resolveThreadType(String(conversation), threadTypeIn);
2099
+ const isGroup = Number(resolvedType) === THREAD_GROUP;
2100
+ const result = await unpinMessage(api, { threadId: String(conversation), isGroup, msgId });
2101
+ if (result.notPinned) {
2102
+ return err(`Message ${msgId} is not pinned in ${conversation}.`);
2103
+ }
2104
+ return ok({ action, success: true, msgId, threadId: String(conversation), isGroup, result });
2105
+ }
2106
+
2107
+ return err(`Unsupported note action: ${action}`);
2108
+ } catch (e) {
2109
+ console.error("[mcp-tools] zalo_note error:", describeZaloError(e));
2110
+ return err(describeZaloError(e));
2111
+ }
2112
+ },
2113
+ );
2114
+
2115
+ // --- zalo_poll ---
2116
+ server.registerTool(
2117
+ "zalo_poll",
2118
+ {
2119
+ title: "Manage Zalo Group Polls",
2120
+ description:
2121
+ "Create, inspect, vote, unvote, add options, lock, or share a poll in a Zalo group. " +
2122
+ "Actions: 'create' (requires groupId, question, options with >=2 items), 'info' (requires pollId), " +
2123
+ "'vote' (requires pollId, optionIds), 'unvote' (requires pollId), " +
2124
+ "'add_options' (requires pollId, options), 'lock' (requires pollId), 'share' (requires pollId).",
2125
+ inputSchema: z.object({
2126
+ action: z
2127
+ .enum(["create", "info", "vote", "unvote", "add_options", "lock", "share"])
2128
+ .describe("Poll operation to perform"),
2129
+ groupId: z.string().min(1).optional().describe("Group ID (required for 'create')"),
2130
+ pollId: z
2131
+ .union([z.number().int().positive(), z.string().regex(/^\d+$/)])
2132
+ .optional()
2133
+ .describe(
2134
+ "Numeric poll ID (required for 'info', 'vote', 'unvote', 'add_options', 'lock', 'share')",
2135
+ ),
2136
+ question: z.string().min(1).optional().describe("Poll question text (required for 'create')"),
2137
+ options: z
2138
+ .array(z.string().min(1))
2139
+ .optional()
2140
+ .describe("Poll option strings (at least 2 required for 'create'; at least 1 for 'add_options')"),
2141
+ optionIds: z
2142
+ .array(z.number().int())
2143
+ .optional()
2144
+ .describe("Option IDs to vote for on 'vote' (get option_id values from action='info')"),
2145
+ multi: z.boolean().default(false).describe("Allow voters to pick multiple options ('create' only)"),
2146
+ allowAddOptions: z
2147
+ .boolean()
2148
+ .default(false)
2149
+ .describe("Allow group members to add new options ('create' only)"),
2150
+ anonymous: z.boolean().default(false).describe("Hide voter identities ('create' only)"),
2151
+ hidePreview: z.boolean().default(false).describe("Hide vote counts until voted ('create' only)"),
2152
+ expireMinutes: z
2153
+ .number()
2154
+ .int()
2155
+ .positive()
2156
+ .optional()
2157
+ .describe("Auto-close the poll after N minutes ('create' only; omit for no expiration)"),
2158
+ voteNewOptions: z
2159
+ .boolean()
2160
+ .default(false)
2161
+ .describe("Also cast your vote for the newly added options ('add_options' only)"),
2162
+ }),
2163
+ },
2164
+ async ({
2165
+ action,
2166
+ groupId,
2167
+ pollId,
2168
+ question,
2169
+ options,
2170
+ optionIds,
2171
+ multi,
2172
+ allowAddOptions,
2173
+ anonymous,
2174
+ hidePreview,
2175
+ expireMinutes,
2176
+ voteNewOptions,
2177
+ }) => {
2178
+ try {
2179
+ if (action === "create") {
2180
+ if (!groupId) return err("groupId is required for action='create'.");
2181
+ if (!question) return err("question is required for action='create'.");
2182
+ if (!Array.isArray(options) || options.length < 2) {
2183
+ return err("A poll requires at least 2 options in `options`.");
2184
+ }
2185
+ const expiredTime = expireMinutes ? Date.now() + expireMinutes * 60 * 1000 : 0;
2186
+ const pollOpts = {
2187
+ question,
2188
+ options,
2189
+ allowMultiChoices: Boolean(multi),
2190
+ allowAddNewOption: Boolean(allowAddOptions),
2191
+ isAnonymous: Boolean(anonymous),
2192
+ hideVotePreview: Boolean(hidePreview),
2193
+ expiredTime,
2194
+ };
2195
+ const result = await api.createPoll(pollOpts, groupId);
2196
+ return ok({
2197
+ action,
2198
+ success: true,
2199
+ groupId,
2200
+ pollId: result?.poll_id ?? null,
2201
+ result,
2202
+ });
2203
+ }
2204
+
2205
+ if (pollId === undefined || pollId === null || String(pollId).trim() === "") {
2206
+ return err(`pollId is required for action='${action}'.`);
2207
+ }
2208
+ const numericPollId = Number(pollId);
2209
+
2210
+ if (action === "info") {
2211
+ const result = await api.getPollDetail(numericPollId);
2212
+ return ok({ action, pollId: numericPollId, poll: result });
2213
+ }
2214
+
2215
+ if (action === "vote") {
2216
+ if (!Array.isArray(optionIds) || optionIds.length === 0) {
2217
+ return err("optionIds (non-empty array of numeric option IDs) is required for action='vote'.");
2218
+ }
2219
+ const result = await api.votePoll(numericPollId, optionIds.map(Number));
2220
+ return ok({ action, success: true, pollId: numericPollId, optionIds, result });
2221
+ }
2222
+
2223
+ if (action === "unvote") {
2224
+ const result = await api.votePoll(numericPollId, []);
2225
+ return ok({ action, success: true, pollId: numericPollId, result });
2226
+ }
2227
+
2228
+ if (action === "add_options") {
2229
+ if (!Array.isArray(options) || options.length === 0) {
2230
+ return err("options (non-empty array of strings) is required for action='add_options'.");
2231
+ }
2232
+ const newOptions = options.map((content) => ({
2233
+ content,
2234
+ voted: Boolean(voteNewOptions),
2235
+ }));
2236
+ const result = await api.addPollOptions({
2237
+ pollId: numericPollId,
2238
+ options: newOptions,
2239
+ votedOptionIds: [],
2240
+ });
2241
+ return ok({ action, success: true, pollId: numericPollId, added: options.length, result });
2242
+ }
2243
+
2244
+ if (action === "lock") {
2245
+ const result = await api.lockPoll(numericPollId);
2246
+ return ok({ action, success: true, pollId: numericPollId, result });
2247
+ }
2248
+
2249
+ if (action === "share") {
2250
+ const result = await api.sharePoll(numericPollId);
2251
+ return ok({ action, success: true, pollId: numericPollId, result });
2252
+ }
2253
+
2254
+ return err(`Unsupported poll action: ${action}`);
2255
+ } catch (e) {
2256
+ console.error("[mcp-tools] zalo_poll error:", e.message);
2257
+ return err(e.message);
2258
+ }
2259
+ },
2260
+ );
1223
2261
  }