@ardennguyen/zalo-agent-cli 2.5.4 → 2.5.6

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,209 @@
1
+ /**
2
+ * Two small guarantees for the files that more than one process shares or that must never be half
3
+ * written: an atomic replace, and a cross-process lock for read-modify-write.
4
+ *
5
+ * `accounts.json` is written by every login, `account` command and name refresh, and a fleet of
6
+ * daemons refreshes names concurrently. It used to be read, changed in memory and written back with a
7
+ * plain `writeFileSync`: two writers silently lost each other's change, a reader could see a file
8
+ * the writer had truncated and not yet filled (and took it for "no accounts"), and a crash mid-write
9
+ * left JSON that the next `login` replaced with a one-account registry. Credentials had the same
10
+ * truncate-then-write window, plus a moment at default permissions before `chmod`.
11
+ *
12
+ * - {@link writeFileAtomic}: write a temp file beside the target, flush it, rename it over the
13
+ * target. A reader sees the old file or the new one, never a mix; a crash leaves the old one.
14
+ * - {@link withFileLock}: `O_EXCL` lock file with the holder's pid; a lock whose holder died, or that
15
+ * is older than any real critical section, is taken over.
16
+ *
17
+ * Both are synchronous because their callers are (the registry API is sync throughout).
18
+ */
19
+
20
+ import {
21
+ closeSync,
22
+ fsyncSync,
23
+ mkdirSync,
24
+ openSync,
25
+ readdirSync,
26
+ readFileSync,
27
+ renameSync,
28
+ statSync,
29
+ unlinkSync,
30
+ writeSync,
31
+ } from "node:fs";
32
+ import { randomBytes } from "node:crypto";
33
+ import { basename, dirname, join } from "node:path";
34
+
35
+ /** Errors a rename can raise on Windows while another process has the target open for reading. */
36
+ const TRANSIENT = new Set(["EPERM", "EBUSY", "EACCES"]);
37
+
38
+ /**
39
+ * Block this thread for `ms` without spinning.
40
+ * @param {number} ms
41
+ */
42
+ export function sleepSync(ms) {
43
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
44
+ }
45
+
46
+ /**
47
+ * Replace `file` with `data` so no reader and no crash ever sees a partial write.
48
+ *
49
+ * The temp file is created exclusively with `mode` (so a secret is never readable by others, not
50
+ * even briefly), flushed to disk, then renamed over the target. On Windows a rename can fail while a
51
+ * reader has the target open, so it is retried for a second or two (a reader holds the file for
52
+ * microseconds, but a tight loop of them can keep one in the way for a while).
53
+ *
54
+ * @param {string} file
55
+ * @param {string|Buffer} data
56
+ * @param {{ mode?: number, durable?: boolean }} [options] - `mode`: file mode of the result (default 0o600).
57
+ * `durable: false` skips the flush to disk for a copy that is only a convenience (the registry's `.bak`)
58
+ */
59
+ export function writeFileAtomic(file, data, { mode = 0o600, durable = true } = {}) {
60
+ mkdirSync(dirname(file), { recursive: true });
61
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
62
+ const fd = openSync(tmp, "wx", mode);
63
+ try {
64
+ writeSync(fd, data);
65
+ if (durable) fsyncSync(fd);
66
+ } catch (e) {
67
+ closeSync(fd);
68
+ try {
69
+ unlinkSync(tmp);
70
+ } catch {
71
+ /* nothing to clean */
72
+ }
73
+ throw e;
74
+ }
75
+ closeSync(fd);
76
+
77
+ for (let attempt = 0; ; attempt++) {
78
+ try {
79
+ renameSync(tmp, file);
80
+ return;
81
+ } catch (e) {
82
+ if (!TRANSIENT.has(e.code) || attempt >= 60) {
83
+ try {
84
+ unlinkSync(tmp);
85
+ } catch {
86
+ /* already gone */
87
+ }
88
+ throw e;
89
+ }
90
+ sleepSync(10 + Math.floor(Math.random() * 40));
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Delete the temp files that `writeFileAtomic(file, ...)` left when its process was killed between
97
+ * creating the temp file and renaming it. Only a temp file whose pid is dead is removed.
98
+ *
99
+ * @param {string} file - The target the temp files belong to
100
+ * @returns {number} How many were removed
101
+ */
102
+ export function sweepStaleTemps(file) {
103
+ const prefix = `${basename(file)}.`;
104
+ let removed = 0;
105
+ try {
106
+ for (const name of readdirSync(dirname(file))) {
107
+ const m = name.startsWith(prefix) && /^(\d+)\.[0-9a-f]{8}\.tmp$/.exec(name.slice(prefix.length));
108
+ if (!m || alive(Number(m[1]))) continue;
109
+ try {
110
+ unlinkSync(join(dirname(file), name));
111
+ removed++;
112
+ } catch {
113
+ /* gone already */
114
+ }
115
+ }
116
+ } catch {
117
+ /* directory unreadable: nothing to sweep */
118
+ }
119
+ return removed;
120
+ }
121
+
122
+ /** @param {number} pid */
123
+ function alive(pid) {
124
+ try {
125
+ process.kill(pid, 0);
126
+ return true;
127
+ } catch (e) {
128
+ // EPERM: it exists, owned by someone else.
129
+ return e.code === "EPERM";
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Run `fn` while holding an exclusive lock on `lockPath`.
135
+ *
136
+ * A held lock is waited for, up to `timeoutMs`. It is taken over when its holder is dead or when it
137
+ * is older than `staleMs` (a read-modify-write of a small file takes milliseconds, so anything older
138
+ * is a crashed holder, or a pid the OS reused). The lock is released when `fn` returns or throws.
139
+ *
140
+ * @template T
141
+ * @param {string} lockPath
142
+ * @param {() => T} fn
143
+ * @param {{ timeoutMs?: number, staleMs?: number }} [options]
144
+ * @returns {T}
145
+ */
146
+ export function withFileLock(lockPath, fn, { timeoutMs = 10_000, staleMs = 30_000 } = {}) {
147
+ mkdirSync(dirname(lockPath), { recursive: true });
148
+ const started = Date.now();
149
+ const mine = JSON.stringify({ pid: process.pid, at: Date.now() });
150
+
151
+ for (;;) {
152
+ try {
153
+ const fd = openSync(lockPath, "wx", 0o600);
154
+ try {
155
+ writeSync(fd, mine);
156
+ } finally {
157
+ closeSync(fd);
158
+ }
159
+ break;
160
+ } catch (e) {
161
+ if (e.code !== "EEXIST" && !(process.platform === "win32" && TRANSIENT.has(e.code))) throw e;
162
+ }
163
+
164
+ // Held. Is the holder gone?
165
+ let seen = null;
166
+ let holder = null;
167
+ try {
168
+ seen = readFileSync(lockPath, "utf8");
169
+ holder = JSON.parse(seen)?.pid ?? null;
170
+ } catch {
171
+ /* released meanwhile, or torn: judged by age below */
172
+ }
173
+ let age = 0;
174
+ try {
175
+ age = Date.now() - statSync(lockPath).mtimeMs;
176
+ } catch {
177
+ continue; // released between the two looks: try to take it
178
+ }
179
+ if ((holder !== null && !alive(holder)) || age > staleMs) {
180
+ try {
181
+ // Only remove what was judged stale: a lock freed and retaken meanwhile is a live one.
182
+ if (readFileSync(lockPath, "utf8") === seen) unlinkSync(lockPath);
183
+ } catch {
184
+ /* someone else got there first */
185
+ }
186
+ continue;
187
+ }
188
+
189
+ if (Date.now() - started > timeoutMs) {
190
+ throw new Error(
191
+ `could not lock ${lockPath} within ${Math.round(timeoutMs / 1000)}s: held by pid ${holder ?? "unknown"}. ` +
192
+ "If that process is gone, delete the lock file.",
193
+ );
194
+ }
195
+ // Short polls: a critical section is a few milliseconds, so a long wait leaves the lock idle.
196
+ sleepSync(2 + Math.floor(Math.random() * 8));
197
+ }
198
+
199
+ try {
200
+ return fn();
201
+ } finally {
202
+ try {
203
+ // Never remove a lock another process took over while this one was stuck.
204
+ if (readFileSync(lockPath, "utf8") === mine) unlinkSync(lockPath);
205
+ } catch {
206
+ /* already gone */
207
+ }
208
+ }
209
+ }
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Markdown to Zalo rich text.
3
+ *
4
+ * Zalo shows `**bold**` and `- item` as typed: a message is plain text plus a list of style ranges
5
+ * (`{ start, len, st }`, offsets in UTF-16 code units, the same unit mentions use), and the apps draw bold,
6
+ * bullets and sizes only from those ranges. An AI agent writes Markdown, so its replies arrived with the
7
+ * asterisks and hyphens still in them. This turns the Markdown into the text Zalo should show and the
8
+ * ranges that make it look right.
9
+ *
10
+ * What Zalo's own clients send, measured on real rich-text messages in a local cache, decides the mapping:
11
+ *
12
+ * - a bullet is the line's text with NO marker, covered whole by `lst_1` (start at the line start, length
13
+ * of the line without its newline);
14
+ * - a heading is a bold line (the numbers in "1. Heading" are literal text);
15
+ * - bold inside a list item is a second, overlapping range.
16
+ *
17
+ * Supported: `**bold**` / `__bold__`, `*italic*` / `_italic_`, `***both***`, `~~strike~~`, `# headings`
18
+ * (bold; `#` and `##` also big), `- * +` bullet lists with nesting, ordered lists (kept as typed, numbers
19
+ * included: Zalo's own clients type them), `> quotes` (marker dropped), `` `code` `` and fenced blocks
20
+ * (markers dropped, content kept as is), `[text](url)` (becomes `text (url)`), images, `---` rules (dropped),
21
+ * backslash escapes, and Zalo's own `{red:text}` colors. Tables and anything else are left as typed.
22
+ *
23
+ * It is deliberately conservative where Markdown is ambiguous, because most of what is sent is not
24
+ * Markdown at all: `snake_case_names`, `2 * 3 * 4`, a lone `*`, an unclosed `**` and a `5 > 3` stay exactly
25
+ * as written.
26
+ */
27
+
28
+ export const STYLE = Object.freeze({
29
+ bold: "b",
30
+ italic: "i",
31
+ underline: "u",
32
+ strike: "s",
33
+ red: "c_db342e",
34
+ orange: "c_f27806",
35
+ yellow: "c_f7b503",
36
+ green: "c_15a85f",
37
+ small: "f_13",
38
+ big: "f_18",
39
+ list: "lst_1",
40
+ indent: "ind_$",
41
+ });
42
+
43
+ const COLOR_ST = {
44
+ red: STYLE.red,
45
+ orange: STYLE.orange,
46
+ yellow: STYLE.yellow,
47
+ green: STYLE.green,
48
+ big: STYLE.big,
49
+ small: STYLE.small,
50
+ };
51
+
52
+ const isWord = (c) => c !== undefined && /[\p{L}\p{N}]/u.test(c);
53
+ const isSpace = (c) => c === undefined || /\s/.test(c);
54
+
55
+ /**
56
+ * One line (or paragraph run) of inline Markdown.
57
+ *
58
+ * @param {string} src
59
+ * @returns {{ text: string, styles: Array<{ start: number, len: number, st: string }> }} offsets relative to `text`
60
+ */
61
+ export function convertInline(src) {
62
+ let out = "";
63
+ const styles = [];
64
+ let i = 0;
65
+
66
+ /** Append already-converted text, shifting the styles that came with it. */
67
+ const append = (piece) => {
68
+ for (const s of piece.styles) styles.push({ ...s, start: s.start + out.length });
69
+ out += piece.text;
70
+ };
71
+ const wrap = (inner, st) => {
72
+ const piece = convertInline(inner);
73
+ const start = out.length;
74
+ append(piece);
75
+ if (piece.text.length) styles.push({ start, len: piece.text.length, st });
76
+ };
77
+
78
+ while (i < src.length) {
79
+ const c = src[i];
80
+ const rest = src.slice(i);
81
+
82
+ // An @[uid] mention token passes through whole: its brackets are not a link, whatever follows them.
83
+ if (c === "@") {
84
+ const token = /^@\[-?\d+\]/.exec(rest);
85
+ if (token) {
86
+ out += token[0];
87
+ i += token[0].length;
88
+ continue;
89
+ }
90
+ }
91
+
92
+ // Backslash escape: the next character is literal.
93
+ if (c === "\\" && i + 1 < src.length && /[\\`*_{}[\]()#+\-.!~|>]/.test(src[i + 1])) {
94
+ out += src[i + 1];
95
+ i += 2;
96
+ continue;
97
+ }
98
+
99
+ // `code`: the content is literal, the backticks go.
100
+ if (c === "`") {
101
+ const run = /^`+/.exec(rest)[0];
102
+ const close = src.indexOf(run, i + run.length);
103
+ if (close > i + run.length) {
104
+ out += src.slice(i + run.length, close).replace(/^ (.*) $/, "$1");
105
+ i = close + run.length;
106
+ continue;
107
+ }
108
+ out += run;
109
+ i += run.length;
110
+ continue;
111
+ }
112
+
113
+ // ![alt](url) and [text](url): Zalo links a bare URL by itself, so the URL stays in the text.
114
+ if (c === "!" || c === "[") {
115
+ const m = /^(!?)\[([^\]]*)\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/.exec(rest);
116
+ if (m) {
117
+ const label = convertInline(m[2]);
118
+ const url = m[3];
119
+ if (!label.text || label.text === url) out += url;
120
+ else {
121
+ append(label);
122
+ out += ` (${url})`;
123
+ }
124
+ i += m[0].length;
125
+ continue;
126
+ }
127
+ }
128
+
129
+ // <https://example.com>
130
+ if (c === "<") {
131
+ const m = /^<((?:https?|mailto):[^>\s]+)>/.exec(rest);
132
+ if (m) {
133
+ out += m[1];
134
+ i += m[0].length;
135
+ continue;
136
+ }
137
+ }
138
+
139
+ // {red:text}, {big:text} ...: Zalo's own extras, kept from `msg send --md`.
140
+ if (c === "{") {
141
+ const m = /^\{(red|orange|yellow|green|big|small):([^}]+)\}/.exec(rest);
142
+ if (m) {
143
+ wrap(m[2], COLOR_ST[m[1]]);
144
+ i += m[0].length;
145
+ continue;
146
+ }
147
+ }
148
+
149
+ // Emphasis: *** ** * __ _ ~~
150
+ if (c === "*" || c === "_" || c === "~") {
151
+ const run = /^(\*{1,3}|_{1,2}|~~)/.exec(rest)?.[0];
152
+ if (run) {
153
+ const before = src[i - 1];
154
+ const after = src[i + run.length];
155
+ // An opener has text hugging it on the inside; `_` also must not be inside a word.
156
+ const opens = !isSpace(after) && !(c === "_" && isWord(before)) && !(c === "~" && run.length !== 2);
157
+ if (opens) {
158
+ const close = findCloser(src, i + run.length, run);
159
+ if (close !== -1) {
160
+ const inner = src.slice(i + run.length, close);
161
+ if (run === "***") {
162
+ const start = out.length;
163
+ const piece = convertInline(inner);
164
+ append(piece);
165
+ if (piece.text.length) {
166
+ styles.push({ start, len: piece.text.length, st: STYLE.bold });
167
+ styles.push({ start, len: piece.text.length, st: STYLE.italic });
168
+ }
169
+ } else if (run === "**" || run === "__") wrap(inner, STYLE.bold);
170
+ else if (run === "~~") wrap(inner, STYLE.strike);
171
+ else wrap(inner, STYLE.italic);
172
+ i = close + run.length;
173
+ continue;
174
+ }
175
+ }
176
+ out += run;
177
+ i += run.length;
178
+ continue;
179
+ }
180
+ }
181
+
182
+ out += c;
183
+ i++;
184
+ }
185
+ return { text: out, styles };
186
+ }
187
+
188
+ /**
189
+ * The index of the delimiter `run` that closes an emphasis opened just before `from`, or -1.
190
+ * A closer has text hugging it on the inside, `_` must not be inside a word, and a code span or a longer
191
+ * run of the same character is skipped.
192
+ */
193
+ function findCloser(src, from, run) {
194
+ const ch = run[0];
195
+ for (let j = from; j < src.length; j++) {
196
+ if (src[j] === "\\") {
197
+ j++;
198
+ continue;
199
+ }
200
+ if (src[j] === "`") {
201
+ const ticks = /^`+/.exec(src.slice(j))[0];
202
+ const end = src.indexOf(ticks, j + ticks.length);
203
+ if (end !== -1) j = end + ticks.length - 1;
204
+ continue;
205
+ }
206
+ if (src[j] !== ch) continue;
207
+ const len = /^[*_~]+/.exec(src.slice(j))[0].length;
208
+ if (len === run.length && !isSpace(src[j - 1]) && !(ch === "_" && isWord(src[j + len]))) return j;
209
+ // `**bold *and italic* still bold**`: a nested shorter run opens and closes inside; step over it.
210
+ j += len - 1;
211
+ }
212
+ return -1;
213
+ }
214
+
215
+ const FENCE = /^\s*(```+|~~~+)/;
216
+ const RULE = /^\s*([-*_])(?:\s*\1){2,}\s*$/;
217
+ const HEADING = /^(#{1,6})\s+(.*?)(?:\s+#+)?\s*$/;
218
+ const BULLET = /^(\s*)[-*+]\s+(.*)$/;
219
+ const ORDERED = /^(\s*)(\d{1,9})([.)])\s+(.*)$/;
220
+ const QUOTE = /^\s{0,3}>\s?(.*)$/;
221
+
222
+ /**
223
+ * Markdown to the text Zalo should show and the style ranges that dress it.
224
+ *
225
+ * @param {string} markdown
226
+ * @returns {{ text: string, styles: Array<{ start: number, len: number, st: string, indentSize?: number }> }}
227
+ */
228
+ export function markdownToZalo(markdown) {
229
+ const lines = String(markdown ?? "")
230
+ .replace(/\r\n?/g, "\n")
231
+ .split("\n");
232
+ let text = "";
233
+ const styles = [];
234
+ let fenced = false;
235
+ let wroteLine = false;
236
+
237
+ const emit = (line, lineStyles = [], whole = []) => {
238
+ if (wroteLine) text += "\n";
239
+ wroteLine = true;
240
+ const start = text.length;
241
+ for (const s of lineStyles) styles.push({ ...s, start: s.start + start });
242
+ if (line.length)
243
+ for (const st of whole)
244
+ styles.push(
245
+ typeof st === "string" ? { start, len: line.length, st } : { start, len: line.length, ...st },
246
+ );
247
+ text += line;
248
+ };
249
+
250
+ for (const raw of lines) {
251
+ if (FENCE.test(raw)) {
252
+ fenced = !fenced;
253
+ continue; // the fence line itself is not shown
254
+ }
255
+ if (fenced) {
256
+ emit(raw);
257
+ continue;
258
+ }
259
+ if (RULE.test(raw)) continue;
260
+
261
+ let m = HEADING.exec(raw);
262
+ if (m) {
263
+ const inline = convertInline(m[2]);
264
+ emit(inline.text, inline.styles, m[1].length <= 2 ? [STYLE.big, STYLE.bold] : [STYLE.bold]);
265
+ continue;
266
+ }
267
+ m = BULLET.exec(raw);
268
+ if (m) {
269
+ const inline = convertInline(m[2]);
270
+ const depth = Math.floor(m[1].replaceAll("\t", " ").length / 2);
271
+ emit(inline.text, inline.styles, [
272
+ STYLE.list,
273
+ ...(depth > 0 ? [{ st: STYLE.indent, indentSize: Math.min(depth, 4) }] : []),
274
+ ]);
275
+ continue;
276
+ }
277
+ m = ORDERED.exec(raw);
278
+ if (m) {
279
+ const inline = convertInline(m[4]);
280
+ const label = `${m[1]}${m[2]}${m[3]} `;
281
+ emit(
282
+ label + inline.text,
283
+ inline.styles.map((s) => ({ ...s, start: s.start + label.length })),
284
+ );
285
+ continue;
286
+ }
287
+ m = QUOTE.exec(raw);
288
+ if (m) {
289
+ let body = m[1];
290
+ let nested;
291
+ while ((nested = QUOTE.exec(body))) body = nested[1];
292
+ const inline = convertInline(body);
293
+ emit(inline.text, inline.styles);
294
+ continue;
295
+ }
296
+ const inline = convertInline(raw);
297
+ emit(inline.text, inline.styles);
298
+ }
299
+ return { text, styles: dropEmpty(styles) };
300
+ }
301
+
302
+ /** A range over nothing is not a style. */
303
+ function dropEmpty(styles) {
304
+ return styles.filter((s) => s.len > 0);
305
+ }
306
+
307
+ /**
308
+ * Whether `text` looks like it was written as Markdown, so a caller can tell a plain message from a formatted one.
309
+ * @param {string} text
310
+ * @returns {boolean}
311
+ */
312
+ export function looksLikeMarkdown(text) {
313
+ const t = String(text ?? "");
314
+ return (
315
+ /(^|\n)\s*(#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|```)/.test(t) ||
316
+ /\*\*\S|__\S|~~\S|`[^`\n]+`|\[[^\]]+\]\([^)]+\)/.test(t)
317
+ );
318
+ }
319
+
320
+ /**
321
+ * How a text message is sent: as Markdown to be converted ("markdown") or exactly as typed ("plain").
322
+ *
323
+ * The MCP tool defaults to Markdown, because what calls it is a model and models write Markdown; the CLI
324
+ * defaults to plain, because what types into it is a person. `ZALO_SEND_FORMAT` changes either default, and
325
+ * an explicit choice (the tool's `format`, the CLI's `--markdown`) wins over it.
326
+ *
327
+ * @param {object} args
328
+ * @param {"markdown"|"plain"|null|undefined} [args.explicit]
329
+ * @param {"markdown"|"plain"} args.fallback - The surface's own default
330
+ * @param {NodeJS.ProcessEnv} [args.env]
331
+ * @returns {"markdown"|"plain"}
332
+ */
333
+ export function resolveSendFormat({ explicit, fallback, env = process.env }) {
334
+ if (explicit === "markdown" || explicit === "plain") return explicit;
335
+ const fromEnv = String(env.ZALO_SEND_FORMAT ?? "")
336
+ .trim()
337
+ .toLowerCase();
338
+ return fromEnv === "markdown" || fromEnv === "plain" ? fromEnv : fallback;
339
+ }