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 (
|
| 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 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
*/
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 图片语法: `` */
|
| 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 |
+
}
|