chenbhao commited on
Commit
d6862e4
·
1 Parent(s): 588f433

feat: markdown-style feishu

Browse files
src/services/feishu/FeishuService.ts CHANGED
@@ -1,6 +1,5 @@
1
  /**
2
  * FeishuService — in-process Feishu bot using @larksuite/channel
3
- * Refactored to use vendor modules from lark-coding-agent-bridge.
4
  */
5
 
6
  import * as path from 'node:path'
@@ -17,7 +16,7 @@ import {
17
  type FeishuRuntimeConfig,
18
  } from './feishuConfig.js'
19
 
20
- // Vendor modules (from lark-coding-agent-bridge)
21
  import { log } from './vendor/core/logger.js'
22
  import { PendingQueue } from './vendor/bot/pending-queue.js'
23
  import { startKeepalive } from './vendor/bot/keepalive.js'
 
1
  /**
2
  * FeishuService — in-process Feishu bot using @larksuite/channel
 
3
  */
4
 
5
  import * as path from 'node:path'
 
16
  type FeishuRuntimeConfig,
17
  } from './feishuConfig.js'
18
 
19
+ // Vendor modules (self-contained implementations)
20
  import { log } from './vendor/core/logger.js'
21
  import { PendingQueue } from './vendor/bot/pending-queue.js'
22
  import { startKeepalive } from './vendor/bot/keepalive.js'
src/utils/feishuMarkdown.ts CHANGED
@@ -1,8 +1,168 @@
1
  /**
2
- * Re-export feishu markdown optimization for use within src/.
 
 
 
 
 
 
 
 
3
  */
