@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.
- package/.env.example +23 -1
- 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/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/webhook-outbox.js +575 -0
- 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,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
|
+
//  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
|
+
}
|