@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.
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Running many accounts on one machine: which port each gets, which are healthy, and the files that
3
+ * supervise them.
4
+ *
5
+ * One account is one process (AGENTS.md section 13), so hundreds of accounts are hundreds of daemons, each
6
+ * with a port, a token and a systemd unit. Until this module the only tooling was the template unit in the
7
+ * docs: the operator kept the port table by hand, found a dead account by probing each one, and a whole
8
+ * fleet restarting at once was the heaviest moment of its life. `zalo-agent fleet ...` (src/commands/fleet.js)
9
+ * is built on these.
10
+ *
11
+ * - The port table (`fleet.json` in the config directory) gives each account a stable port. It is
12
+ * assigned once and never reshuffled, so adding an account never moves another one's port.
13
+ * - {@link classifyAccount} turns a daemon's health snapshot into one word, the one `fleet status` shows
14
+ * and `fleet restart --unhealthy` acts on.
15
+ * - {@link planBatches} orders a start-up: shuffled, in groups, so accounts that were set up together do
16
+ * not all log in together.
17
+ * - {@link generateFleetFiles} writes the systemd unit, env files with generated tokens, and a proxy.
18
+ */
19
+
20
+ import { randomBytes } from "node:crypto";
21
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { CONFIG_DIR } from "./credentials.js";
24
+ import { withFileLock, writeFileAtomic } from "../utils/atomic-file.js";
25
+ import { COMMON_ENV_TEMPLATE, renderAccountEnv, renderCaddyfile, renderUnit } from "./fleet-templates.js";
26
+
27
+ export const DEFAULT_BASE_PORT = 3900;
28
+
29
+ /** The fleet's port table could not be read or used. Nothing was changed. */
30
+ export class FleetError extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = "FleetError";
34
+ }
35
+ }
36
+
37
+ /** @returns {string} */
38
+ export function fleetFile() {
39
+ return join(CONFIG_DIR, "fleet.json");
40
+ }
41
+
42
+ /**
43
+ * @returns {{ basePort: number, ports: Record<string, number> }}
44
+ * @throws {FleetError} when the file exists but is not a usable table
45
+ */
46
+ export function loadPortTable() {
47
+ const file = fleetFile();
48
+ if (!existsSync(file)) return { basePort: DEFAULT_BASE_PORT, ports: {} };
49
+ let data;
50
+ try {
51
+ data = JSON.parse(readFileSync(file, "utf8"));
52
+ } catch (e) {
53
+ throw new FleetError(
54
+ `${file} exists but cannot be read (${e.message}). It was left untouched; fix or remove it.`,
55
+ );
56
+ }
57
+ const ports = data?.ports;
58
+ if (!ports || typeof ports !== "object" || Array.isArray(ports)) {
59
+ throw new FleetError(`${file} has no "ports" table. It was left untouched; fix or remove it.`);
60
+ }
61
+ for (const [id, p] of Object.entries(ports)) {
62
+ if (!Number.isInteger(p) || p < 1 || p > 65535) {
63
+ throw new FleetError(
64
+ `${file}: account ${id} has port ${JSON.stringify(p)}, which is not a port. It was left untouched.`,
65
+ );
66
+ }
67
+ }
68
+ return { basePort: Number.isInteger(data.basePort) ? data.basePort : DEFAULT_BASE_PORT, ports };
69
+ }
70
+
71
+ /**
72
+ * Give every account in `ownIds` a port, keeping the ones it already has.
73
+ *
74
+ * A new account takes the lowest port at or above the base that no account holds, so a port freed by a
75
+ * removed account is reused and an existing assignment never moves. The table is changed under a lock and
76
+ * written atomically, as the registry is.
77
+ *
78
+ * @param {string[]} ownIds
79
+ * @param {{ basePort?: number }} [options] - Only used while the table is still empty
80
+ * @returns {{ basePort: number, ports: Record<string, number>, added: string[] }}
81
+ */
82
+ export function assignPorts(ownIds, { basePort } = {}) {
83
+ mkdirSync(CONFIG_DIR, { recursive: true });
84
+ return withFileLock(`${fleetFile()}.lock`, () => {
85
+ const table = loadPortTable();
86
+ const hasAny = Object.keys(table.ports).length > 0;
87
+ const base = !hasAny && Number.isInteger(basePort) ? basePort : table.basePort;
88
+ if (base < 1 || base > 65535) throw new FleetError(`base port ${base} is not a port`);
89
+ const taken = new Set(Object.values(table.ports));
90
+ const added = [];
91
+ for (const id of [...ownIds].sort()) {
92
+ if (table.ports[id] !== undefined) continue;
93
+ let p = base;
94
+ while (taken.has(p)) p++;
95
+ if (p > 65535) throw new FleetError("no free port left above the base port");
96
+ table.ports[id] = p;
97
+ taken.add(p);
98
+ added.push(id);
99
+ }
100
+ if (added.length || !existsSync(fleetFile())) {
101
+ writeFileAtomic(fleetFile(), JSON.stringify({ version: 1, basePort: base, ports: table.ports }, null, 2), {
102
+ mode: 0o644,
103
+ });
104
+ }
105
+ return { basePort: base, ports: table.ports, added };
106
+ });
107
+ }
108
+
109
+ /** The states `fleet status` reports, worst last. */
110
+ export const STATES = Object.freeze(["ok", "degraded", "starting", "down", "unresponsive", "stopped"]);
111
+
112
+ /**
113
+ * One word for an account.
114
+ *
115
+ * - `stopped`: no daemon runs for it
116
+ * - `unresponsive`: a daemon runs (its lock is held) but does not answer on its loopback channel
117
+ * - `down`: it answers, and its Zalo socket has been down at least `downForSec`
118
+ * - `starting`: it answers, and its socket is not up yet (or has not been down long enough to count)
119
+ * - `degraded`: the socket is up but it has warnings (a webhook backlog, dead letters, open gaps)
120
+ * - `ok`
121
+ *
122
+ * @param {{ running: boolean, health: object|null }} a - `health` is null when the daemon did not answer
123
+ * @param {{ downForSec?: number }} [o]
124
+ * @returns {"ok"|"degraded"|"starting"|"down"|"unresponsive"|"stopped"}
125
+ */
126
+ export function classifyAccount({ running, health }, { downForSec = 180 } = {}) {
127
+ if (!running) return "stopped";
128
+ if (!health) return "unresponsive";
129
+ const socket = health.socket ?? {};
130
+ if (socket.state === "down" && (socket.forSec ?? 0) >= downForSec) return "down";
131
+ if (socket.state !== "up") return "starting";
132
+ return health.warnings?.length ? "degraded" : "ok";
133
+ }
134
+
135
+ /** True for the states a restart can help: the account is not serving. */
136
+ export const needsRestart = (state) => state === "down" || state === "unresponsive";
137
+
138
+ /**
139
+ * Split accounts into start-up batches: shuffled first, so accounts set up together do not log in together.
140
+ *
141
+ * @template T
142
+ * @param {T[]} items
143
+ * @param {{ size?: number, random?: () => number }} [o]
144
+ * @returns {T[][]}
145
+ */
146
+ export function planBatches(items, { size = 5, random = Math.random } = {}) {
147
+ const n = Math.max(1, Math.floor(size) || 1);
148
+ const shuffled = [...items];
149
+ for (let i = shuffled.length - 1; i > 0; i--) {
150
+ const j = Math.floor(random() * (i + 1));
151
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
152
+ }
153
+ const batches = [];
154
+ for (let i = 0; i < shuffled.length; i += n) batches.push(shuffled.slice(i, i + n));
155
+ return batches;
156
+ }
157
+
158
+ /** @param {number|null|undefined} sec */
159
+ function age(sec) {
160
+ if (sec === null || sec === undefined) return "-";
161
+ if (sec < 90) return `${sec}s`;
162
+ if (sec < 5400) return `${Math.round(sec / 60)}m`;
163
+ if (sec < 172800) return `${(sec / 3600).toFixed(1)}h`;
164
+ return `${Math.round(sec / 86400)}d`;
165
+ }
166
+
167
+ /**
168
+ * The table `fleet status` prints, one row per account.
169
+ *
170
+ * @param {Array<{ accountId: string, name?: string, port?: number|null, state: string, daemon: {running: boolean, pid: number|null}, health: object|null }>} rows
171
+ * @returns {string[]} Lines
172
+ */
173
+ export function renderStatusTable(rows) {
174
+ const header = ["ACCOUNT", "NAME", "PORT", "STATE", "PID", "SOCKET", "LAST EVENT", "WEBHOOK", "GAPS", "MEM"];
175
+ const body = rows.map((r) => {
176
+ const h = r.health;
177
+ const w = h?.webhook;
178
+ return [
179
+ r.accountId,
180
+ (r.name || "").slice(0, 20),
181
+ r.port ?? "-",
182
+ r.state,
183
+ r.daemon.running ? (r.daemon.pid ?? "?") : "-",
184
+ h ? `${h.socket.state} ${age(h.socket.forSec)}` : "-",
185
+ h ? age(h.events.lastEventAgeSec) : "-",
186
+ w ? `${w.pending}q ${w.dead}d` : h ? "off" : "-",
187
+ h ? h.coverage.pendingGaps : "-",
188
+ h ? `${Math.round(h.process.rssBytes / 1048576)}M` : "-",
189
+ ].map(String);
190
+ });
191
+ const widths = header.map((c, i) => Math.max(c.length, ...body.map((r) => r[i].length)));
192
+ const line = (cells) =>
193
+ cells
194
+ .map((c, i) => c.padEnd(widths[i]))
195
+ .join(" ")
196
+ .trimEnd();
197
+ return [line(header), ...body.map(line)];
198
+ }
199
+
200
+ /**
201
+ * Write the files that supervise a fleet: the systemd unit, env files (with a generated bearer token per
202
+ * account), the port table, and optionally a Caddyfile. An existing file is never overwritten unless `force`;
203
+ * an existing account env file keeps its token even then unless `rotateTokens`.
204
+ *
205
+ * @param {object} args
206
+ * @param {string} args.outDir
207
+ * @param {Array<{ ownId: string, port: number }>} args.accounts
208
+ * @param {{ user?: string, home?: string, bin?: string, mediaDir?: string, etcDir?: string }} [args.paths] -
209
+ * for the unit
210
+ * @param {string|null} [args.domain] - Also write a Caddyfile for this hostname
211
+ * @param {boolean} [args.force]
212
+ * @param {boolean} [args.rotateTokens]
213
+ * @param {() => string} [args.makeToken]
214
+ * @returns {{ written: string[], kept: string[] }} Paths relative to outDir. Tokens are never returned.
215
+ */
216
+ export function generateFleetFiles({
217
+ outDir,
218
+ accounts,
219
+ paths = {},
220
+ domain = null,
221
+ force = false,
222
+ rotateTokens = false,
223
+ makeToken = () => randomBytes(32).toString("hex"),
224
+ }) {
225
+ const written = [];
226
+ const kept = [];
227
+ const put = (rel, text, { secret = false, overwrite = force } = {}) => {
228
+ const file = join(outDir, rel);
229
+ if (existsSync(file) && !overwrite) {
230
+ kept.push(rel);
231
+ return false;
232
+ }
233
+ writeFileAtomic(file, text, { mode: secret ? 0o600 : 0o644 });
234
+ written.push(rel);
235
+ return true;
236
+ };
237
+
238
+ put("zalo-mcp@.service", renderUnit(paths));
239
+ put("common.env", COMMON_ENV_TEMPLATE, { secret: true });
240
+ for (const { ownId, port } of accounts) {
241
+ const rel = join("accounts", `${ownId}.env`);
242
+ const file = join(outDir, rel);
243
+ // Never replace a token that is already in use just because the file is being regenerated.
244
+ if (existsSync(file) && !rotateTokens) {
245
+ kept.push(rel);
246
+ continue;
247
+ }
248
+ put(rel, renderAccountEnv({ ownId, port, token: makeToken() }), { secret: true, overwrite: true });
249
+ }
250
+ put("ports.json", JSON.stringify(Object.fromEntries(accounts.map((a) => [a.ownId, a.port])), null, 2) + "\n", {
251
+ overwrite: true,
252
+ });
253
+ if (domain) put("Caddyfile", renderCaddyfile({ domain, accounts }));
254
+ return { written, kept };
255
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * What a daemon's log does between a line being written and it reaching `daemon.log`: a level filter,
3
+ * a timestamp (or JSON), and size-based rotation.
4
+ *
5
+ * `daemon.log` used to be everything `mcp start` / `listen` print, forever: one line per HTTP request
6
+ * (with the caller's address and user agent), one per message (with its text), no timestamps, and a file
7
+ * that only ever grew. A fleet of daemons turns that into disk pressure and a log pipeline full of noise,
8
+ * and the message lines put private text in a file whose job is to say what the daemon is doing.
9
+ *
10
+ * - Levels `error` < `warn` < `info` < `debug`, default `info`. A line is `debug` when it starts with
11
+ * {@link DEBUG_MARK}: the per-request and per-message lines. Every other line is classified by its
12
+ * wording ({@link classifyLine}), because the daemons write free text through console.error from a
13
+ * hundred places and this changes none of them.
14
+ * - Text lines get an ISO timestamp; `ZALO_LOG_FORMAT=json` writes one JSON object per line instead.
15
+ * - The file is rotated by size: `daemon.log` -> `daemon.log.1` -> ... -> `daemon.log.<keep>`, the
16
+ * oldest dropped.
17
+ *
18
+ * Settings: `ZALO_LOG_LEVEL`, `ZALO_LOG_FORMAT`, `ZALO_LOG_MAX_SIZE`, `ZALO_LOG_KEEP`, each with an
19
+ * `_<ownId>` form that wins, and `--log-level` on both daemons.
20
+ */
21
+
22
+ import fs from "node:fs";
23
+
24
+ export const LOG_LEVELS = Object.freeze(["error", "warn", "info", "debug"]);
25
+ export const LOG_FORMATS = Object.freeze(["text", "json"]);
26
+
27
+ /** A line that begins with this is a `debug` line: per request, per message. */
28
+ export const DEBUG_MARK = "[debug] ";
29
+
30
+ export const DEFAULT_LOG_LEVEL = "info";
31
+ export const DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
32
+ export const DEFAULT_KEEP = 3;
33
+ /** Below this a rotation would run every few lines. */
34
+ export const MIN_MAX_BYTES = 4 * 1024;
35
+
36
+ const ANSI = /\x1b\[[0-9;]*m/g;
37
+ const rank = (level) => LOG_LEVELS.indexOf(level);
38
+
39
+ /**
40
+ * The level of one log line, by its wording. Not exact, and not meant to be: it decides which lines a
41
+ * `warn` or `error` setting keeps, and a missed one is one the daemon wrote as plain text.
42
+ *
43
+ * @param {string} line
44
+ * @returns {"error"|"warn"|"info"|"debug"}
45
+ */
46
+ export function classifyLine(line) {
47
+ if (line.startsWith(DEBUG_MARK)) return "debug";
48
+ const text = line.replace(ANSI, "");
49
+ if (/WARNING|⚠|\bwarn(ing)?\b/i.test(text)) return "warn";
50
+ if (
51
+ /\b(fail(ed|ure|s)?|error|exception|fatal|refus(ed|ing)|cannot|could not|unable|exited|crash(ed)?)\b|✗|✖|ECONN|EADDR|EPERM|EACCES/i.test(
52
+ text,
53
+ )
54
+ ) {
55
+ return "error";
56
+ }
57
+ return "info";
58
+ }
59
+
60
+ /**
61
+ * Parse `20m`, `500k`, `1g` or a plain byte count.
62
+ * @param {string|number|null|undefined} value
63
+ * @returns {number|null} Bytes, or null when it is not a size
64
+ */
65
+ export function parseSize(value) {
66
+ const m = /^\s*(\d+(?:\.\d+)?)\s*([kmg]?)b?\s*$/i.exec(String(value ?? ""));
67
+ if (!m) return null;
68
+ const mult = { "": 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3 }[m[2].toLowerCase()];
69
+ return Math.floor(Number(m[1]) * mult);
70
+ }
71
+
72
+ /**
73
+ * The logging settings of one daemon: flag, then `NAME_<ownId>`, then `NAME`, then the defaults. A value
74
+ * that cannot be used is reported and replaced by the default, so a typo is not mistaken for a setting.
75
+ *
76
+ * @param {object} args
77
+ * @param {NodeJS.ProcessEnv} [args.env]
78
+ * @param {string|null} [args.ownId]
79
+ * @param {string|null} [args.levelFlag] - `--log-level`
80
+ * @returns {{ level: string, format: string, maxBytes: number, keep: number, warnings: string[], explicit: boolean }}
81
+ * `explicit` is true when anything was asked for, so a foreground run knows to filter its stderr at all
82
+ */
83
+ export function resolveLogSettings({ env = process.env, ownId = null, levelFlag = null } = {}) {
84
+ const warnings = [];
85
+ const pick = (name) => {
86
+ const scoped = ownId ? env[`${name}_${ownId}`] : undefined;
87
+ const v = typeof scoped === "string" && scoped.trim() ? scoped : env[name];
88
+ return typeof v === "string" && v.trim() ? v.trim() : null;
89
+ };
90
+
91
+ const rawLevel = levelFlag?.trim() || pick("ZALO_LOG_LEVEL");
92
+ let level = DEFAULT_LOG_LEVEL;
93
+ if (rawLevel) {
94
+ if (LOG_LEVELS.includes(rawLevel.toLowerCase())) level = rawLevel.toLowerCase();
95
+ else
96
+ warnings.push(`log level "${rawLevel}" is not one of ${LOG_LEVELS.join(", ")}; using ${DEFAULT_LOG_LEVEL}`);
97
+ }
98
+
99
+ const rawFormat = pick("ZALO_LOG_FORMAT");
100
+ let format = "text";
101
+ if (rawFormat) {
102
+ if (LOG_FORMATS.includes(rawFormat.toLowerCase())) format = rawFormat.toLowerCase();
103
+ else warnings.push(`log format "${rawFormat}" is not one of ${LOG_FORMATS.join(", ")}; using text`);
104
+ }
105
+
106
+ const rawSize = pick("ZALO_LOG_MAX_SIZE");
107
+ let maxBytes = DEFAULT_MAX_BYTES;
108
+ if (rawSize) {
109
+ const n = parseSize(rawSize);
110
+ if (n !== null && n >= MIN_MAX_BYTES) maxBytes = n;
111
+ else warnings.push(`log max size "${rawSize}" is not a size of at least 4k (e.g. 20m, 1g); using 50m`);
112
+ }
113
+
114
+ const rawKeep = pick("ZALO_LOG_KEEP");
115
+ let keep = DEFAULT_KEEP;
116
+ if (rawKeep) {
117
+ const n = Number(rawKeep);
118
+ if (Number.isInteger(n) && n >= 0 && n <= 50) keep = n;
119
+ else warnings.push(`log keep "${rawKeep}" is not a whole number from 0 to 50; using ${DEFAULT_KEEP}`);
120
+ }
121
+
122
+ const explicit = Boolean(levelFlag || pick("ZALO_LOG_LEVEL") || pick("ZALO_LOG_FORMAT"));
123
+ return { level, format, maxBytes, keep, warnings, explicit };
124
+ }
125
+
126
+ /**
127
+ * One log line, as it is written.
128
+ *
129
+ * @param {object} args
130
+ * @param {string} args.line - Without its newline
131
+ * @param {string} args.level - The line's level
132
+ * @param {"text"|"json"} args.format
133
+ * @param {Date} args.at
134
+ * @param {string|null} [args.accountId]
135
+ * @returns {string} Without a newline
136
+ */
137
+ export function formatLine({ line, level, format, at, accountId = null }) {
138
+ if (format === "json") {
139
+ const plain = line.replace(ANSI, "");
140
+ const msg = plain.startsWith(DEBUG_MARK) ? plain.slice(DEBUG_MARK.length) : plain;
141
+ return JSON.stringify({
142
+ ts: at.toISOString(),
143
+ level,
144
+ ...(accountId ? { account: accountId } : {}),
145
+ msg: msg.trimEnd(),
146
+ });
147
+ }
148
+ return `${at.toISOString()} ${line}`;
149
+ }
150
+
151
+ /**
152
+ * A log file that rotates by size.
153
+ *
154
+ * @param {object} args
155
+ * @param {string} args.file
156
+ * @param {number} args.maxBytes
157
+ * @param {number} args.keep - How many rotated files to keep (0: just start over)
158
+ * @param {typeof fs} [args.fsImpl] - Test seam
159
+ * @param {() => Date} [args.now]
160
+ * @returns {{ write: (text: string) => void, rotate: () => boolean, close: () => void, size: () => number }}
161
+ */
162
+ export function createRotatingFile({ file, maxBytes, keep, fsImpl = fs, now = () => new Date() }) {
163
+ let fd = null;
164
+ let size = 0;
165
+ let rotateAt = maxBytes;
166
+
167
+ const open = () => {
168
+ fd = fsImpl.openSync(file, "a", 0o600);
169
+ try {
170
+ size = fsImpl.fstatSync(fd).size;
171
+ } catch {
172
+ size = 0;
173
+ }
174
+ };
175
+
176
+ function rotate() {
177
+ try {
178
+ if (fd !== null) fsImpl.closeSync(fd);
179
+ } catch {
180
+ /* closing a handle that is already gone */
181
+ }
182
+ fd = null;
183
+ try {
184
+ if (keep > 0) {
185
+ for (let i = keep - 1; i >= 1; i--) {
186
+ try {
187
+ fsImpl.renameSync(`${file}.${i}`, `${file}.${i + 1}`);
188
+ } catch (e) {
189
+ if (e.code !== "ENOENT") throw e;
190
+ }
191
+ }
192
+ fsImpl.renameSync(file, `${file}.1`);
193
+ } else {
194
+ fsImpl.unlinkSync(file);
195
+ }
196
+ open();
197
+ rotateAt = maxBytes;
198
+ const header = `--- [${now().toISOString()}] log rotated: the previous ${keep > 0 ? "file is daemon.log.1" : "log was discarded"} ---\n`;
199
+ fsImpl.writeSync(fd, header);
200
+ size += Buffer.byteLength(header);
201
+ return true;
202
+ } catch {
203
+ // Windows refuses to rename a file another process (a tail, an editor) has open. Carry on
204
+ // appending to the same file and look again after another megabyte.
205
+ try {
206
+ open();
207
+ } catch {
208
+ fd = null;
209
+ }
210
+ rotateAt = size + 1024 * 1024;
211
+ return false;
212
+ }
213
+ }
214
+
215
+ return {
216
+ write(text) {
217
+ try {
218
+ if (fd === null) open();
219
+ const buf = Buffer.from(text, "utf8");
220
+ fsImpl.writeSync(fd, buf);
221
+ size += buf.length;
222
+ if (maxBytes > 0 && size >= rotateAt) rotate();
223
+ } catch {
224
+ /* logging must never take the daemon down */
225
+ }
226
+ },
227
+ rotate,
228
+ close() {
229
+ try {
230
+ if (fd !== null) fsImpl.closeSync(fd);
231
+ } catch {
232
+ /* already closed */
233
+ }
234
+ fd = null;
235
+ },
236
+ size: () => size,
237
+ };
238
+ }
239
+
240
+ /**
241
+ * The line-oriented filter in front of a file (or any `write(text)`).
242
+ *
243
+ * Chunks are not lines: a write can hold three lines or half of one. Complete lines are classified,
244
+ * filtered and formatted; a partial line waits for its newline, and is flushed after a moment so it never
245
+ * sits unwritten. A continuation (an indented line, such as a stack frame) takes the level of the line
246
+ * before it, so an error's stack is kept or dropped with it.
247
+ *
248
+ * @param {object} args
249
+ * @param {(text: string) => void} args.write
250
+ * @param {string} [args.level]
251
+ * @param {"text"|"json"} [args.format]
252
+ * @param {string|null} [args.accountId]
253
+ * @param {() => Date} [args.now]
254
+ * @param {number} [args.flushMs]
255
+ * @returns {{ write: (chunk: string|Buffer) => void, flush: () => void, setLevel: (l: string) => void, setFormat: (f: string) => void, level: () => string }}
256
+ */
257
+ export function createLogSink({
258
+ write,
259
+ level = DEFAULT_LOG_LEVEL,
260
+ format = "text",
261
+ accountId = null,
262
+ now = () => new Date(),
263
+ flushMs = 250,
264
+ }) {
265
+ let threshold = rank(level) >= 0 ? level : DEFAULT_LOG_LEVEL;
266
+ let fmt = format;
267
+ let partial = "";
268
+ let lastLevel = "info";
269
+ let timer = null;
270
+
271
+ const emit = (line) => {
272
+ const continuation = /^\s+\S/.test(line) && !line.startsWith(DEBUG_MARK);
273
+ const lineLevel = continuation ? lastLevel : classifyLine(line);
274
+ lastLevel = lineLevel;
275
+ if (rank(lineLevel) > rank(threshold)) return;
276
+ write(`${formatLine({ line, level: lineLevel, format: fmt, at: now(), accountId })}\n`);
277
+ };
278
+
279
+ const flush = () => {
280
+ if (timer) clearTimeout(timer);
281
+ timer = null;
282
+ if (partial) {
283
+ const p = partial;
284
+ partial = "";
285
+ emit(p);
286
+ }
287
+ };
288
+
289
+ return {
290
+ write(chunk) {
291
+ const text = partial + (Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk));
292
+ const lines = text.split(/\r?\n/);
293
+ partial = lines.pop() ?? "";
294
+ for (const line of lines) if (line.length) emit(line);
295
+ if (timer) clearTimeout(timer);
296
+ timer = null;
297
+ if (partial) {
298
+ timer = setTimeout(flush, flushMs);
299
+ timer.unref?.();
300
+ }
301
+ },
302
+ flush,
303
+ setLevel(l) {
304
+ if (rank(l) >= 0) threshold = l;
305
+ },
306
+ setFormat(f) {
307
+ if (LOG_FORMATS.includes(f)) fmt = f;
308
+ },
309
+ level: () => threshold,
310
+ };
311
+ }
@@ -7,6 +7,7 @@
7
7
  import fs from "node:fs";
8
8
  import { join, resolve } from "node:path";
9
9
  import { homedir } from "node:os";
10
+ import { withFileLock, writeFileAtomic } from "../utils/atomic-file.js";
10
11
 
11
12
  const V3_BASE = "https://openapi.zalo.me/v3.0/oa";
12
13
  const V2_BASE = "https://openapi.zalo.me/v2.0/oa";
@@ -36,12 +37,15 @@ const TOKEN_URL = "https://oauth.zaloapp.com/v4/oa/access_token";
36
37
  export function saveOACreds(data, oaId = "default") {
37
38
  ensureDir();
38
39
  const credsFile = getOACredsFile();
39
- let creds = {};
40
- if (fs.existsSync(credsFile)) {
41
- creds = JSON.parse(fs.readFileSync(credsFile, "utf8"));
42
- }
43
- creds[oaId] = { ...creds[oaId], ...data, updatedAt: new Date().toISOString() };
44
- fs.writeFileSync(credsFile, JSON.stringify(creds, null, 2), { mode: 0o600 });
40
+ // One file holds every OA: read-modify-write under a lock, replaced atomically.
41
+ withFileLock(`${credsFile}.lock`, () => {
42
+ let creds = {};
43
+ if (fs.existsSync(credsFile)) {
44
+ creds = JSON.parse(fs.readFileSync(credsFile, "utf8"));
45
+ }
46
+ creds[oaId] = { ...creds[oaId], ...data, updatedAt: new Date().toISOString() };
47
+ writeFileAtomic(credsFile, JSON.stringify(creds, null, 2));
48
+ });
45
49
  }
46
50
 
47
51
  /** Save OA access token to disk (backward compat). */