4
- export {
5
- optimizeMarkdownForFeishu,
6
- sanitizeTextForCard,
7
- FEISHU_CARD_TABLE_LIMIT,
8
- } from '../../feishu/markdown-style.js'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  /**
2
+ * Feishu 卡片 Markdown 样式优化(内联实现,不依赖外部文件)
3
+ *
4
+ * 背景:
5
+ * - 飞书卡片的 `tag: 'markdown'` 元素对 H1~H3 标题有已知渲染异常(字面量显示)。
6
+ * 必须降级为 H4/H5 才能正常渲染。
7
+ * - Schema 2.0 CardKit 支持完整的 markdown 但需要手动加 `<br>` 间距避免标题/表格/
8
+ * 代码块贴得太紧。
9
+ * - 卡片有表格数量上限(FEISHU_CARD_TABLE_LIMIT=3),超出会触发 230099/11310。
10
+ * - 图片必须是飞书上传过的 `img_xxx` key,其它 URL 会触发 CardKit 错误 200570。
11
  */
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Constants
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** 飞书卡片表格数量上限 —— 超出 3 张触发 230099/11310 */
18
+ export const FEISHU_CARD_TABLE_LIMIT = 3
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Public: optimizeMarkdownForFeishu
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * 对将要放入 `tag: 'markdown'` 元素的 markdown 做安全预处理。
26
+ *
27
+ * - 标题降级: 若原文包含 H1~H3,则 H2~H6 → H5,H1 → H4
28
+ * - 代码块内容受保护,不会被降级
29
+ * - Schema 2.0: 连续标题/表格/代码块前后加 `<br>` 间距
30
+ * - 连续 3+ 空行压缩为 2
31
+ * - 删除非 `img_*` 的 markdown 图片引用(防止 CardKit 200570)
32
+ * - 任何内部错误都 fallback 到原文,不阻塞消息发送
33
+ */
34
+ export function optimizeMarkdownForFeishu(text: string, cardVersion = 2): string {
35
+ try {
36
+ let r = _optimizeMarkdownForFeishu(text, cardVersion)
37
+ r = stripInvalidImageKeys(r)
38
+ return r
39
+ } catch {
40
+ return text
41
+ }
42
+ }
43
+
44
+ function _optimizeMarkdownForFeishu(text: string, cardVersion: number): string {
45
+ // ── 1. 提取代码块,用占位符保护,处理后再还原 ─────────────────────
46
+ const MARK = '___CB_'
47
+ const codeBlocks: string[] = []
48
+ let r = text.replace(/```[\s\S]*?```/g, (m) => {
49
+ return `${MARK}${codeBlocks.push(m) - 1}___`
50
+ })
51
+
52
+ // ── 2. 标题降级 ────────────────────────────────────────────────────
53
+ const hasH1toH3 = /^#{1,3} /m.test(text)
54
+ if (hasH1toH3) {
55
+ r = r.replace(/^#{2,6} (.+)$/gm, '##### $1') // H2~H6 → H5
56
+ r = r.replace(/^# (.+)$/gm, '#### $1') // H1 → H4
57
+ }
58
+
59
+ // ── 3. Schema 2.0 段落间距 ────────────────────────────────────────
60
+ if (cardVersion >= 2) {
61
+ // 3a. 连续标题之间加 <br>
62
+ r = r.replace(/^(#{4,5} .+)\n{1,2}(#{4,5} )/gm, '$1\n<br>\n$2')
63
+
64
+ // 3b. 非表格行直接跟表格行 → 先补空行
65
+ r = r.replace(/^([^|\n].*)\n(\|.+\|)/gm, '$1\n\n$2')
66
+
67
+ // 3c. 表格前: 在空行之前插入 <br>
68
+ r = r.replace(/\n\n((?:\|.+\|[^\S\n]*\n?)+)/g, '\n\n<br>\n\n$1')
69
+
70
+ // 3d. 表格后: 在表格块末尾追加 <br>
71
+ r = r.replace(/((?:^\|.+\|[^\S\n]*\n?)+)/gm, (m, _table, offset) => {
72
+ const after = r.slice(offset + m.length).replace(/^\n+/, '')
73
+ if (!after || /^(---|#{4,5} |\*\*)/.test(after)) return m
74
+ return m + '\n<br>\n'
75
+ })
76
+
77
+ // 3e. 表格前是普通文本: 只保留 <br>,去掉多余空行
78
+ r = r.replace(/^((?!#{4,5} )(?!\*\*).+)\n\n(<br>)\n\n(\|)/gm, '$1\n$2\n$3')
79
+
80
+ // 3f. 表格前是加粗行: <br> 紧贴加粗行,空行保留在后面
81
+ r = r.replace(/^(\*\*.+)\n\n(<br>)\n\n(\|)/gm, '$1\n$2\n\n$3')
82
+
83
+ // 3g. 表格后是普通文本: 去掉多余空行
84
+ r = r.replace(/(\|[^\n]*\n)\n(<br>\n)((?!#{4,5} )(?!\*\*))/gm, '$1$2$3')
85
+ }
86
+
87
+ // ── 4. 压缩多余空行(3 个以上连续换行 → 2 个)────────────────────
88
+ r = r.replace(/\n{3,}/g, '\n\n')
89
+
90
+ // ── 5. 还原代码块 ─────────────────────────────────────────────────
91
+ codeBlocks.forEach((block, i) => {
92
+ const replacement = cardVersion >= 2 ? `\n<br>\n${block}\n<br>\n` : block
93
+ r = r.replace(`${MARK}${i}___`, replacement)
94
+ })
95
+
96
+ return r
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // stripInvalidImageKeys
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /** 匹配完整的 markdown 图片语法: `![alt](value)` */
104
+ const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g
105
+
106
+ /**
107
+ * 删除 value 不是飞书 image key (`img_xxx`) 的 markdown 图片引用。
108
+ */
109
+ function stripInvalidImageKeys(text: string): string {
110
+ if (!text.includes('![')) return text
111
+ return text.replace(IMAGE_RE, (fullMatch, _alt, value) => {
112
+ if (value.startsWith('img_')) return fullMatch
113
+ return ''
114
+ })
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Table limiting: sanitizeTextForCard
119
+ // ---------------------------------------------------------------------------
120
+
121
+ export type MarkdownTableMatch = {
122
+ index: number
123
+ length: number
124
+ raw: string
125
+ }
126
+
127
+ /**
128
+ * 扫描正文里会被飞书卡片实际渲染的 markdown 表格位置。
129
+ */
130
+ export function findMarkdownTablesOutsideCodeBlocks(text: string): MarkdownTableMatch[] {
131
+ const codeBlockRanges: Array<{ start: number; end: number }> = []
132
+ const codeBlockRegex = /```[\s\S]*?```/g
133
+ let cbMatch = codeBlockRegex.exec(text)
134
+ while (cbMatch != null) {
135
+ codeBlockRanges.push({ start: cbMatch.index, end: cbMatch.index + cbMatch[0].length })
136
+ cbMatch = codeBlockRegex.exec(text)
137
+ }
138
+ const isInsideCodeBlock = (idx: number): boolean =>
139
+ codeBlockRanges.some((range) => idx >= range.start && idx < range.end)
140
+
141
+ const tableRegex = /\|.+\|[\r\n]+\|[-:| ]+\|[\s\S]*?(?=\n\n|\n(?!\|)|$)/g
142
+ const matches: MarkdownTableMatch[] = []
143
+ let tableMatch = tableRegex.exec(text)
144
+ while (tableMatch != null) {
145
+ if (!isInsideCodeBlock(tableMatch.index)) {
146
+ matches.push({ index: tableMatch.index, length: tableMatch[0].length, raw: tableMatch[0] })
147
+ }
148
+ tableMatch = tableRegex.exec(text)
149
+ }
150
+ return matches
151
+ }
152
+
153
+ /**
154
+ * 对正文里超出 tableLimit 张的 markdown 表格降级为代码块,防止触发 230099/11310。
155
+ */
156
+ export function sanitizeTextForCard(
157
+ text: string,
158
+ tableLimit: number = FEISHU_CARD_TABLE_LIMIT,
159
+ ): string {
160
+ const matches = findMarkdownTablesOutsideCodeBlocks(text)
161
+ if (matches.length <= tableLimit) return text
162
+ let result = text
163
+ for (let i = matches.length - 1; i >= tableLimit; i--) {
164
+ const { index, length, raw } = matches[i]!
165
+ result = result.slice(0, index) + '```\n' + raw + '\n```' + result.slice(index + length)
166
+ }
167
+ return result
168
+ }