@ardennguyen/zalo-agent-cli 2.5.6 → 2.5.8
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/package.json +1 -1
- package/src/commands/mcp.js +3 -0
- package/src/commands/msg.js +37 -13
- package/src/commands/sync.js +12 -1
- package/src/core/sync-v2/index.js +16 -3
- package/src/core/zalo-client.js +7 -3
- package/src/mcp/instructions.js +32 -0
- package/src/mcp/mcp-http-transport.js +2 -2
- package/src/mcp/mcp-server.js +3 -6
- package/src/mcp/mcp-tools.js +229 -158
- package/src/mcp/new-server.js +14 -0
- package/src/utils/literal-newline.js +24 -0
- package/src/utils/split-message.js +166 -0
package/package.json
CHANGED
package/src/commands/mcp.js
CHANGED
|
@@ -99,6 +99,9 @@ export function normalizeMessage(msg, info = null) {
|
|
|
99
99
|
const cls = info || classifyLiveMessage(data);
|
|
100
100
|
const media = cls.attachments.find((a) => a.url || a.thumbUrl) || null;
|
|
101
101
|
return {
|
|
102
|
+
// `msgId` is the name every tool takes and every other reply uses; `id` is the same value, kept for clients
|
|
103
|
+
// that already read it (the webhook and /events/wait payloads carry both).
|
|
104
|
+
msgId: data.msgId,
|
|
102
105
|
id: data.msgId,
|
|
103
106
|
threadId: msg.threadId,
|
|
104
107
|
threadType: msg.type === 0 ? "dm" : "group",
|
package/src/commands/msg.js
CHANGED
|
@@ -35,6 +35,7 @@ import { applySelfThreadAlias } from "../utils/my-documents.js";
|
|
|
35
35
|
import { buildQuote, resolveQuoteSender } from "../utils/quote.js";
|
|
36
36
|
import { urgencyLevel } from "../utils/urgency.js";
|
|
37
37
|
import { markdownToZalo, resolveSendFormat } from "../utils/markdown.js";
|
|
38
|
+
import { splitMessage, sendParts } from "../utils/split-message.js";
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Open the active account's SQLite cache.
|
|
@@ -678,19 +679,38 @@ export function registerMsgCommands(program) {
|
|
|
678
679
|
// Build message content. `urgency` is 1 (important) or 2
|
|
679
680
|
// (urgent); zca-js turns it into the `metaData: {urgency}`
|
|
680
681
|
// object Zalo Web sends, on every text endpoint.
|
|
682
|
+
//
|
|
683
|
+
// Zalo refuses one oversized message (114 plain, 112 styled), so
|
|
684
|
+
// a long text goes out as Web sends it: split at 3000 bytes of
|
|
685
|
+
// text plus styles, in order, 300 ms apart. The quote and the
|
|
686
|
+
// urgency belong to the first part only.
|
|
681
687
|
const urgency = opts.urgency;
|
|
682
|
-
const
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
688
|
+
const parts = splitMessage({ text: finalMsg, styles, mentions });
|
|
689
|
+
const contentOf = (part, index) => {
|
|
690
|
+
const first = index === 0;
|
|
691
|
+
const extras = part.mentions.length > 0 || part.styles.length > 0 || (first && (quote || urgency));
|
|
692
|
+
return extras
|
|
693
|
+
? {
|
|
694
|
+
msg: part.text,
|
|
695
|
+
...(part.mentions.length > 0 && { mentions: part.mentions }),
|
|
696
|
+
...(part.styles.length > 0 && { styles: part.styles }),
|
|
697
|
+
...(first && quote && { quote }),
|
|
698
|
+
...(first && urgency && { urgency }),
|
|
699
|
+
}
|
|
700
|
+
: part.text;
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
const answers = await sendParts(parts, (part, index) =>
|
|
704
|
+
getApi().sendMessage(contentOf(part, index), threadId, Number(opts.type)),
|
|
705
|
+
);
|
|
706
|
+
const result = answers[0];
|
|
707
|
+
if (parts.length > 1) {
|
|
708
|
+
result.splitInto = parts.length;
|
|
709
|
+
result.parts = answers.map((r) => ({
|
|
710
|
+
msgId: r.message?.msgId ? String(r.message.msgId) : null,
|
|
711
|
+
cliMsgId: r.message?.cliMsgId ? String(r.message.cliMsgId) : null,
|
|
712
|
+
}));
|
|
713
|
+
}
|
|
694
714
|
|
|
695
715
|
// The cliMsgId comes back from zca-js, which hands over the
|
|
696
716
|
// clientId it actually put on the wire (patches/zca-js+2.2.0.patch
|
|
@@ -709,7 +729,11 @@ export function registerMsgCommands(program) {
|
|
|
709
729
|
// so — a fabricated one is worse than an absent one.
|
|
710
730
|
const cliMsgId = result.message?.cliMsgId ? String(result.message.cliMsgId) : null;
|
|
711
731
|
if (cliMsgId) result.cliMsgId = cliMsgId;
|
|
712
|
-
output(result, program.opts().json, () =>
|
|
732
|
+
output(result, program.opts().json, () =>
|
|
733
|
+
success(
|
|
734
|
+
parts.length > 1 ? `Message sent in ${parts.length} parts (too long for one)` : "Message sent",
|
|
735
|
+
),
|
|
736
|
+
);
|
|
713
737
|
|
|
714
738
|
if (result.message && !cliMsgId) {
|
|
715
739
|
warning(
|
package/src/commands/sync.js
CHANGED
|
@@ -1259,7 +1259,18 @@ function reportRestore(res, win, ctx = {}) {
|
|
|
1259
1259
|
return { status: "ok", reason: "phone confirmed: nothing missed in this window" };
|
|
1260
1260
|
}
|
|
1261
1261
|
if (res.reason === "partial" || res.reason === "socket-lost") {
|
|
1262
|
-
const how =
|
|
1262
|
+
const how =
|
|
1263
|
+
res.reason === "socket-lost"
|
|
1264
|
+
? "the connection dropped"
|
|
1265
|
+
: res.rowErrors
|
|
1266
|
+
? "some messages could not be written"
|
|
1267
|
+
: "the run was cut short";
|
|
1268
|
+
if (res.rowErrors) {
|
|
1269
|
+
warning(
|
|
1270
|
+
`${res.rowErrors} message(s) could not be written to the local cache (${res.firstRowError ?? "unknown error"}). ` +
|
|
1271
|
+
"Re-run with --force once the cause is gone.",
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1263
1274
|
warning(
|
|
1264
1275
|
`Partial restore: ${res.messagesSaved} message(s) from ${res.conversations} conversation(s) before ${how}.`,
|
|
1265
1276
|
);
|
|
@@ -384,6 +384,7 @@ export function createShardSchedule(shardCount, opts = {}) {
|
|
|
384
384
|
* @param {number} st.total - shards requested
|
|
385
385
|
* @param {number} [st.resumes=0] - reconnects used
|
|
386
386
|
* @param {number} [st.waitMs] - the budget, for the timeout wording
|
|
387
|
+
* @param {number} [st.rowErrors] - rows that could not be written to the cache
|
|
387
388
|
* @returns {{reason: string, error: string|null}}
|
|
388
389
|
*/
|
|
389
390
|
export function describeRestoreOutcome(st) {
|
|
@@ -410,6 +411,8 @@ export function describeRestoreOutcome(st) {
|
|
|
410
411
|
};
|
|
411
412
|
}
|
|
412
413
|
if (st.finished < st.total) return { reason: st.socketDied ? "socket-lost" : "partial", error: null };
|
|
414
|
+
// Every shard came back, but a row did not reach the cache: a hole is not complete.
|
|
415
|
+
if (st.rowErrors > 0) return { reason: "partial", error: null };
|
|
413
416
|
return { reason: "complete", error: null };
|
|
414
417
|
}
|
|
415
418
|
|
|
@@ -874,7 +877,10 @@ export class SyncV2 {
|
|
|
874
877
|
const ik = zproto.generateKeyPair();
|
|
875
878
|
let conversations = 0,
|
|
876
879
|
messagesSaved = 0,
|
|
877
|
-
attachmentsSaved = 0
|
|
880
|
+
attachmentsSaved = 0,
|
|
881
|
+
rowErrors = 0;
|
|
882
|
+
/** The first reason a row could not be written, for the report. */
|
|
883
|
+
let firstRowError = null;
|
|
878
884
|
const mappedThreads = new Set(),
|
|
879
885
|
unmappedThreads = new Set();
|
|
880
886
|
/** type name -> count, for the command's summary line. */
|
|
@@ -892,6 +898,8 @@ export class SyncV2 {
|
|
|
892
898
|
confirmed,
|
|
893
899
|
...resumeInfo,
|
|
894
900
|
attachmentsSaved,
|
|
901
|
+
rowErrors,
|
|
902
|
+
firstRowError,
|
|
895
903
|
typeCounts: { ...typeCounts },
|
|
896
904
|
// Every threadId the conversation round accounted for. Anything
|
|
897
905
|
// cached outside this list is orphaned.
|
|
@@ -1253,8 +1261,12 @@ export class SyncV2 {
|
|
|
1253
1261
|
messagesSaved++;
|
|
1254
1262
|
if (info.hasAttachment) attachmentsSaved++;
|
|
1255
1263
|
typeCounts[info.type] = (typeCounts[info.type] || 0) + 1;
|
|
1256
|
-
} catch {
|
|
1257
|
-
|
|
1264
|
+
} catch (e) {
|
|
1265
|
+
// Keep going with the rest of the chunk, but never silently: a row that cannot
|
|
1266
|
+
// be written (a full disk, a locked file) leaves a hole, so the run does not
|
|
1267
|
+
// call itself complete.
|
|
1268
|
+
rowErrors++;
|
|
1269
|
+
firstRowError ??= e?.message ?? String(e);
|
|
1258
1270
|
}
|
|
1259
1271
|
}
|
|
1260
1272
|
}
|
|
@@ -1314,6 +1326,7 @@ export class SyncV2 {
|
|
|
1314
1326
|
timedOut,
|
|
1315
1327
|
confirmed,
|
|
1316
1328
|
messagesSaved,
|
|
1329
|
+
rowErrors,
|
|
1317
1330
|
finished,
|
|
1318
1331
|
total: shards.length,
|
|
1319
1332
|
resumes: schedule.resumes(),
|
package/src/core/zalo-client.js
CHANGED
|
@@ -8,7 +8,7 @@ import { basename } from "node:path";
|
|
|
8
8
|
import { imageSizeFromFile } from "image-size/fromFile";
|
|
9
9
|
import { Zalo } from "zca-js";
|
|
10
10
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
11
|
-
import { ProxyAgent } from "undici";
|
|
11
|
+
import { ProxyAgent, fetch as undiciFetch } from "undici";
|
|
12
12
|
import { getActive } from "./accounts.js";
|
|
13
13
|
import { loadCredentials } from "./credentials.js";
|
|
14
14
|
import { info } from "../utils/output.js";
|
|
@@ -100,10 +100,14 @@ export function isLoggedIn() {
|
|
|
100
100
|
/**
|
|
101
101
|
* Create a proxy-aware fetch that uses undici ProxyAgent dispatcher.
|
|
102
102
|
* Native Node.js fetch ignores the `agent` option — must use `dispatcher`.
|
|
103
|
+
*
|
|
104
|
+
* The fetch is undici's own, not the global one: Node bundles its own undici and the package here is another, and a
|
|
105
|
+
* dispatcher from one handed to the fetch of the other fails every request with "invalid onRequestStart method". A
|
|
106
|
+
* proxied account could not log in at all.
|
|
103
107
|
*/
|
|
104
|
-
function createProxyFetch(proxyUrl) {
|
|
108
|
+
export function createProxyFetch(proxyUrl) {
|
|
105
109
|
const dispatcher = new ProxyAgent(proxyUrl);
|
|
106
|
-
return (url, init = {}) =>
|
|
110
|
+
return (url, init = {}) => undiciFetch(url, { ...init, dispatcher });
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
/**
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every agent connecting to this MCP server is told once, on connect (the MCP `instructions` field), before it
|
|
3
|
+
* reads a single tool. The tool descriptions say what each tool does; this says what they share: the ids, the way to
|
|
4
|
+
* read and to send, and the few rules a model gets wrong without being told. Keep it short: clients put it in the
|
|
5
|
+
* model's context on every session.
|
|
6
|
+
*/
|
|
7
|
+
export const SERVER_INSTRUCTIONS = [
|
|
8
|
+
"Zalo MCP server: read and send messages on one Zalo account.",
|
|
9
|
+
"",
|
|
10
|
+
"IDS",
|
|
11
|
+
"- threadId is the conversation: the other person's user id for a DM, the group id for a group. Find one by name with zalo_search_threads, among recent ones with zalo_list_conversations, or from a phone number or username with zalo_lookup_user. `me` is the owner's My Documents.",
|
|
12
|
+
"- msgId is one message, and it is called `msgId` in every input and reply. Live messages also carry it as `id`, and a send reply as `messageId`: the same value, kept for older clients.",
|
|
13
|
+
"- userId is a Zalo user. A message's author is its `senderId` (a userId); group members have a `userId`. `@[userId]` mentions someone.",
|
|
14
|
+
"- cliMsgId is the sender's own id for a message. zalo_send_message returns it, and zalo_react and zalo_undo need it (they read it from the cache when you leave it out).",
|
|
15
|
+
'- threadType, as an input, is the number 0 (DM) or 1 (group); leave it out and the type stored for the thread is used. Messages show `threadType` as the text "dm" or "group": never pass that text back.',
|
|
16
|
+
"",
|
|
17
|
+
"READING",
|
|
18
|
+
"- To receive messages, wait with zalo_wait_for_message or poll with zalo_get_messages, then call zalo_mark_read with the returned `cursor`. If several bots share the server, each passes its own `consumer` name.",
|
|
19
|
+
"- For older messages use zalo_get_history (one conversation) or zalo_search_messages (text, across all conversations).",
|
|
20
|
+
"",
|
|
21
|
+
"SENDING TEXT",
|
|
22
|
+
"- Put real line breaks in `text`. Never type the two characters backslash and n: Zalo shows them as typed.",
|
|
23
|
+
'- Markdown is converted to Zalo formatting by default; `format: "plain"` sends the text as written.',
|
|
24
|
+
"- Mention someone in a group with `@[uid]` (`@[-1]` is everyone). Reply to a message with `quoteMsgId`.",
|
|
25
|
+
"- A text over 3000 bytes goes out as several messages; the reply lists each part.",
|
|
26
|
+
"- A sent message cannot be edited. To correct one, recall it with zalo_undo (msgId and cliMsgId from the send reply) and send it again.",
|
|
27
|
+
"",
|
|
28
|
+
"RULES",
|
|
29
|
+
"- Call zalo_sync only when the owner asked: it makes the owner's phone ask for a confirmation.",
|
|
30
|
+
"- zalo_group `leave` and `remove_member` need `confirm: true`.",
|
|
31
|
+
"- A refused call says why in its error text. Fix the argument it names; do not repeat the same call.",
|
|
32
|
+
].join("\n");
|
|
@@ -12,7 +12,7 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
|
12
12
|
import { readFileSync } from "fs";
|
|
13
13
|
import { fileURLToPath } from "url";
|
|
14
14
|
import { dirname, join } from "path";
|
|
15
|
-
import {
|
|
15
|
+
import { newMcpServer } from "./new-server.js";
|
|
16
16
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
17
17
|
|
|
18
18
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -539,7 +539,7 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken, host =
|
|
|
539
539
|
// MCP endpoint — stateless, fresh server+transport per request (POST, GET, DELETE per Streamable HTTP spec)
|
|
540
540
|
app.all("/mcp", async (req, res) => {
|
|
541
541
|
try {
|
|
542
|
-
const server =
|
|
542
|
+
const server = newMcpServer(pkg.version);
|
|
543
543
|
registerToolsFn(
|
|
544
544
|
server,
|
|
545
545
|
deps.api,
|
package/src/mcp/mcp-server.js
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
import { readFileSync } from "fs";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
8
8
|
import { dirname, join } from "path";
|
|
9
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
9
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
10
|
import { registerTools } from "./mcp-tools.js";
|
|
11
|
+
import { newMcpServer } from "./new-server.js";
|
|
12
12
|
|
|
13
13
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8"));
|
|
@@ -24,13 +24,10 @@ const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8
|
|
|
24
24
|
* @param {string} [accountDir] - account data dir, for cache reads and media paths
|
|
25
25
|
* @param {object} [stageLock] - the daemon's one-stage-at-a-time lock (daemon-channel.js)
|
|
26
26
|
* @param {object} [syncJobs] - account-local sync tracker shared by all MCP requests
|
|
27
|
-
* @returns {Promise<McpServer>}
|
|
27
|
+
* @returns {Promise<import("@modelcontextprotocol/sdk/server/mcp.js").McpServer>}
|
|
28
28
|
*/
|
|
29
29
|
export async function createMCPServer(api, buffer, filter, config, nameCache, accountDir, stageLock, syncJobs) {
|
|
30
|
-
const server =
|
|
31
|
-
name: "zalo-agent",
|
|
32
|
-
version: pkg.version,
|
|
33
|
-
});
|
|
30
|
+
const server = newMcpServer(pkg.version);
|
|
34
31
|
|
|
35
32
|
registerTools(server, api, buffer, filter, config, nameCache, accountDir, stageLock, syncJobs);
|
|
36
33
|
|