@ardennguyen/zalo-agent-cli 2.5.5 → 2.5.7
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/.env.example +9 -0
- package/README.md +10 -6
- package/package.json +1 -1
- package/src/commands/fleet.js +439 -0
- package/src/commands/listen.js +87 -24
- package/src/commands/mcp-webhook.js +197 -0
- package/src/commands/mcp.js +162 -37
- package/src/commands/msg.js +13 -1
- package/src/commands/sync.js +12 -1
- package/src/core/accounts.js +182 -40
- package/src/core/credentials.js +5 -3
- package/src/core/daemon-channel.js +145 -19
- package/src/core/daemon-health.js +403 -0
- package/src/core/fleet-templates.js +177 -0
- package/src/core/fleet.js +255 -0
- package/src/core/log-sink.js +311 -0
- package/src/core/oa-client.js +10 -6
- package/src/core/startup-gate.js +206 -0
- package/src/core/sync-v2/index.js +16 -3
- package/src/core/webhook-outbox.js +575 -0
- package/src/core/zalo-client.js +7 -3
- package/src/index.js +13 -3
- package/src/mcp/mcp-config.js +42 -2
- package/src/mcp/mcp-http-transport.js +37 -18
- package/src/mcp/mcp-tools.js +82 -36
- package/src/mcp/message-buffer.js +94 -4
- package/src/mcp/wait-result.js +52 -0
- package/src/utils/atomic-file.js +209 -0
- package/src/utils/markdown.js +339 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start-up spreading inside the daemon: a random delay before it logs in, and a cap on how many daemons on
|
|
3
|
+
* this machine are logging in and loading their lists at the same moment.
|
|
4
|
+
*
|
|
5
|
+
* Every start logs in and loads the account's friend and group lists, and a fleet that restarts together (a
|
|
6
|
+
* reboot, a deploy, a crash loop) does all of that at once: the heaviest moment of each daemon's life, all
|
|
7
|
+
* against Zalo from one address. The systemd unit spreads it with `sleep $((RANDOM % 30))` and `fleet start`
|
|
8
|
+
* with batches, but both are the supervisor's doing and neither exists for a daemon started by hand, by a
|
|
9
|
+
* container, or by another supervisor. These settings put the same protection in the engine, off by default
|
|
10
|
+
* (one account loses nothing by starting at once).
|
|
11
|
+
*
|
|
12
|
+
* - `ZALO_START_JITTER` (seconds, default 0): wait a random time up to this long before logging in.
|
|
13
|
+
* - `ZALO_LOGIN_SLOTS` (default 0, no cap): at most this many daemons on the machine between "about to log
|
|
14
|
+
* in" and "lists loaded". A daemon over the cap waits for a slot, up to `ZALO_LOGIN_SLOT_WAIT` seconds
|
|
15
|
+
* (default 900), then goes ahead anyway: a stuck slot must not keep an account offline.
|
|
16
|
+
*
|
|
17
|
+
* A slot is a file in `<config dir>/login-slots/` holding the owner's pid; a slot whose owner died, or that
|
|
18
|
+
* has been held for `holdSec`, is reclaimed. Both settings take an `_<ownId>` form.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_SLOT_WAIT_SEC = 900;
|
|
25
|
+
/** A slot held this long is a stuck daemon, not a slow one: a start-up is seconds. */
|
|
26
|
+
export const DEFAULT_SLOT_HOLD_SEC = 300;
|
|
27
|
+
export const MAX_JITTER_SEC = 600;
|
|
28
|
+
export const MAX_SLOTS = 64;
|
|
29
|
+
|
|
30
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
31
|
+
|
|
32
|
+
/** @param {number} pid */
|
|
33
|
+
function alive(pid) {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
return true;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
return e.code === "EPERM";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The start-up settings of one account: `NAME_<ownId>`, then `NAME`, then the defaults. A value that cannot be
|
|
44
|
+
* used is reported and replaced by the default.
|
|
45
|
+
*
|
|
46
|
+
* @param {{ env?: NodeJS.ProcessEnv, ownId?: string|null }} [args]
|
|
47
|
+
* @returns {{ jitterSec: number, slots: number, waitSec: number, holdSec: number, warnings: string[] }}
|
|
48
|
+
*/
|
|
49
|
+
export function resolveStartupSettings({ env = process.env, ownId = null } = {}) {
|
|
50
|
+
const warnings = [];
|
|
51
|
+
const pick = (name) => {
|
|
52
|
+
const scoped = ownId ? env[`${name}_${ownId}`] : undefined;
|
|
53
|
+
const v = typeof scoped === "string" && scoped.trim() ? scoped : env[name];
|
|
54
|
+
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
55
|
+
};
|
|
56
|
+
const whole = (name, label, min, max, fallback) => {
|
|
57
|
+
const raw = pick(name);
|
|
58
|
+
if (raw === null) return fallback;
|
|
59
|
+
const n = Number(raw);
|
|
60
|
+
if (Number.isInteger(n) && n >= min && n <= max) return n;
|
|
61
|
+
warnings.push(`${name}="${raw}" is not a whole number from ${min} to ${max} (${label}); using ${fallback}`);
|
|
62
|
+
return fallback;
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
jitterSec: whole("ZALO_START_JITTER", "seconds", 0, MAX_JITTER_SEC, 0),
|
|
66
|
+
slots: whole("ZALO_LOGIN_SLOTS", "0 means no cap", 0, MAX_SLOTS, 0),
|
|
67
|
+
waitSec: whole("ZALO_LOGIN_SLOT_WAIT", "seconds", 1, 86400, DEFAULT_SLOT_WAIT_SEC),
|
|
68
|
+
holdSec: DEFAULT_SLOT_HOLD_SEC,
|
|
69
|
+
warnings,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {object} args
|
|
75
|
+
* @param {string} args.configDir - Slots live in `<configDir>/login-slots/`, shared by every daemon on the machine
|
|
76
|
+
* @param {string} args.ownId
|
|
77
|
+
* @param {ReturnType<typeof resolveStartupSettings>} args.settings
|
|
78
|
+
* @param {(line: string) => void} [args.log]
|
|
79
|
+
* @param {() => number} [args.random]
|
|
80
|
+
* @param {(ms: number) => Promise<void>} [args.sleep]
|
|
81
|
+
* @param {() => number} [args.now]
|
|
82
|
+
* @returns {{ enter: () => Promise<{ jitteredMs: number, waitedMs: number, slot: number|null, timedOut: boolean }>, leave: () => void, held: () => number|null }}
|
|
83
|
+
*/
|
|
84
|
+
export function createStartupGate({
|
|
85
|
+
configDir,
|
|
86
|
+
ownId,
|
|
87
|
+
settings,
|
|
88
|
+
log = () => {},
|
|
89
|
+
random = Math.random,
|
|
90
|
+
sleep = defaultSleep,
|
|
91
|
+
now = Date.now,
|
|
92
|
+
}) {
|
|
93
|
+
const dir = join(configDir, "login-slots");
|
|
94
|
+
let mine = null; // { file, text }
|
|
95
|
+
let exitHook = false;
|
|
96
|
+
|
|
97
|
+
const slotFile = (i) => join(dir, `slot-${i}`);
|
|
98
|
+
|
|
99
|
+
/** Remove slot files whose owner is gone or that have been held too long. */
|
|
100
|
+
function reclaimStale() {
|
|
101
|
+
let names = [];
|
|
102
|
+
try {
|
|
103
|
+
names = readdirSync(dir).filter((n) => /^slot-\d+$/.test(n));
|
|
104
|
+
} catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
for (const n of names) {
|
|
108
|
+
const file = join(dir, n);
|
|
109
|
+
try {
|
|
110
|
+
const text = readFileSync(file, "utf8");
|
|
111
|
+
const owner = JSON.parse(text)?.pid;
|
|
112
|
+
const age = now() - statSync(file).mtimeMs;
|
|
113
|
+
if ((Number.isInteger(owner) && !alive(owner)) || age > settings.holdSec * 1000) {
|
|
114
|
+
// Only what was judged stale: a slot freed and retaken meanwhile is a live one.
|
|
115
|
+
if (readFileSync(file, "utf8") === text) unlinkSync(file);
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
/* released meanwhile, or unreadable: the next try finds out */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function tryTake() {
|
|
124
|
+
const text = JSON.stringify({ pid: process.pid, ownId, at: now() });
|
|
125
|
+
for (let i = 0; i < settings.slots; i++) {
|
|
126
|
+
try {
|
|
127
|
+
const fd = openSync(slotFile(i), "wx", 0o600);
|
|
128
|
+
try {
|
|
129
|
+
writeSync(fd, text);
|
|
130
|
+
} finally {
|
|
131
|
+
closeSync(fd);
|
|
132
|
+
}
|
|
133
|
+
mine = { file: slotFile(i), text, index: i };
|
|
134
|
+
return i;
|
|
135
|
+
} catch (e) {
|
|
136
|
+
if (e.code !== "EEXIST") throw e;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function leave() {
|
|
143
|
+
if (!mine) return;
|
|
144
|
+
const { file, text } = mine;
|
|
145
|
+
mine = null;
|
|
146
|
+
try {
|
|
147
|
+
// Never remove a slot another daemon took over while this one was stuck.
|
|
148
|
+
if (readFileSync(file, "utf8") === text) unlinkSync(file);
|
|
149
|
+
} catch {
|
|
150
|
+
/* already gone */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
/**
|
|
156
|
+
* Wait out the jitter, then wait for a slot. Resolves when this daemon may log in.
|
|
157
|
+
* @returns {Promise<{ jitteredMs: number, waitedMs: number, slot: number|null, timedOut: boolean }>}
|
|
158
|
+
*/
|
|
159
|
+
async enter() {
|
|
160
|
+
let jitteredMs = 0;
|
|
161
|
+
if (settings.jitterSec > 0) {
|
|
162
|
+
jitteredMs = Math.round(random() * settings.jitterSec * 1000);
|
|
163
|
+
log(
|
|
164
|
+
`start-up jitter: waiting ${(jitteredMs / 1000).toFixed(1)}s of up to ${settings.jitterSec}s before logging in`,
|
|
165
|
+
);
|
|
166
|
+
await sleep(jitteredMs);
|
|
167
|
+
}
|
|
168
|
+
if (settings.slots <= 0) return { jitteredMs, waitedMs: 0, slot: null, timedOut: false };
|
|
169
|
+
|
|
170
|
+
mkdirSync(dir, { recursive: true });
|
|
171
|
+
if (!exitHook) {
|
|
172
|
+
exitHook = true;
|
|
173
|
+
process.on("exit", leave);
|
|
174
|
+
}
|
|
175
|
+
const started = now();
|
|
176
|
+
let announced = false;
|
|
177
|
+
for (;;) {
|
|
178
|
+
reclaimStale();
|
|
179
|
+
const slot = tryTake();
|
|
180
|
+
if (slot !== null) {
|
|
181
|
+
const waitedMs = now() - started;
|
|
182
|
+
if (announced)
|
|
183
|
+
log(`got login slot ${slot + 1} of ${settings.slots} after ${(waitedMs / 1000).toFixed(1)}s`);
|
|
184
|
+
return { jitteredMs, waitedMs, slot, timedOut: false };
|
|
185
|
+
}
|
|
186
|
+
const waitedMs = now() - started;
|
|
187
|
+
if (waitedMs >= settings.waitSec * 1000) {
|
|
188
|
+
log(
|
|
189
|
+
`no login slot free after ${settings.waitSec}s (ZALO_LOGIN_SLOTS=${settings.slots}); logging in anyway: ` +
|
|
190
|
+
"a stuck slot must not keep an account offline",
|
|
191
|
+
);
|
|
192
|
+
return { jitteredMs, waitedMs, slot: null, timedOut: true };
|
|
193
|
+
}
|
|
194
|
+
if (!announced) {
|
|
195
|
+
announced = true;
|
|
196
|
+
log(`waiting for a login slot (ZALO_LOGIN_SLOTS=${settings.slots}): other daemons are logging in`);
|
|
197
|
+
}
|
|
198
|
+
await sleep(150 + Math.floor(random() * 350));
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
/** Give the slot back. Safe to call twice, or without having entered. */
|
|
202
|
+
leave,
|
|
203
|
+
/** The slot this daemon holds, or null. */
|
|
204
|
+
held: () => (mine ? mine.index : null),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -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(),
|