@mrrisega/dsh-remote 0.7.11-beta.3 → 0.7.12-beta.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.
@@ -3107,7 +3107,13 @@ function startImBots() {
3107
3107
  // 订阅器要连的本机 dsh web:传取值器,宿主换端口时下一次重连能跟上
3108
3108
  botOptions: {
3109
3109
  upstream: () => currentUpstreamForChannel(),
3110
- cookie: harnessCookieOf
3110
+ cookie: harnessCookieOf,
3111
+ // 会员判定(2026-11-07 业主拍板:通知免费 / 审批免费 / 互动半阉割):
3112
+ // 直接复用微信通道那份档位解析(/api/me → 服务端权益包,含 caps/limits/rev),
3113
+ // 拿不到就由 wecom-bot 按 free fail-closed 处理。
3114
+ capsProvider: resolveWechatTier,
3115
+ // 免费用户越界时的转化入口(与微信通道同一个 App 地址;wecom-bot 拼 /app/promo)
3116
+ appUrl: wechatAppUrl()
3111
3117
  },
3112
3118
  // 企微审批卡片点按钮 → 与手机拍板同一条路(协调器 → DSH 回执);失败返回 {ok:false} 让卡片显示"没送达"
3113
3119
  onApprovalDecision: (requestId, decision) => applyApprovalDecision(requestId, decision),
@@ -0,0 +1,372 @@
1
+ /**
2
+ * im-format — **通道感知的消息格式化**(2026-11-08 业主反馈「企微/飞书推回来的表格都是乱的」)。
3
+ *
4
+ * 背景
5
+ * -----------------------------------------------------------------
6
+ * agent 的最终回答常带 Markdown 表格(`| a | b |`)、标题、引用、任务列表…… 而企业微信 / 钉钉的
7
+ * markdown **不支持表格**:原样发出去就是一堆竖线,用户看到的就是「乱」。不同 IM 通道的能力也不同
8
+ * (企微/钉钉支持 markdown 子集;飞书 markdown 能力弱、卡片强;个人微信机器人有自己的一套特殊格式),
9
+ * 所以"发之前按通道能力适配一次"应该是一个**独立的、可单测的纯函数模块**,而不是散在 wecom-bot 里。
10
+ *
11
+ * 用法
12
+ * -----------------------------------------------------------------
13
+ * ```js
14
+ * import { formatForChannel, CHANNEL_CAPS } from "./im-format.mjs";
15
+ * const text = formatForChannel("wecom", { title: "", text: agentAnswer });
16
+ * ```
17
+ * 规则(确定性、可测,见 formatForChannel 注释):
18
+ * · 表格 → 「• 列1:值1 · 列2:值2」多行(列多/超宽时按「• 记录 N」+ 每列一行拆开)
19
+ * · `#` 标题 → 加粗(markdown 通道)/ 纯文本
20
+ * · `---` 分隔线 → 空行;`>` 引用 → `› `;`- [ ]` / `- [x]` → `☐` / `☑`;嵌套列表压平一层
21
+ * · 代码块**原样保留**;inline code / bold / 链接不动(企微 markdown 支持)
22
+ * · wecom 默认按 **4096 字节(UTF-8)** 截断,优先在段落/行边界,附「…(已截断,完整内容看电脑)」
23
+ * · `wechat` 走现有特殊格式(本模块只做 **plain 兜底**,不重复实现微信那套富文本)
24
+ * · 未知通道 → `plain`(fail-safe:宁可少用格式,也不发坏的 markdown)
25
+ */
26
+
27
+ /** 各通道能力(钉钉/飞书实现时直接复用;wechat = 现有特殊格式,不经本模块排版)。 */
28
+ export const CHANNEL_CAPS = Object.freeze({
29
+ wecom: Object.freeze({ markdown: true, tables: false, card: true }),
30
+ dingtalk: Object.freeze({ markdown: true, tables: false, card: true }),
31
+ feishu: Object.freeze({ markdown: false, tables: false, card: true }),
32
+ wechat: Object.freeze({ markdown: false, special: true }),
33
+ plain: Object.freeze({ markdown: false })
34
+ });
35
+
36
+ /** 截断提示(与 wecom-bot 的 ANSWER_TRUNCATED_NOTE 同文,便于去重)。 */
37
+ export const TRUNCATED_NOTE = "…(已截断,完整内容看电脑)";
38
+
39
+ /** 各通道默认字节上限(wecom 官方 4096;其它先给宽松值,留 maxBytes 覆盖)。 */
40
+ const DEFAULT_MAX_BYTES = Object.freeze({
41
+ wecom: 4096,
42
+ dingtalk: 4096,
43
+ feishu: 8192,
44
+ wechat: Infinity,
45
+ plain: Infinity
46
+ });
47
+
48
+ /** UTF-8 字节数(中文 3 字节;emoji 4 字节)。 */
49
+ export function utf8Bytes(value) {
50
+ try {
51
+ return Buffer.byteLength(String(value == null ? "" : value), "utf8");
52
+ } catch {
53
+ return String(value == null ? "" : value).length * 3;
54
+ }
55
+ }
56
+
57
+ /** 终端/聊天窗口的显示宽度(CJK 与 emoji 记 2 列;只用于"这行会不会太宽"的确定性判断)。 */
58
+ export function displayWidth(value) {
59
+ let width = 0;
60
+ for (const ch of String(value == null ? "" : value)) {
61
+ const cp = ch.codePointAt(0) || 0;
62
+ const wide = cp === 0x2329 || cp === 0x232a
63
+ || (cp >= 0x1100 && cp <= 0x115f)
64
+ || (cp >= 0x2e80 && cp <= 0xa4cf)
65
+ || (cp >= 0xac00 && cp <= 0xd7a3)
66
+ || (cp >= 0xf900 && cp <= 0xfaff)
67
+ || (cp >= 0xfe30 && cp <= 0xfe4f)
68
+ || (cp >= 0xff00 && cp <= 0xff60)
69
+ || (cp >= 0xffe0 && cp <= 0xffe6)
70
+ || (cp >= 0x1f300 && cp <= 0x1f64f)
71
+ || (cp >= 0x1f900 && cp <= 0x1f9ff)
72
+ || (cp >= 0x20000 && cp <= 0x3fffd);
73
+ width += wide ? 2 : 1;
74
+ }
75
+ return width;
76
+ }
77
+
78
+ /** 通道名规范化:未知/空 → `plain`(fail-safe)。 */
79
+ export function normalizeChannelName(channel) {
80
+ const name = String(channel || "").trim().toLowerCase();
81
+ if (Object.prototype.hasOwnProperty.call(CHANNEL_CAPS, name)) return name;
82
+ // 常见别名(调用方拼错/叫法不同时不至于掉进 markdown 分支)
83
+ if (name === "weixin" || name === "wx" || name === "wechat-bot" || name === "personal") return "wechat";
84
+ if (name === "workbench" || name === "wxwork" || name === "qywx" || name === "wecom-bot") return "wecom";
85
+ if (name === "ding" || name === "dingding") return "dingtalk";
86
+ if (name === "lark") return "feishu";
87
+ return "plain";
88
+ }
89
+
90
+ // ---------- Markdown 表格解析(确定性:只按未转义的 `|` 切) ----------
91
+
92
+ /** 是否是表格行(以 `|` 开头且至少 2 个竖线)。 */
93
+ function isTableLine(line) {
94
+ const t = String(line || "").trim();
95
+ if (!t.startsWith("|")) return false;
96
+ const count = (t.match(/\|/g) || []).length;
97
+ return count >= 2;
98
+ }
99
+
100
+ /** 把一行表格拆成单元格(支持 `\|` 转义;不会把转义竖线当分隔符)。 */
101
+ export function splitTableRow(line) {
102
+ let s = String(line || "").trim();
103
+ if (s.startsWith("|")) s = s.slice(1);
104
+ // 结尾的竖线是边框(`\|` 结尾不算)
105
+ if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
106
+ const cells = [];
107
+ let cur = "";
108
+ for (let i = 0; i < s.length; i += 1) {
109
+ const ch = s[i];
110
+ if (ch === "\\" && s[i + 1] === "|") { cur += "|"; i += 1; continue; }
111
+ if (ch === "|") { cells.push(cur.trim()); cur = ""; continue; }
112
+ cur += ch;
113
+ }
114
+ cells.push(cur.trim());
115
+ return cells;
116
+ }
117
+
118
+ /** 分隔行(`|---|---|` / `|:--|--:|`)。 */
119
+ function isSeparatorRow(cells) {
120
+ return Array.isArray(cells) && cells.length > 0 && cells.every((c) => /^:?-{1,}:?$/.test(String(c || "").trim()));
121
+ }
122
+
123
+ /**
124
+ * 表格 → 可读文本。
125
+ *
126
+ * 规则(写死,便于单测):
127
+ * · 每条数据行 = 「• 列1:值1 · 列2:值2 …」;
128
+ * · 列数 ≤3 且整行显示宽度 ≤60 → 单行;否则拆成「• 记录 N」+ 每列一行(避免超宽/被客户端折行成乱码);
129
+ * · 表头作为**列名**逐行带上(不是另起一行表格头——用户要的是"看得懂哪列是什么")。
130
+ * @param {string[]} lines 连续的表格行
131
+ * @returns {string[]} 转换后的行(不含 `|---`)
132
+ */
133
+ export function convertMarkdownTable(lines) {
134
+ const rows = (Array.isArray(lines) ? lines : []).map(splitTableRow);
135
+ if (!rows.length) return [];
136
+ let header = null;
137
+ let dataRows = rows;
138
+ if (rows.length >= 2 && isSeparatorRow(rows[1])) { header = rows[0]; dataRows = rows.slice(2); }
139
+ else if (rows.length >= 1 && isSeparatorRow(rows[0])) { dataRows = rows.slice(1); }
140
+ else { header = rows[0]; dataRows = rows.slice(1); }
141
+ const colCount = Math.max(
142
+ header ? header.length : 0,
143
+ ...dataRows.map((r) => r.length),
144
+ 0
145
+ );
146
+ const out = [];
147
+ let index = 0;
148
+ for (const row of dataRows) {
149
+ if (!row || isSeparatorRow(row)) continue;
150
+ index += 1;
151
+ const cells = [];
152
+ for (let c = 0; c < colCount; c += 1) {
153
+ const rawName = header && header[c] ? String(header[c]).trim() : "";
154
+ const name = rawName || `列${c + 1}`;
155
+ const val = row[c] == null ? "" : String(row[c]).trim();
156
+ if (!val) continue;
157
+ cells.push({ name, val });
158
+ }
159
+ if (!cells.length) continue;
160
+ const oneLine = cells.map((c) => `${c.name}:${c.val}`).join(" · ");
161
+ if (cells.length <= 3 && displayWidth(oneLine) <= 60) {
162
+ out.push(`• ${oneLine}`);
163
+ continue;
164
+ }
165
+ out.push(`• 记录 ${index}`);
166
+ for (const c of cells) out.push(` ${c.name}:${c.val}`);
167
+ }
168
+ return out;
169
+ }
170
+
171
+ // ---------- 行级转换 ----------
172
+
173
+ const RE_FENCE = /^\s{0,3}(```|~~~)/;
174
+ const RE_HEADING = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
175
+ const RE_HR = /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/;
176
+ const RE_QUOTE = /^\s{0,3}>\s?(.*)$/;
177
+ const RE_TASK = /^(\s*)([-*+]|\d+\.)\s+\[( |x|X)\]\s+(.*)$/;
178
+ const RE_NESTED_LIST = /^(\s{2,})([-*+]|\d+\.)\s+(.*)$/;
179
+ const RE_BULLET = /^(\s*)([-*+])\s+(.*)$/;
180
+
181
+ /** 把一段"非代码块"文本按行转换(markdown 通道保留 inline 语法;非 markdown 通道降级)。 */
182
+ function convertLines(lines, markdown) {
183
+ const out = [];
184
+ let table = [];
185
+ const flushTable = () => {
186
+ if (!table.length) return;
187
+ const converted = convertMarkdownTable(table);
188
+ if (converted.length) {
189
+ out.push("", ...converted, "");
190
+ }
191
+ table = [];
192
+ };
193
+ for (const rawLine of lines) {
194
+ const line = String(rawLine == null ? "" : rawLine);
195
+ if (isTableLine(line)) { table.push(line); continue; }
196
+ flushTable();
197
+ // 标题
198
+ const heading = RE_HEADING.exec(line);
199
+ if (heading) {
200
+ const text = heading[2].trim();
201
+ out.push(markdown ? `**${text}**` : text);
202
+ continue;
203
+ }
204
+ // 分隔线 → 空行
205
+ if (RE_HR.test(line)) { out.push(""); continue; }
206
+ // 引用
207
+ const quote = RE_QUOTE.exec(line);
208
+ if (quote) { out.push(`› ${quote[1].trim()}`); continue; }
209
+ // 任务列表(☐/☑,不再保留 `- [ ]`)
210
+ const task = RE_TASK.exec(line);
211
+ if (task) {
212
+ const done = String(task[3]).toLowerCase() === "x";
213
+ const box = done ? "☑" : "☐";
214
+ out.push(markdown ? `- ${box} ${task[4].trim()}` : `${box} ${task[4].trim()}`);
215
+ continue;
216
+ }
217
+ // 嵌套列表:压平一层(更深缩进统一成 2 空格 + `·`,不再出现多层缩进)
218
+ const nested = RE_NESTED_LIST.exec(line);
219
+ if (nested) { out.push(` · ${nested[3].trim()}`); continue; }
220
+ // 普通列表:非 markdown 通道把 `-` 换成 `•`(markdown 通道保持)
221
+ const bullet = RE_BULLET.exec(line);
222
+ if (bullet && !markdown) { out.push(`${bullet[1]}• ${bullet[3].trim()}`); continue; }
223
+ out.push(markdown ? line : line.replace(/\*\*(.+?)\*\*/g, "$1").replace(/`([^`]+)`/g, "$1"));
224
+ }
225
+ flushTable();
226
+ return out;
227
+ }
228
+
229
+ /** 整段 Markdown → 目标通道文本(代码块原样保留)。 */
230
+ function convertMarkdownLike(input, markdown) {
231
+ const text = String(input == null ? "" : input);
232
+ if (!text.trim()) return "";
233
+ const lines = text.split("\n");
234
+ const out = [];
235
+ let code = [];
236
+ let inCode = false;
237
+ const flushCode = () => {
238
+ if (!code.length) return;
239
+ if (markdown) out.push(...code);
240
+ else {
241
+ // 非 markdown 通道:去掉围栏,内容缩进两格(保持"是代码"的观感,不出现 ```)
242
+ for (const line of code) {
243
+ if (RE_FENCE.test(line)) continue;
244
+ out.push(line ? ` ${line}` : "");
245
+ }
246
+ }
247
+ code = [];
248
+ };
249
+ let plainBuffer = [];
250
+ const flushPlain = () => {
251
+ if (!plainBuffer.length) return;
252
+ out.push(...convertLines(plainBuffer, markdown));
253
+ plainBuffer = [];
254
+ };
255
+ for (const line of lines) {
256
+ if (RE_FENCE.test(line)) {
257
+ if (!inCode) { flushPlain(); inCode = true; code = [line]; continue; }
258
+ code.push(line); flushCode(); inCode = false; continue;
259
+ }
260
+ if (inCode) { code.push(line); continue; }
261
+ plainBuffer.push(line);
262
+ }
263
+ if (inCode) flushCode(); // 未闭合的代码块也原样保留(不吞内容)
264
+ flushPlain();
265
+ // 收尾清理:行尾空格 / 连续空行压成一个 / 首尾空行去掉
266
+ const cleaned = [];
267
+ for (const line of out) {
268
+ const trimmedRight = String(line).replace(/[ \t]+$/, "");
269
+ if (trimmedRight === "" && (cleaned.length === 0 || cleaned[cleaned.length - 1] === "")) continue;
270
+ cleaned.push(trimmedRight);
271
+ }
272
+ while (cleaned.length && cleaned[cleaned.length - 1] === "") cleaned.pop();
273
+ return cleaned.join("\n");
274
+ }
275
+
276
+ // ---------- 截断(按 UTF-8 字节 / 字符,优先段落边界) ----------
277
+
278
+ function fitsBoth(value, maxBytes, maxChars) {
279
+ if (utf8Bytes(value) > maxBytes) return false;
280
+ if (maxChars !== Infinity && [...String(value)].length > maxChars) return false;
281
+ return true;
282
+ }
283
+
284
+ /**
285
+ * 安全截断:优先整段 → 整行 → 逐码点;绝不切在 UTF-8 中间(用 `for...of` 按码点累加)。
286
+ * 结果末尾附 `suffix`(调用方保证 suffix 也计入上限)。
287
+ * @returns {string}
288
+ */
289
+ export function truncateUtf8Safe(text, { maxBytes = Infinity, maxChars = Infinity, suffix = TRUNCATED_NOTE } = {}) {
290
+ const body = String(text == null ? "" : text);
291
+ if (fitsBoth(body, maxBytes, maxChars)) return body;
292
+ const tail = `\n${suffix}`;
293
+ const budgetBytes = maxBytes - utf8Bytes(tail);
294
+ const budgetChars = maxChars === Infinity ? Infinity : maxChars - [...tail].length;
295
+ if (budgetBytes <= 0 || (budgetChars !== Infinity && budgetChars <= 0)) return suffix;
296
+ // ① 段落
297
+ let out = "";
298
+ for (const para of body.split(/\n{2,}/)) {
299
+ const cand = out ? `${out}\n\n${para}` : para;
300
+ if (!fitsBoth(cand, budgetBytes, budgetChars)) break;
301
+ out = cand;
302
+ }
303
+ // ② 行
304
+ if (!out) {
305
+ for (const line of body.split("\n")) {
306
+ const cand = out ? `${out}\n${line}` : line;
307
+ if (!fitsBoth(cand, budgetBytes, budgetChars)) break;
308
+ out = cand;
309
+ }
310
+ }
311
+ // ③ 逐码点(不会切坏代理对/中文)
312
+ if (!out) {
313
+ for (const ch of body) {
314
+ const cand = out + ch;
315
+ if (!fitsBoth(cand, budgetBytes, budgetChars)) break;
316
+ out = cand;
317
+ }
318
+ }
319
+ return `${out.replace(/[ \t]+$/, "")}${tail}`;
320
+ }
321
+
322
+ // ---------- 对外主函数 ----------
323
+
324
+ /**
325
+ * 按通道能力格式化消息。
326
+ *
327
+ * 支持的选项:
328
+ * · `text` 正文(Markdown 源码)
329
+ * · `title` 可选标题(会作为加粗/纯文本首行;正文里的 `#` 标题也会各自转换)
330
+ * · `kind` 预留:目前不影响输出(钉钉/飞书将来可按 kind 分样式)
331
+ * · `maxChars` 字符上限(可选)
332
+ * · `maxBytes` 字节上限(默认按通道:wecom/dingtalk 4096;feishu 8192;plain/wechat 不限)
333
+ *
334
+ * @param {"wecom"|"dingtalk"|"feishu"|"wechat"|"plain"|string} channel
335
+ * @param {{title?:string, text?:string, kind?:string, maxChars?:number, maxBytes?:number}} [options]
336
+ * @returns {string}
337
+ */
338
+ export function formatForChannel(channel, options = {}) {
339
+ const name = normalizeChannelName(channel);
340
+ const caps = CHANNEL_CAPS[name] || CHANNEL_CAPS.plain;
341
+ const markdown = caps.markdown === true;
342
+ const rawText = String(options.text == null ? "" : options.text);
343
+ const maxBytes = Number.isFinite(Number(options.maxBytes)) && Number(options.maxBytes) > 0
344
+ ? Number(options.maxBytes) : DEFAULT_MAX_BYTES[name];
345
+ const maxChars = Number.isFinite(Number(options.maxChars)) && Number(options.maxChars) > 0
346
+ ? Number(options.maxChars) : Infinity;
347
+
348
+ // 调用方可能已经自己截断过并附了 suffix(wecom-bot 的回答回传会先按字符截 1500):
349
+ // 先摘掉,统一由本模块决定是否需要再按字节截,避免出现两个「已截断」。
350
+ let preTruncated = false;
351
+ let bodyText = rawText;
352
+ if (bodyText.endsWith(TRUNCATED_NOTE)) {
353
+ preTruncated = true;
354
+ bodyText = bodyText.slice(0, -TRUNCATED_NOTE.length);
355
+ }
356
+
357
+ let body = convertMarkdownLike(bodyText, markdown);
358
+ const title = String(options.title == null ? "" : options.title).trim();
359
+ if (title) {
360
+ const titleLine = markdown ? `**${title}**` : title;
361
+ body = body ? `${titleLine}\n\n${body}` : titleLine;
362
+ }
363
+ // 微信通道有其自己的特殊格式(wechat-runtime 负责):这里只做 plain 兜底,且不截断(由微信侧管)。
364
+ if (name === "wechat") return body;
365
+
366
+ const fitted = truncateUtf8Safe(body, { maxBytes, maxChars, suffix: TRUNCATED_NOTE });
367
+ if (fitted === body) {
368
+ // 原本就在上限内:如果调用方已标过截断,保留它的提示(不要悄悄丢掉;保持原样拼接)
369
+ return preTruncated ? `${body}${TRUNCATED_NOTE}` : body;
370
+ }
371
+ return fitted;
372
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * im-format — 通道感知消息格式化单测(2026-11-08 业主反馈「企微/飞书推回来的表格都是乱的」)。
3
+ *
4
+ * 只测**纯函数**(不连网、不起进程):表格→可读列表、标题/引用/分隔线/任务列表/嵌套列表、
5
+ * 代码块原样保留、UTF-8 字节截断(4096,优先段落边界、绝不切坏中文)、各通道分支。
6
+ *
7
+ * 接入测(wecom-bot 发出的报文里没有裸表格)在 wecom-bot.test.mjs 里。
8
+ */
9
+ import assert from "node:assert/strict";
10
+ import test from "node:test";
11
+
12
+ import {
13
+ CHANNEL_CAPS,
14
+ TRUNCATED_NOTE,
15
+ convertMarkdownTable,
16
+ displayWidth,
17
+ formatForChannel,
18
+ normalizeChannelName,
19
+ splitTableRow,
20
+ truncateUtf8Safe,
21
+ utf8Bytes
22
+ } from "../im-format.mjs";
23
+
24
+ const fmt = (text, opts = {}) => formatForChannel("wecom", { text, ...opts });
25
+
26
+ /** 展示用:断言"没有裸表格"(分隔行 / 行首竖线表格)。 */
27
+ const hasRawTable = (s) => /\|\s*-{2,}/.test(s) || /^\s*\|.*\|\s*$/m.test(s);
28
+
29
+ // ─────────────────────────── 通道能力表 ───────────────────────────
30
+
31
+ test("CHANNEL_CAPS:企微/钉钉 markdown 无表格、飞书无 markdown、微信特殊格式、plain 兜底", () => {
32
+ assert.deepEqual(CHANNEL_CAPS.wecom, { markdown: true, tables: false, card: true });
33
+ assert.deepEqual(CHANNEL_CAPS.dingtalk, { markdown: true, tables: false, card: true });
34
+ assert.deepEqual(CHANNEL_CAPS.feishu, { markdown: false, tables: false, card: true });
35
+ assert.deepEqual(CHANNEL_CAPS.wechat, { markdown: false, special: true });
36
+ assert.deepEqual(CHANNEL_CAPS.plain, { markdown: false });
37
+ // 通道别名与未知通道
38
+ assert.equal(normalizeChannelName("wxwork"), "wecom");
39
+ assert.equal(normalizeChannelName("Lark"), "feishu");
40
+ assert.equal(normalizeChannelName("dingding"), "dingtalk");
41
+ assert.equal(normalizeChannelName("weixin"), "wechat");
42
+ assert.equal(normalizeChannelName("something-else"), "plain");
43
+ assert.equal(normalizeChannelName(""), "plain");
44
+ });
45
+
46
+ // ─────────────────────────── 表格 ───────────────────────────
47
+
48
+ test("★ 表格 → 可读列表(2 列中文):不含 |---,每条记录一行「• 列:值」", () => {
49
+ const md = [
50
+ "查询结果如下:",
51
+ "",
52
+ "| 姓名 | 年龄 |",
53
+ "|---|---|",
54
+ "| 张三 | 30 |",
55
+ "| 李四 | 25 |",
56
+ "",
57
+ "以上。"
58
+ ].join("\n");
59
+ const out = fmt(md);
60
+ assert.ok(!out.includes("|---"), "★ 不得残留表格分隔行");
61
+ assert.ok(!hasRawTable(out), "★ 不得残留裸表格");
62
+ assert.match(out, /^• 姓名:张三 · 年龄:30$/m);
63
+ assert.match(out, /^• 姓名:李四 · 年龄:25$/m);
64
+ assert.match(out, /查询结果如下/);
65
+ assert.match(out, /以上。/);
66
+ // 表头只作为列名出现,不额外留一行"| 姓名 | 年龄 |"
67
+ assert.ok(!out.includes("| 姓名 |"));
68
+ });
69
+
70
+ test("表格 1 列 / 3 列:1 列逐条「• 列:值」;3 列窄表仍单行", () => {
71
+ const one = fmt("| 项目 |\n|---|\n| A |\n| B |");
72
+ assert.deepEqual(one.split("\n"), ["• 项目:A", "• 项目:B"]);
73
+
74
+ const three = fmt("| 名称 | 数量 | 价格 |\n|---|---|---|\n| 苹果 | 2 | 5 |");
75
+ assert.match(three, /^• 名称:苹果 · 数量:2 · 价格:5$/m);
76
+ });
77
+
78
+ test("★ 表格 5 列(超宽):拆成「• 记录 N」+ 每列一行,避免客户端折行成乱码", () => {
79
+ const md = [
80
+ "| 序号 | 模块 | 状态 | 负责人 | 备注 |",
81
+ "|---|---|---|---|---|",
82
+ "| 1 | 登录 | 完成 | 张三 | 已经上线验证过了 |",
83
+ "| 2 | 支付 | 进行中 | 李四 | 等第三方回调 |"
84
+ ].join("\n");
85
+ const out = fmt(md);
86
+ assert.ok(!hasRawTable(out));
87
+ assert.match(out, /^• 记录 1$/m);
88
+ assert.match(out, /^ {2}序号:1$/m);
89
+ assert.match(out, /^ {2}模块:登录$/m);
90
+ assert.match(out, /^ {2}负责人:张三$/m);
91
+ assert.match(out, /^• 记录 2$/m);
92
+ // 每行都不该太宽(拆开是为了可读;阈值 60 显示列 + 列名余量)
93
+ for (const line of out.split("\n")) {
94
+ assert.ok(displayWidth(line) <= 80, `行太宽(${displayWidth(line)}):${line}`);
95
+ }
96
+ });
97
+
98
+ test("表格转义竖线:`\\|` 是单元格内容,不是分隔符", () => {
99
+ assert.deepEqual(splitTableRow("| a \\| b | c |"), ["a | b", "c"]);
100
+ const out = fmt("| 表达式 | 值 |\n|---|---|\n| a \\| b | 真 |");
101
+ assert.match(out, /• 表达式:a \| b · 值:真/);
102
+ assert.ok(!hasRawTable(out));
103
+ });
104
+
105
+ test("无分隔行 / 无表头 / 空单元格:不崩、不生造列", () => {
106
+ // 没有 |---| 分隔行的两行表:第一行当表头
107
+ const out = fmt("| k | v |\n| a | 1 |");
108
+ assert.match(out, /^• k:a · v:1$/m);
109
+ // 单元格为空 → 该列不输出
110
+ const sparse = convertMarkdownTable(["| a | b | c |", "|---|---|---|", "| 1 | | 3 |"]);
111
+ assert.deepEqual(sparse, ["• a:1 · c:3"]);
112
+ // 全空行表 → 空数组(不要留空壳)
113
+ assert.deepEqual(convertMarkdownTable(["| a | b |", "|---|---|"]), []);
114
+ assert.deepEqual(convertMarkdownTable([]), []);
115
+ });
116
+
117
+ // ─────────────────────────── 标题 / 引用 / 分隔线 / 列表 ───────────────────────────
118
+
119
+ test("★ 标题→加粗、引用→›、分隔线→空行、任务列表→☐/☑、嵌套列表压平一层、代码块原样保留", () => {
120
+ const md = [
121
+ "# 一级标题",
122
+ "## 二级标题",
123
+ "> 引用一句话",
124
+ "",
125
+ "---",
126
+ "",
127
+ "- [ ] 待办事项",
128
+ "- [x] 已完成",
129
+ "- 父项",
130
+ " - 子项",
131
+ " - 孙项",
132
+ "",
133
+ "```js",
134
+ "const a = 1; // 代码块里 # 和 | 都不该被转换",
135
+ "| not | a | table |",
136
+ "```"
137
+ ].join("\n");
138
+ const out = fmt(md);
139
+ assert.match(out, /^\*\*一级标题\*\*$/m, "标题 → 加粗");
140
+ assert.match(out, /^\*\*二级标题\*\*$/m);
141
+ assert.match(out, /^› 引用一句话$/m, "引用 → ›");
142
+ assert.ok(!/^-{3,}$/m.test(out), "★ 分隔线不得残留");
143
+ assert.match(out, /^- ☐ 待办事项$/m, "未完成 → ☐");
144
+ assert.match(out, /^- ☑ 已完成$/m, "已完成 → ☑");
145
+ assert.match(out, /^- 父项$/m);
146
+ assert.match(out, /^ {2}· 子项$/m, "嵌套压平成一层 ·");
147
+ assert.match(out, /^ {2}· 孙项$/m, "第三层也压到同一层");
148
+ assert.ok(out.includes("```js"), "★ 代码块围栏必须保留");
149
+ assert.ok(out.includes("const a = 1;"), "代码内容保留");
150
+ assert.ok(out.includes("| not | a | table |"), "★ 代码块里的表格语法不得被转换");
151
+ });
152
+
153
+ test("inline code / 加粗 / 链接保留(企微 markdown 支持)", () => {
154
+ const out = fmt("用 `npm test` 跑一下,**注意**看 [文档](https://example.com/x)。");
155
+ assert.equal(out, "用 `npm test` 跑一下,**注意**看 [文档](https://example.com/x)。");
156
+ });
157
+
158
+ test("非 markdown 通道(plain/feishu)降级:去掉 ** 与 `,列表换 •,代码围栏去掉", () => {
159
+ const md = "# 标题\n\n**重点** 和 `code`\n\n- 条目\n\n```\nplain code\n```";
160
+ const plain = formatForChannel("plain", { text: md });
161
+ assert.ok(!plain.includes("**"), "plain 不得保留加粗标记");
162
+ assert.ok(!plain.includes("`code`"), "plain 不得保留 inline code 标记");
163
+ assert.match(plain, /^• 条目$/m);
164
+ assert.ok(!plain.includes("```"), "plain 去掉代码围栏");
165
+ assert.ok(plain.includes("plain code"), "代码内容保留");
166
+ // feishu:markdown=false,走同一套降级
167
+ const feishu = formatForChannel("feishu", { text: md });
168
+ assert.ok(!feishu.includes("**"));
169
+ });
170
+
171
+ // ─────────────────────────── 截断 ───────────────────────────
172
+
173
+ test("★ wecom 4096 字节截断:中文按字节算、优先段落边界、末尾带截断提示、不切坏 UTF-8", () => {
174
+ const para1 = "第一段:" + "甲".repeat(600) + "。"; // 1800+ 字节
175
+ const para2 = "第二段:" + "乙".repeat(600) + "。";
176
+ const para3 = "第三段:" + "丙".repeat(600) + "。";
177
+ const md = [para1, para2, para3].join("\n\n");
178
+ assert.ok(utf8Bytes(md) > 4096, "输入必须超过 4096 字节");
179
+ const out = fmt(md);
180
+ assert.ok(utf8Bytes(out) <= 4096, `输出必须 ≤4096 字节(实际 ${utf8Bytes(out)})`);
181
+ assert.ok(out.endsWith(TRUNCATED_NOTE), "必须带「…(已截断,完整内容看电脑)」");
182
+ assert.ok(out.startsWith("第一段:"), "保留开头");
183
+ assert.ok(!out.includes("第三段:"), "后面的段落被截掉");
184
+ // 不切在 UTF-8 中间:字符串往返必须一致(出现 U+FFFD 说明切坏了)
185
+ assert.equal(Buffer.from(out, "utf8").toString("utf8"), out);
186
+ assert.ok(!out.includes("\uFFFD"));
187
+ });
188
+
189
+ test("截断:段落太大时退到行边界;单行超长时按码点切(不破坏代理对/中文)", () => {
190
+ // 每行都很短但整体超限 → 在行边界截断(不会出现半行)
191
+ const manyLines = Array.from({ length: 400 }, (_, i) => `第 ${i} 行:数据`).join("\n");
192
+ const out = fmt(manyLines, { maxBytes: 300 });
193
+ assert.ok(utf8Bytes(out) <= 300);
194
+ assert.ok(out.endsWith(TRUNCATED_NOTE));
195
+ assert.equal(Buffer.from(out, "utf8").toString("utf8"), out);
196
+ // 单行超长(含 emoji 4 字节)→ 逐码点截断,不产生 U+FFFD
197
+ const huge = "😀".repeat(2000);
198
+ const cut = truncateUtf8Safe(huge, { maxBytes: 100 });
199
+ assert.ok(utf8Bytes(cut) <= 100);
200
+ assert.equal(Buffer.from(cut, "utf8").toString("utf8"), cut);
201
+ assert.ok(!cut.includes("\uFFFD"));
202
+ });
203
+
204
+ test("maxChars 与「调用方已截断」去重:不会出现两个「已截断」", () => {
205
+ const out = fmt("甲".repeat(1000), { maxChars: 100 });
206
+ assert.ok([...out].length <= 100, "字符上限生效");
207
+ assert.equal((out.match(/已截断/g) || []).length, 1);
208
+ // 调用方自带 suffix(回答回传先按字符截 1500 + 本提示):模块不得重复追加
209
+ const pre = `${"乙".repeat(1500)}${TRUNCATED_NOTE}`;
210
+ const out2 = fmt(pre);
211
+ assert.equal((out2.match(/已截断/g) || []).length, 1, "★ 不得出现两个截断提示");
212
+ assert.ok(out2.endsWith(TRUNCATED_NOTE));
213
+ // 自带 suffix 且其实没超限 → 保留原提示
214
+ const shortPre = `短内容${TRUNCATED_NOTE}`;
215
+ assert.equal(fmt(shortPre), shortPre);
216
+ });
217
+
218
+ // ─────────────────────────── wechat / 未知通道 ───────────────────────────
219
+
220
+ test("wechat 分支:走现有特殊格式(本模块只做 plain 兜底、不截断)", () => {
221
+ const big = "| a | b |\n|---|---|\n| 1 | 2 |\n" + "内容".repeat(5000);
222
+ const out = formatForChannel("wechat", { text: big });
223
+ assert.ok(!hasRawTable(out), "兜底也不留裸表格");
224
+ assert.ok(!out.endsWith(TRUNCATED_NOTE), "wechat 不在这里截断(由微信通道自己管长度/格式)");
225
+ // 未知通道 → plain(fail-safe,不发坏 markdown)
226
+ const unknown = formatForChannel("mystery-channel", { text: "# 标题\n\n**粗**" });
227
+ assert.ok(!unknown.includes("**"));
228
+ assert.equal(unknown, "标题\n\n粗");
229
+ });