@ardennguyen/zalo-agent-cli 2.2.0 → 2.2.2

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.
@@ -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
  }