@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ardennguyen/zalo-agent-cli",
3
- "version": "2.2.1",
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.1": true
80
+ "@ardennguyen/zalo-agent-cli@2.2.2": true
81
81
  }
82
82
  }
@@ -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
 
@@ -16,6 +16,7 @@ export function getDefaultConfig() {
16
16
  watchThreads: ["dm:*", "group:*"],
17
17
  mode: "manual",
18
18
  triggerKeywords: ["@bot"],
19
+ webhook: null,
19
20
  notify: {
20
21
  enabled: false,
21
22
  thread: null,
@@ -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
  /**