@ardennguyen/zalo-agent-cli 2.2.1 → 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.
- package/package.json +2 -2
- package/src/commands/mcp.js +42 -0
- package/src/mcp/mcp-config.js +1 -0
- package/src/mcp/mcp-http-transport.js +40 -0
- package/src/mcp/message-buffer.js +62 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ardennguyen/zalo-agent-cli",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.2",
|
|
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.2.
|
|
80
|
+
"@ardennguyen/zalo-agent-cli@2.2.2": true
|
|
81
81
|
}
|
|
82
82
|
}
|
package/src/commands/mcp.js
CHANGED
|
@@ -90,6 +90,38 @@ export function normalizeMessage(msg, info = null) {
|
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Fire-and-forget HTTP POST for an incoming buffered MCP message when a
|
|
95
|
+
* webhook URL is configured (`-w, --webhook <url>` or `webhook` in
|
|
96
|
+
* `mcp-config.json`). Never blocks or throws into the listener loop.
|
|
97
|
+
*
|
|
98
|
+
* @param {string|null} webhookUrl
|
|
99
|
+
* @param {object} message - Normalized message object
|
|
100
|
+
* @param {object} [extra] - Additional top-level fields (e.g. `{ accountId }`)
|
|
101
|
+
* @param {Function} [fetchFn=fetch] - Injectable fetch for unit testing
|
|
102
|
+
* @returns {Promise<boolean>} True when dispatched, false when skipped or failed
|
|
103
|
+
*/
|
|
104
|
+
export async function postMCPWebhook(webhookUrl, message, extra = {}, fetchFn = fetch) {
|
|
105
|
+
if (!webhookUrl) return false;
|
|
106
|
+
const payload = {
|
|
107
|
+
event: "message",
|
|
108
|
+
...extra,
|
|
109
|
+
...message,
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
await fetchFn(webhookUrl, {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { "Content-Type": "application/json" },
|
|
115
|
+
body: JSON.stringify(payload),
|
|
116
|
+
signal: AbortSignal.timeout(5000),
|
|
117
|
+
});
|
|
118
|
+
return true;
|
|
119
|
+
} catch (e) {
|
|
120
|
+
console.error(`[mcp] Webhook failed: ${e.message}`);
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
93
125
|
export function registerMCPCommands(program) {
|
|
94
126
|
const mcp = program.command("mcp").description("MCP server for AI agent integration");
|
|
95
127
|
|
|
@@ -104,6 +136,10 @@ export function registerMCPCommands(program) {
|
|
|
104
136
|
.option("--http <port>", "Use HTTP transport on specified port (default: stdio)")
|
|
105
137
|
.option("--auth <token>", "Bearer token for HTTP auth (only with --http)")
|
|
106
138
|
.option("--host <address>", "HTTP bind address (default: 127.0.0.1, only with --http)")
|
|
139
|
+
.option(
|
|
140
|
+
"-w, --webhook <url>",
|
|
141
|
+
"POST each watched incoming message as JSON to this URL (or set webhook in mcp-config.json)",
|
|
142
|
+
)
|
|
107
143
|
.option(
|
|
108
144
|
"--no-delivered-receipts",
|
|
109
145
|
"Do not acknowledge received messages as delivered (every Zalo client does; seen receipts are never sent)",
|
|
@@ -281,6 +317,10 @@ export function registerMCPCommands(program) {
|
|
|
281
317
|
|
|
282
318
|
// Setup notifier (sends to Zalo group when agent is offline)
|
|
283
319
|
const notifier = new ZaloNotifier(sessionApi, config);
|
|
320
|
+
const webhookUrl = opts.webhook?.trim() || config.webhook?.trim() || null;
|
|
321
|
+
if (webhookUrl) {
|
|
322
|
+
console.error(`[mcp] Webhook enabled: ${webhookUrl}`);
|
|
323
|
+
}
|
|
284
324
|
|
|
285
325
|
// Delivered receipts: the same shared implementation `listen` uses
|
|
286
326
|
// (AGENTS.md §13 -- the two listeners must not differ). Diagnostics
|
|
@@ -366,6 +406,7 @@ export function registerMCPCommands(program) {
|
|
|
366
406
|
if (!filter.shouldKeep(normalized)) continue;
|
|
367
407
|
buffer.push(normalized.threadId, normalized);
|
|
368
408
|
notifier.onMessage(normalized);
|
|
409
|
+
postMCPWebhook(webhookUrl, normalized, { accountId: activeAcc.ownId });
|
|
369
410
|
}
|
|
370
411
|
},
|
|
371
412
|
});
|
|
@@ -433,6 +474,7 @@ export function registerMCPCommands(program) {
|
|
|
433
474
|
|
|
434
475
|
buffer.push(normalized.threadId, normalized);
|
|
435
476
|
notifier.onMessage(normalized);
|
|
477
|
+
postMCPWebhook(webhookUrl, normalized, { accountId: activeAcc.ownId });
|
|
436
478
|
console.error(`[mcp] Buffered ${normalized.threadType} msg from ${normalized.threadId}`);
|
|
437
479
|
});
|
|
438
480
|
|
package/src/mcp/mcp-config.js
CHANGED
|
@@ -79,6 +79,46 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken, host =
|
|
|
79
79
|
}
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
// Long-poll endpoint for zero-CPU event-driven wakeup (protected by auth middleware above)
|
|
83
|
+
app.get("/events/wait", async (req, res) => {
|
|
84
|
+
const rawTimeout = Number(req.query.timeout);
|
|
85
|
+
const timeoutSec = Number.isFinite(rawTimeout) && rawTimeout > 0 ? Math.min(rawTimeout, 300) : 60;
|
|
86
|
+
const threadId =
|
|
87
|
+
typeof req.query.threadId === "string" && req.query.threadId.trim() ? req.query.threadId.trim() : null;
|
|
88
|
+
const sinceRaw = req.query.since !== undefined ? Number(req.query.since) : undefined;
|
|
89
|
+
const since = Number.isFinite(sinceRaw) ? sinceRaw : undefined;
|
|
90
|
+
|
|
91
|
+
const ac = new AbortController();
|
|
92
|
+
res.on("close", () => ac.abort());
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const msg = await deps.buffer.waitForMessage({
|
|
96
|
+
threadId,
|
|
97
|
+
since,
|
|
98
|
+
timeoutMs: timeoutSec * 1000,
|
|
99
|
+
signal: ac.signal,
|
|
100
|
+
});
|
|
101
|
+
if (res.writableEnded) return;
|
|
102
|
+
if (msg) {
|
|
103
|
+
res.json({
|
|
104
|
+
status: "message",
|
|
105
|
+
message: msg,
|
|
106
|
+
cursor: msg._cursor,
|
|
107
|
+
});
|
|
108
|
+
} else {
|
|
109
|
+
res.json({
|
|
110
|
+
status: "timeout",
|
|
111
|
+
message: null,
|
|
112
|
+
cursor: deps.buffer.readCursor(),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (!res.headersSent) {
|
|
117
|
+
res.status(500).json({ error: err.message });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
82
122
|
// Health check — no auth required
|
|
83
123
|
app.get("/health", (req, res) => {
|
|
84
124
|
res.json({
|
|
@@ -28,6 +28,8 @@ export class MessageBuffer {
|
|
|
28
28
|
this._globalCursor = 0;
|
|
29
29
|
/** @type {Map<string, { cursor: number, at: number }>} */
|
|
30
30
|
this._readCursors = new Map();
|
|
31
|
+
/** @type {Set<{ threadId: string|null, resolve: Function, timer: NodeJS.Timeout }>} */
|
|
32
|
+
this._waiters = new Set();
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/**
|
|
@@ -40,7 +42,8 @@ export class MessageBuffer {
|
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/**
|
|
43
|
-
* Add message to thread buffer. Auto-evicts stale messages
|
|
45
|
+
* Add message to thread buffer. Auto-evicts stale messages and wakes any
|
|
46
|
+
* long-poll waiters matching the thread.
|
|
44
47
|
* @param {string} threadId
|
|
45
48
|
* @param {object} message - Normalized message object
|
|
46
49
|
*/
|
|
@@ -54,6 +57,64 @@ export class MessageBuffer {
|
|
|
54
57
|
thread.messages.push(message);
|
|
55
58
|
thread.lastActivity = Date.now();
|
|
56
59
|
this._evict(threadId);
|
|
60
|
+
|
|
61
|
+
for (const waiter of [...this._waiters]) {
|
|
62
|
+
if (!waiter.threadId || waiter.threadId === String(threadId)) {
|
|
63
|
+
this._waiters.delete(waiter);
|
|
64
|
+
clearTimeout(waiter.timer);
|
|
65
|
+
waiter.resolve(message);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Wait for the next buffered message (or return immediately if `since` is
|
|
72
|
+
* provided and a newer message is already in the buffer).
|
|
73
|
+
*
|
|
74
|
+
* @param {object} [opts]
|
|
75
|
+
* @param {string} [opts.threadId] - Optional thread filter
|
|
76
|
+
* @param {number} [opts.since] - Optional cursor; if a message > since already exists, resolves immediately
|
|
77
|
+
* @param {number} [opts.timeoutMs=60000] - Max wait duration in milliseconds
|
|
78
|
+
* @param {AbortSignal} [opts.signal] - Optional abort signal (e.g. when HTTP client disconnects)
|
|
79
|
+
* @returns {Promise<object|null>} The message object, or null on timeout/abort
|
|
80
|
+
*/
|
|
81
|
+
waitForMessage({ threadId = null, since = undefined, timeoutMs = 60000, signal = undefined } = {}) {
|
|
82
|
+
if (since !== undefined && since !== null && Number.isFinite(Number(since))) {
|
|
83
|
+
const existing = this.read(threadId || undefined, Number(since), 1);
|
|
84
|
+
if (existing.messages.length > 0) {
|
|
85
|
+
return Promise.resolve(existing.messages[0]);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (signal?.aborted) {
|
|
89
|
+
return Promise.resolve(null);
|
|
90
|
+
}
|
|
91
|
+
return new Promise((resolve) => {
|
|
92
|
+
const waiter = {
|
|
93
|
+
threadId: threadId ? String(threadId) : null,
|
|
94
|
+
resolve: (msg) => {
|
|
95
|
+
signal?.removeEventListener("abort", onAbort);
|
|
96
|
+
resolve(msg);
|
|
97
|
+
},
|
|
98
|
+
timer: null,
|
|
99
|
+
};
|
|
100
|
+
const onAbort = () => {
|
|
101
|
+
if (this._waiters.delete(waiter)) {
|
|
102
|
+
clearTimeout(waiter.timer);
|
|
103
|
+
resolve(null);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
waiter.timer = setTimeout(
|
|
107
|
+
() => {
|
|
108
|
+
if (this._waiters.delete(waiter)) {
|
|
109
|
+
signal?.removeEventListener("abort", onAbort);
|
|
110
|
+
resolve(null);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
Math.max(1, Number(timeoutMs) || 60000),
|
|
114
|
+
);
|
|
115
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
116
|
+
this._waiters.add(waiter);
|
|
117
|
+
});
|
|
57
118
|
}
|
|
58
119
|
|
|
59
120
|
/**
|