@ardennguyen/zalo-agent-cli 2.2.2 → 2.5.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 +6 -6
- package/package.json +1 -1
- package/src/commands/conv.js +8 -1
- package/src/commands/listen.js +144 -42
- package/src/commands/login.js +16 -3
- package/src/commands/mcp.js +335 -77
- package/src/commands/profile.js +6 -2
- package/src/commands/sync.js +3 -1
- package/src/core/accounts.js +20 -0
- package/src/core/daemon-channel.js +419 -2
- package/src/core/daemon-sync.js +13 -2
- package/src/core/db.js +60 -3
- package/src/core/live-store.js +58 -1
- package/src/core/sync-v2/index.js +27 -3
- package/src/index.js +3 -0
- package/src/mcp/mcp-config.js +110 -8
- package/src/mcp/mcp-http-transport.js +529 -14
- package/src/mcp/mcp-tools.js +305 -14
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
|
-
>
|
|
53
|
+
> 23 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` · `zalo_wait_for_message` · `zalo_label`
|
|
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) —
|
|
127
|
+
| `mcp` | 1 | MCP server (stdio/HTTP/HTTPS) — 23 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) —
|
|
140
|
+
- **MCP server** (stdio + HTTP/HTTPS + OAuth 2.0) — 23 tools cho Claude Code, Gemini và các MCP client: đọc, chờ tin nhắn đến (`zalo_wait_for_message`), 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, gắn/gỡ nhãn hội thoại (`zalo_label`), 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
|
-
>
|
|
218
|
+
> 23 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` · `zalo_wait_for_message` · `zalo_label`
|
|
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) —
|
|
258
|
+
| `mcp` | 1 | MCP server (stdio/HTTP/HTTPS) — 23 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) —
|
|
269
|
+
- **MCP server** (stdio + HTTP/HTTPS + OAuth 2.0) — 23 tools for Claude Code, Gemini and other MCP clients: read, wait for incoming messages (`zalo_wait_for_message`), send (text, link previews, contact cards, to My Documents too, marked Important/Urgent), search cached messages, look up users, manage conversation labels (`zalo_label`), 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
package/src/commands/conv.js
CHANGED
|
@@ -13,6 +13,7 @@ import { recentConversations } from "../core/recent-conversations.js";
|
|
|
13
13
|
import { describeReadState, readStateLabel } from "../core/read-state.js";
|
|
14
14
|
import { pruneDownloadedMedia } from "../core/sync-v2/media.js";
|
|
15
15
|
import { markConversationRead, zaloPost } from "../core/receipts.js";
|
|
16
|
+
import { readUserAliases, currentUserName } from "../core/current-user-names.js";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Find the newest message in a thread and return the anchor triple
|
|
@@ -298,10 +299,16 @@ export function registerConvCommands(program, deps = {}) {
|
|
|
298
299
|
.filter((f) => f.lastActionTime > 0)
|
|
299
300
|
.sort((a, b) => b.lastActionTime - a.lastActionTime)
|
|
300
301
|
.slice(0, limit);
|
|
302
|
+
const aliases = sorted.length > 0 ? await readUserAliases(api) : new Map();
|
|
301
303
|
for (const f of sorted) {
|
|
304
|
+
const resolved = currentUserName(f.userId, f, aliases);
|
|
302
305
|
conversations.push({
|
|
303
306
|
threadId: f.userId,
|
|
304
|
-
name:
|
|
307
|
+
name:
|
|
308
|
+
(resolved.source !== "uid" ? resolved.name : null) ||
|
|
309
|
+
f.displayName ||
|
|
310
|
+
f.zaloName ||
|
|
311
|
+
"?",
|
|
305
312
|
type: "User",
|
|
306
313
|
typeFlag: 0,
|
|
307
314
|
// lastActionTime is epoch MILLISECONDS (13 digits). The old x1000
|
package/src/commands/listen.js
CHANGED
|
@@ -3,14 +3,22 @@
|
|
|
3
3
|
* Production-ready with auto-reconnect and re-login.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { createHmac } from "crypto";
|
|
6
7
|
import { appendFileSync, mkdirSync, existsSync } from "fs";
|
|
7
8
|
import { resolve, join } from "path";
|
|
8
9
|
import { getApi, clearSession } from "../core/zalo-client.js";
|
|
9
|
-
import { success, error, info, warning } from "../utils/output.js";
|
|
10
|
+
import { success, error, info, warning, output } from "../utils/output.js";
|
|
10
11
|
import { parseAccountId, selectDaemonAccount, loginDaemonAccount } from "../core/daemon-account.js";
|
|
12
|
+
import { updateAccountName } from "../core/accounts.js";
|
|
11
13
|
import { getAccountDir } from "../core/credentials.js";
|
|
12
14
|
import { acquireLock, releaseLock } from "../core/lock.js";
|
|
13
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
createStageLock,
|
|
17
|
+
startDaemonChannel,
|
|
18
|
+
getDaemonStatus,
|
|
19
|
+
stopDaemon,
|
|
20
|
+
spawnDaemonProcess,
|
|
21
|
+
} from "../core/daemon-channel.js";
|
|
14
22
|
import { createSyncRunners } from "../core/daemon-sync.js";
|
|
15
23
|
import { createSocketTap } from "../core/socket-tap.js";
|
|
16
24
|
import { createSelfHeal } from "../core/self-heal.js";
|
|
@@ -18,6 +26,7 @@ import { createReadStateSync, readStateEvent } from "../core/read-state.js";
|
|
|
18
26
|
import { initDb, getPendingSyncGaps } from "../core/db.js";
|
|
19
27
|
import {
|
|
20
28
|
storeLiveMessage,
|
|
29
|
+
refreshLiveMessageIdentities,
|
|
21
30
|
storeLiveDelete,
|
|
22
31
|
storeLiveReaction,
|
|
23
32
|
storeLiveUndo,
|
|
@@ -31,6 +40,7 @@ import { downloadSyncedMedia } from "../core/sync-v2/media.js";
|
|
|
31
40
|
import { SyncManager } from "../core/sync.js";
|
|
32
41
|
import { describeGap } from "../core/sync-v2/gap-advice.js";
|
|
33
42
|
import { createDeliveredReceipts } from "../core/receipts.js";
|
|
43
|
+
import { envForAccount } from "../mcp/mcp-config.js";
|
|
34
44
|
|
|
35
45
|
/** Thread types matching zca-js ThreadType enum */
|
|
36
46
|
const THREAD_USER = 0;
|
|
@@ -65,8 +75,8 @@ export function registerListenCommand(program) {
|
|
|
65
75
|
)
|
|
66
76
|
.option(
|
|
67
77
|
"-e, --events <types>",
|
|
68
|
-
"Comma-separated event types: message,friend,group,reaction,read (default: message,friend)",
|
|
69
|
-
"message,friend",
|
|
78
|
+
"Comma-separated event types: message,friend,group,reaction,read (default: message,friend,group,reaction,read)",
|
|
79
|
+
"message,friend,group,reaction,read",
|
|
70
80
|
)
|
|
71
81
|
.option("-f, --filter <type>", "Message filter: user (DM only), group (groups only), all", "all")
|
|
72
82
|
.option("-w, --webhook <url>", "POST each event as JSON to this URL (for n8n, Make, etc.)")
|
|
@@ -81,7 +91,11 @@ export function registerListenCommand(program) {
|
|
|
81
91
|
"--no-self-heal",
|
|
82
92
|
"Do not pull what a dropped socket missed from Zalo's offline queue on reconnect (the default does, as Zalo Web does, on this socket and with no phone tap)",
|
|
83
93
|
)
|
|
94
|
+
.option("-d, --daemon", "Run in the background (logs to <accountDir>/daemon.log)")
|
|
95
|
+
.option("--stop", "Stop the running background daemon for this account")
|
|
96
|
+
.option("--status", "Show background daemon status for this account")
|
|
84
97
|
.action(async (opts) => {
|
|
98
|
+
const jsonMode = program.opts().json;
|
|
85
99
|
let activeAcc;
|
|
86
100
|
try {
|
|
87
101
|
const requestedAccount =
|
|
@@ -92,6 +106,68 @@ export function registerListenCommand(program) {
|
|
|
92
106
|
process.exit(1);
|
|
93
107
|
}
|
|
94
108
|
const accountDir = getAccountDir(activeAcc.ownId);
|
|
109
|
+
|
|
110
|
+
if (opts.status) {
|
|
111
|
+
const status = getDaemonStatus(accountDir);
|
|
112
|
+
output({ accountId: activeAcc.ownId, ...status }, jsonMode, () => {
|
|
113
|
+
if (status.running) {
|
|
114
|
+
success(
|
|
115
|
+
`Daemon running for account ${activeAcc.ownId} (PID ${status.pid}${status.channelPort ? `, channel 127.0.0.1:${status.channelPort}` : ""})`,
|
|
116
|
+
);
|
|
117
|
+
info(`Log file: ${status.logPath}`);
|
|
118
|
+
} else {
|
|
119
|
+
info(`No daemon running for account ${activeAcc.ownId}`);
|
|
120
|
+
info(`Log file: ${status.logPath}`);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (opts.stop) {
|
|
127
|
+
const res = await stopDaemon(accountDir);
|
|
128
|
+
output({ accountId: activeAcc.ownId, ...res }, jsonMode, () => {
|
|
129
|
+
if (res.stopped) {
|
|
130
|
+
success(
|
|
131
|
+
`Stopped daemon for account ${activeAcc.ownId} (PID ${res.pid}${res.graceful ? "" : ", forced"})`,
|
|
132
|
+
);
|
|
133
|
+
} else {
|
|
134
|
+
info(`No daemon running for account ${activeAcc.ownId}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (opts.daemon && !process.env.ZALO_DAEMON_CHILD) {
|
|
141
|
+
const spawned = await spawnDaemonProcess({ accountDir, args: process.argv.slice(1) });
|
|
142
|
+
if (!spawned.ok) {
|
|
143
|
+
if (spawned.alreadyRunning) {
|
|
144
|
+
error(
|
|
145
|
+
`Another listen daemon is already running for account ${activeAcc.ownId} (PID ${spawned.pid}).`,
|
|
146
|
+
);
|
|
147
|
+
info("Stop it first with: zalo-agent listen --stop");
|
|
148
|
+
} else {
|
|
149
|
+
error(`Failed to start background listener: ${spawned.error}`);
|
|
150
|
+
info(`Log file: ${spawned.logPath}`);
|
|
151
|
+
}
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
output(
|
|
155
|
+
{
|
|
156
|
+
accountId: activeAcc.ownId,
|
|
157
|
+
pid: spawned.pid,
|
|
158
|
+
channelPort: spawned.channelPort,
|
|
159
|
+
logPath: spawned.logPath,
|
|
160
|
+
},
|
|
161
|
+
jsonMode,
|
|
162
|
+
() => {
|
|
163
|
+
success(`Listener started in background for account ${activeAcc.ownId} (PID ${spawned.pid})`);
|
|
164
|
+
info(`Log file: ${spawned.logPath}`);
|
|
165
|
+
info("Stop with: zalo-agent listen --stop");
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
95
171
|
if (!acquireLock(accountDir)) {
|
|
96
172
|
error(`Another listen daemon is already running for account ${activeAcc.ownId}.`);
|
|
97
173
|
process.exit(1);
|
|
@@ -284,7 +360,6 @@ export function registerListenCommand(program) {
|
|
|
284
360
|
}
|
|
285
361
|
const heartbeatTimer = setInterval(heartbeat, HEARTBEAT_MS);
|
|
286
362
|
|
|
287
|
-
const jsonMode = program.opts().json;
|
|
288
363
|
const startTime = Date.now();
|
|
289
364
|
let reconnectCount = 0;
|
|
290
365
|
let eventCount = 0;
|
|
@@ -308,13 +383,24 @@ export function registerListenCommand(program) {
|
|
|
308
383
|
return h > 0 ? `${h}h${m}m` : `${m}m${s % 60}s`;
|
|
309
384
|
}
|
|
310
385
|
|
|
386
|
+
const webhookUrl = opts.webhook?.trim() || envForAccount("ZALO_WEBHOOK_URL", activeAcc.ownId) || null;
|
|
387
|
+
const webhookSecret = envForAccount("ZALO_WEBHOOK_SECRET", activeAcc.ownId) || null;
|
|
388
|
+
|
|
311
389
|
/** Fire-and-forget webhook POST — never blocks event processing */
|
|
312
390
|
function postWebhook(data) {
|
|
313
|
-
if (!
|
|
314
|
-
|
|
391
|
+
if (!webhookUrl) return;
|
|
392
|
+
const payload = { accountId: activeAcc.ownId, ...data };
|
|
393
|
+
const body = JSON.stringify(payload);
|
|
394
|
+
const headers = { "Content-Type": "application/json" };
|
|
395
|
+
if (webhookSecret) {
|
|
396
|
+
headers["Authorization"] = `Bearer ${webhookSecret}`;
|
|
397
|
+
headers["X-Zalo-Signature"] =
|
|
398
|
+
`sha256=${createHmac("sha256", webhookSecret).update(body).digest("hex")}`;
|
|
399
|
+
}
|
|
400
|
+
fetch(webhookUrl, {
|
|
315
401
|
method: "POST",
|
|
316
|
-
headers
|
|
317
|
-
body
|
|
402
|
+
headers,
|
|
403
|
+
body,
|
|
318
404
|
signal: AbortSignal.timeout(5000),
|
|
319
405
|
}).catch((e) => {
|
|
320
406
|
console.error(`[listen] Webhook failed: ${e.message}`);
|
|
@@ -504,6 +590,10 @@ export function registerListenCommand(program) {
|
|
|
504
590
|
console.error(`[listen] message not stored: ${stored.reason}`);
|
|
505
591
|
return;
|
|
506
592
|
}
|
|
593
|
+
refreshLiveMessageIdentities(api, msg, {
|
|
594
|
+
onSelfName: (name) => updateAccountName(activeAcc.ownId, name),
|
|
595
|
+
log: (line) => console.error(`[listen] ${line}`),
|
|
596
|
+
}).catch((err) => console.error(`[listen] identity refresh failed: ${err.message}`));
|
|
507
597
|
// Fetch through the SAME downloader the mobile sync
|
|
508
598
|
// uses: it brings the request deadline, the
|
|
509
599
|
// expiry-vs-throttling classification and the shared
|
|
@@ -761,7 +851,7 @@ export function registerListenCommand(program) {
|
|
|
761
851
|
info(`Events: ${opts.events}`);
|
|
762
852
|
info("Auto-reconnect enabled.");
|
|
763
853
|
if (opts.filter !== "all") info(`Message filter: ${opts.filter}`);
|
|
764
|
-
if (
|
|
854
|
+
if (webhookUrl) info(`Webhook: ${webhookUrl}${webhookSecret ? " (signed)" : ""}`);
|
|
765
855
|
if (saveDir) info(`Save dir: ${saveDir} (JSONL per thread)`);
|
|
766
856
|
if (opts.autoAccept) info("Auto-accept friend requests: ON");
|
|
767
857
|
info(
|
|
@@ -794,14 +884,53 @@ export function registerListenCommand(program) {
|
|
|
794
884
|
// rebuilds one on a duplicate-session close, and a stage bound to
|
|
795
885
|
// the old object would tap a socket zca-js has already nulled.
|
|
796
886
|
let channel = null;
|
|
887
|
+
const shutdown = () => {
|
|
888
|
+
// First, before anything can emit a close event.
|
|
889
|
+
stopping = true;
|
|
890
|
+
channel?.stop();
|
|
891
|
+
deliveredReceipts.stop();
|
|
892
|
+
try {
|
|
893
|
+
getApi().listener.stop();
|
|
894
|
+
} catch (e) {
|
|
895
|
+
console.error(`[listen] Stop failed: ${e.message}`);
|
|
896
|
+
}
|
|
897
|
+
clearInterval(heartbeatTimer);
|
|
898
|
+
// Stamp "last known connected" at the moment we stop, so a
|
|
899
|
+
// deliberate close (or the process simply exiting) gives
|
|
900
|
+
// the NEXT launch an accurate window to backfill from,
|
|
901
|
+
// rather than treating this whole downtime as unknown.
|
|
902
|
+
try {
|
|
903
|
+
syncManager.markConnected();
|
|
904
|
+
} catch (e) {
|
|
905
|
+
console.error(`[listen] Failed to stamp shutdown state: ${e.message}`);
|
|
906
|
+
}
|
|
907
|
+
releaseLock(accountDir);
|
|
908
|
+
info(`Stopped. Uptime: ${uptime()}, events: ${eventCount}, reconnects: ${reconnectCount}`);
|
|
909
|
+
const receipts = deliveredReceipts.stats();
|
|
910
|
+
if (receipts.calls) {
|
|
911
|
+
info(`Delivered receipts: ${receipts.sent} acknowledged, ${receipts.failed} failed`);
|
|
912
|
+
}
|
|
913
|
+
if (saveDir) info(`Messages saved to: ${saveDir}`);
|
|
914
|
+
// Resolving the keep-alive promise is not enough: an async
|
|
915
|
+
// `closed` handler already in flight resumes after the action
|
|
916
|
+
// function has returned and keeps the process alive. mcp.js
|
|
917
|
+
// already exits explicitly here for the same reason.
|
|
918
|
+
process.exit(0);
|
|
919
|
+
};
|
|
920
|
+
|
|
797
921
|
try {
|
|
798
922
|
channel = await startDaemonChannel({
|
|
799
923
|
getApi,
|
|
800
924
|
accountDir,
|
|
801
925
|
onLog: (m) => info(m),
|
|
802
|
-
runners: createSyncRunners({
|
|
926
|
+
runners: createSyncRunners({
|
|
927
|
+
getApi,
|
|
928
|
+
accountName: activeAcc.ownId,
|
|
929
|
+
onSelfName: (name) => updateAccountName(activeAcc.ownId, name),
|
|
930
|
+
}),
|
|
803
931
|
// The self-heal's lock: a stage and a catch-up never share the socket.
|
|
804
932
|
lock: stageLock,
|
|
933
|
+
onStop: shutdown,
|
|
805
934
|
});
|
|
806
935
|
info(`Sync & upload channel ready on 127.0.0.1:${channel.port} — this socket is reused for both.`);
|
|
807
936
|
info("`zalo-agent sync` will run its socket stages here; it still needs your phone tap.");
|
|
@@ -810,42 +939,15 @@ export function registerListenCommand(program) {
|
|
|
810
939
|
warning("Attachment sends will open their own session, and `zalo-agent sync` will refuse to run.");
|
|
811
940
|
}
|
|
812
941
|
|
|
813
|
-
// Keep alive until Ctrl+C
|
|
814
|
-
await new Promise((
|
|
942
|
+
// Keep alive until Ctrl+C or `listen --stop`
|
|
943
|
+
await new Promise(() => {
|
|
815
944
|
process.on("SIGINT", () => {
|
|
816
945
|
// First, before anything can emit a close event.
|
|
817
946
|
stopping = true;
|
|
818
|
-
|
|
819
|
-
deliveredReceipts.stop();
|
|
820
|
-
try {
|
|
821
|
-
getApi().listener.stop();
|
|
822
|
-
} catch (e) {
|
|
823
|
-
console.error(`[listen] Stop failed: ${e.message}`);
|
|
824
|
-
}
|
|
825
|
-
clearInterval(heartbeatTimer);
|
|
826
|
-
// Stamp "last known connected" at the moment we stop, so a
|
|
827
|
-
// deliberate close (or the process simply exiting) gives
|
|
828
|
-
// the NEXT launch an accurate window to backfill from,
|
|
829
|
-
// rather than treating this whole downtime as unknown.
|
|
830
|
-
try {
|
|
831
|
-
syncManager.markConnected();
|
|
832
|
-
} catch (e) {
|
|
833
|
-
console.error(`[listen] Failed to stamp shutdown state: ${e.message}`);
|
|
834
|
-
}
|
|
835
|
-
releaseLock(accountDir);
|
|
836
|
-
info(`Stopped. Uptime: ${uptime()}, events: ${eventCount}, reconnects: ${reconnectCount}`);
|
|
837
|
-
const receipts = deliveredReceipts.stats();
|
|
838
|
-
if (receipts.calls) {
|
|
839
|
-
info(`Delivered receipts: ${receipts.sent} acknowledged, ${receipts.failed} failed`);
|
|
840
|
-
}
|
|
841
|
-
if (saveDir) info(`Messages saved to: ${saveDir}`);
|
|
842
|
-
resolve();
|
|
843
|
-
// Resolving the keep-alive promise is not enough: an async
|
|
844
|
-
// `closed` handler already in flight resumes after the action
|
|
845
|
-
// function has returned and keeps the process alive. mcp.js
|
|
846
|
-
// already exits explicitly here for the same reason.
|
|
947
|
+
shutdown();
|
|
847
948
|
process.exit(0);
|
|
848
949
|
});
|
|
950
|
+
process.on("SIGTERM", shutdown);
|
|
849
951
|
});
|
|
850
952
|
});
|
|
851
953
|
}
|
package/src/commands/login.js
CHANGED
|
@@ -13,8 +13,9 @@ import {
|
|
|
13
13
|
getApi,
|
|
14
14
|
getOwnId,
|
|
15
15
|
} from "../core/zalo-client.js";
|
|
16
|
-
import { saveCredentials, loadCredentials } from "../core/credentials.js";
|
|
17
|
-
import { addAccount, getActive, removeAccount } from "../core/accounts.js";
|
|
16
|
+
import { saveCredentials, loadCredentials, getAccountDir } from "../core/credentials.js";
|
|
17
|
+
import { addAccount, getActive, removeAccount, updateAccountName } from "../core/accounts.js";
|
|
18
|
+
import { getDaemonStatus } from "../core/daemon-channel.js";
|
|
18
19
|
import { serverLogout, reportLogout, finishLocalLogout, lockHolder } from "../core/logout.js";
|
|
19
20
|
import { maskProxy } from "../utils/proxy-helpers.js";
|
|
20
21
|
import { displayQR, getQRPath } from "../utils/qr-display.js";
|
|
@@ -279,12 +280,14 @@ export function registerLoginCommands(program) {
|
|
|
279
280
|
.description("Show current login status")
|
|
280
281
|
.action(() => {
|
|
281
282
|
const active = getActive();
|
|
283
|
+
const daemon = active ? getDaemonStatus(getAccountDir(active.ownId)) : null;
|
|
282
284
|
const data = {
|
|
283
285
|
loggedIn: isLoggedIn(),
|
|
284
286
|
ownId: getOwnId(),
|
|
285
287
|
activeAccount: active
|
|
286
288
|
? { ownId: active.ownId, name: active.name, proxy: maskProxy(active.proxy) }
|
|
287
289
|
: null,
|
|
290
|
+
daemon,
|
|
288
291
|
};
|
|
289
292
|
output(data, program.opts().json, () => {
|
|
290
293
|
if (data.loggedIn) {
|
|
@@ -299,6 +302,15 @@ export function registerLoginCommands(program) {
|
|
|
299
302
|
else if (active)
|
|
300
303
|
info(`Account ${active.name || active.ownId} is logged out — run: zalo-agent login`);
|
|
301
304
|
}
|
|
305
|
+
if (daemon) {
|
|
306
|
+
if (daemon.running) {
|
|
307
|
+
info(
|
|
308
|
+
`Daemon: running (PID ${daemon.pid}${daemon.channelPort ? `, channel 127.0.0.1:${daemon.channelPort}` : ""})`,
|
|
309
|
+
);
|
|
310
|
+
} else {
|
|
311
|
+
info("Daemon: not running");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
302
314
|
});
|
|
303
315
|
});
|
|
304
316
|
|
|
@@ -309,8 +321,9 @@ export function registerLoginCommands(program) {
|
|
|
309
321
|
try {
|
|
310
322
|
const api = getApi();
|
|
311
323
|
const accountInfo = await api.fetchAccountInfo();
|
|
324
|
+
const p = accountInfo?.profile || {};
|
|
325
|
+
updateAccountName(p.userId || getOwnId(), p.displayName || p.zaloName);
|
|
312
326
|
output(accountInfo, program.opts().json, () => {
|
|
313
|
-
const p = accountInfo?.profile || {};
|
|
314
327
|
info(`Name: ${p.displayName || "?"}`);
|
|
315
328
|
info(`ID: ${p.userId || getOwnId()}`);
|
|
316
329
|
info(`Phone: ${p.phoneNumber || "?"}`);
|