chore(adapters): snapshot pre-attachment WIP (permission card v2, handler serialization, markdown style)

Carries forward ~800 lines of in-flight work that existed in the working
tree before the IM-attachment branch was cut. Kept as a single isolated
commit so later attachment work has a clean diff baseline and so this
WIP can be cherry-picked back to main on its own merits.

Contents:
- ws-bridge: per-chat handler chain serialization; sendPermissionResponse
  now accepts optional `rule: 'always'` for persistent permits
- feishu/index.ts: permission card rewritten to Schema 2.0 with icon,
  cross-dir warning, and ♾️ 永久允许 button; optimizeMarkdownForFeishu
  applied to streaming card
- feishu/markdown-style.ts: new Feishu-specific markdown downgrader
- tests: corresponding bun:test coverage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-11 15:24:10 +08:00
parent 77974f7052
commit 4ccda92cfe
6 changed files with 1062 additions and 73 deletions

View File

@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import { WsBridge } from '../ws-bridge.js'
import { WebSocketServer, type WebSocket as WsServerSocket } from 'ws'
describe('WsBridge', () => {
let bridge: WsBridge
@ -54,3 +55,123 @@ describe('WsBridge', () => {
expect(bridge.hasSession('b')).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Integration: per-chat handler serialization
//
// Reproduces the feishu text→tool→text race: a slow handler on msg 1 must
// complete BEFORE msg 2's handler starts, otherwise msg 2 reads the stale
// state msg 1's continuation is about to clear.
// ---------------------------------------------------------------------------
describe('WsBridge: handler serialization', () => {
let server: WebSocketServer
let port: number
let connections: WsServerSocket[]
let serverUrl: string
beforeEach(async () => {
connections = []
// port 0 → let the OS pick a free one
server = new WebSocketServer({ port: 0 })
server.on('connection', (ws) => {
connections.push(ws)
})
await new Promise<void>((resolve) => server.on('listening', () => resolve()))
port = (server.address() as { port: number }).port
serverUrl = `ws://127.0.0.1:${port}`
})
afterEach(async () => {
// Forcibly kill any server-side sockets (not graceful close) so
// WebSocketServer.close() doesn't wait for client FIN.
for (const ws of connections) {
try { ws.terminate() } catch {}
}
await new Promise<void>((resolve) => {
const t = setTimeout(() => resolve(), 500) // hard cap
server.close(() => {
clearTimeout(t)
resolve()
})
})
})
it('processes handler calls in strict FIFO order per chatId', async () => {
const bridge = new WsBridge(serverUrl, 'test')
const events: string[] = []
// The handler simulates an async side effect that takes varying time.
// If handlers ran concurrently, fast msgs could finish before slow ones,
// producing an out-of-order `events` array.
bridge.onServerMessage('chat-1', async (msg: any) => {
const tag = msg.tag as string
const delay = msg.delay as number
events.push(`start:${tag}`)
await new Promise((r) => setTimeout(r, delay))
events.push(`end:${tag}`)
})
bridge.connectSession('chat-1', 'sess-1')
const ok = await bridge.waitForOpen('chat-1')
expect(ok).toBe(true)
expect(connections.length).toBe(1)
const serverWs = connections[0]!
// Blast three messages back-to-back. msg1 is slow, msg2/msg3 are fast.
// With serialization: start:1, end:1, start:2, end:2, start:3, end:3
// Without serialization: start:1, start:2, start:3, end:2, end:3, end:1
serverWs.send(JSON.stringify({ tag: '1', delay: 40 }))
serverWs.send(JSON.stringify({ tag: '2', delay: 5 }))
serverWs.send(JSON.stringify({ tag: '3', delay: 5 }))
// Wait long enough for all three handlers to run serially
await new Promise((r) => setTimeout(r, 200))
expect(events).toEqual([
'start:1', 'end:1',
'start:2', 'end:2',
'start:3', 'end:3',
])
bridge.destroy()
})
it('handler error does not break the chain (subsequent messages still run)', async () => {
const bridge = new WsBridge(serverUrl, 'test')
const events: string[] = []
bridge.onServerMessage('chat-err', async (msg: any) => {
if (msg.throw) {
events.push('throwing')
throw new Error('boom')
}
events.push(`ok:${msg.tag}`)
})
bridge.connectSession('chat-err', 'sess-err')
await bridge.waitForOpen('chat-err')
const serverWs = connections[0]!
serverWs.send(JSON.stringify({ throw: true }))
serverWs.send(JSON.stringify({ tag: 'after' }))
await new Promise((r) => setTimeout(r, 80))
expect(events).toEqual(['throwing', 'ok:after'])
bridge.destroy()
})
it('resetSession clears the handler chain', async () => {
const bridge = new WsBridge(serverUrl, 'test')
bridge.onServerMessage('chat-reset', () => {})
bridge.connectSession('chat-reset', 'sess-reset')
await bridge.waitForOpen('chat-reset')
bridge.resetSession('chat-reset')
expect(bridge.hasSession('chat-reset')).toBe(false)
bridge.destroy()
})
})

View File

@ -32,6 +32,10 @@ export class WsBridge {
private sessions = new Map<string, Session>()
/** Single handler per chatId — separate from sessions so reconnect doesn't duplicate */
private handlers = new Map<string, MessageHandler>()
/** Per-chat FIFO queue of in-flight handler promises.
* Ensures an async handler for message N completes before handler for N+1
* starts, preventing state races at `await` points. */
private handlerChains = new Map<string, Promise<void>>()
private serverUrl: string
private platform: string
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
@ -58,9 +62,25 @@ export class WsBridge {
return this.send(chatId, { type: 'user_message', content })
}
/** Respond to a permission request. */
sendPermissionResponse(chatId: string, requestId: string, allowed: boolean): boolean {
return this.send(chatId, { type: 'permission_response', requestId, allowed })
/** Respond to a permission request.
*
* @param rule - optional rule name to make the permission persistent.
* Currently the server supports `'always'`, which uses the CLI's
* permission_suggestions to produce updatedPermissions so the same
* tool call won't prompt again in this session. Omit for one-shot allow. */
sendPermissionResponse(
chatId: string,
requestId: string,
allowed: boolean,
rule?: string,
): boolean {
const message: Record<string, unknown> = {
type: 'permission_response',
requestId,
allowed,
}
if (rule) message.rule = rule
return this.send(chatId, message)
}
/** Stop the current generation. */
@ -82,6 +102,7 @@ export class WsBridge {
this.sessions.delete(chatId)
}
this.handlers.delete(chatId)
this.handlerChains.delete(chatId)
}
/** Has a session (connected or handler registered) for chatId. */
@ -102,6 +123,7 @@ export class WsBridge {
}
this.sessions.clear()
this.handlers.clear()
this.handlerChains.clear()
}
// ------- internal -------
@ -131,14 +153,30 @@ export class WsBridge {
})
ws.on('message', (raw) => {
let msg: ServerMessage
try {
const msg: ServerMessage = JSON.parse(raw.toString())
if (msg.type === 'pong') return
const handler = this.handlers.get(chatId)
if (handler) handler(msg)
msg = JSON.parse(raw.toString())
} catch (err) {
console.error('[WsBridge] Parse error:', err)
return
}
if (msg.type === 'pong') return
const handler = this.handlers.get(chatId)
if (!handler) return
// Serialize per-chat handler calls: chain each message onto the previous
// one so a slow handler (e.g. one awaiting im.message.create) fully
// finishes before the next message's handler runs. This prevents state
// races where a later message reads stale map entries set up by an
// earlier-but-still-in-flight handler.
const prev = this.handlerChains.get(chatId) ?? Promise.resolve()
const next = prev
.catch(() => {}) // upstream errors must not poison the chain
.then(() => Promise.resolve().then(() => handler(msg)))
.catch((err) => {
console.error(`[WsBridge] Handler error on ${chatId}:`, err)
})
this.handlerChains.set(chatId, next)
})
ws.on('close', (code, reason) => {

View File

@ -5,6 +5,7 @@
*/
import { describe, it, expect } from 'bun:test'
import * as path from 'node:path'
// ---------- helpers extracted from feishu/index.ts for testability ----------
@ -147,40 +148,191 @@ function buildProjectPickerCard(projects: RecentProject[]): Record<string, unkno
}
}
function buildPermissionCard(toolName: string, input: unknown, requestId: string): Record<string, unknown> {
const preview = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
const truncated = preview.length > 300 ? preview.slice(0, 300) + '…' : preview
// ---------- permission card helpers (mirrored from feishu/index.ts) ----------
return {
schema: '2.0',
config: { wide_screen_mode: true },
header: {
title: { tag: 'plain_text', content: '🔐 需要权限确认' },
template: 'orange',
type ToolCallSummary = {
icon: string
label: string
target?: string
filePath?: string
}
function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary {
const rec: Record<string, unknown> =
input && typeof input === 'object' ? (input as Record<string, unknown>) : {}
const str = (key: string): string | undefined =>
typeof rec[key] === 'string' ? (rec[key] as string) : undefined
switch (toolName) {
case 'Write': {
const fp = str('file_path')
return { icon: '✏️', label: '写入文件', target: fp, filePath: fp }
}
case 'Edit':
case 'MultiEdit':
case 'NotebookEdit': {
const fp = str('file_path') ?? str('notebook_path')
return { icon: '✏️', label: '修改文件', target: fp, filePath: fp }
}
case 'Read': {
const fp = str('file_path')
return { icon: '📖', label: '读取文件', target: fp, filePath: fp }
}
case 'Bash':
case 'BashOutput': {
return { icon: '🖥️', label: '执行命令', target: str('command') }
}
case 'Grep': {
const pattern = str('pattern')
return {
icon: '🔍',
label: '搜索内容',
target: pattern ? `pattern: ${pattern}` : undefined,
filePath: str('path'),
}
}
case 'Glob': {
const pattern = str('pattern')
return {
icon: '📁',
label: '查找文件',
target: pattern ? `pattern: ${pattern}` : undefined,
filePath: str('path'),
}
}
case 'WebFetch':
return { icon: '🌐', label: '访问网页', target: str('url') }
case 'WebSearch':
return { icon: '🌐', label: '搜索网页', target: str('query') }
default:
return { icon: '🔧', label: toolName }
}
}
function isOutsideWorkDir(filePath: string, workDir: string): boolean {
const abs = path.isAbsolute(filePath)
? path.normalize(filePath)
: path.resolve(workDir, filePath)
const normWork = path.normalize(workDir).replace(/\/+$/, '')
return abs !== normWork && !abs.startsWith(normWork + path.sep)
}
function truncateTarget(s: string, maxLen = 160): string {
if (s.length <= maxLen) return s
return s.slice(0, maxLen - 1) + '…'
}
function buildPermissionCard(
toolName: string,
input: unknown,
requestId: string,
workDir?: string,
): Record<string, unknown> {
const summary = summarizeToolCall(toolName, input)
const crossDir = Boolean(
workDir && summary.filePath && isOutsideWorkDir(summary.filePath, workDir),
)
const elements: Record<string, unknown>[] = [
{
tag: 'markdown',
content: `${summary.icon} **${summary.label}** \`${toolName}\``,
},
elements: [
]
if (summary.target) {
const shown = summary.filePath
? prettyPath(summary.target, 80)
: truncateTarget(summary.target, 160)
elements.push({
tag: 'markdown',
content: '```\n' + shown + '\n```',
margin: '4px 0 0 0',
})
}
if (crossDir) {
elements.push({
tag: 'markdown',
content: '⚠️ **该操作位于当前项目目录之外**',
margin: '8px 0 0 0',
text_size: 'notation',
})
}
elements.push({ tag: 'hr', margin: '12px 0 0 0' })
elements.push({
tag: 'column_set',
flex_mode: 'stretch',
horizontal_spacing: '8px',
margin: '8px 0 0 0',
columns: [
{
tag: 'markdown',
content: `**工具**: ${toolName}\n**内容**:\n\`\`\`\n${truncated}\n\`\`\``,
},
{
tag: 'action',
actions: [
tag: 'column',
width: 'weighted',
weight: 1,
vertical_align: 'center',
elements: [
{
tag: 'button',
text: { tag: 'plain_text', content: '✅ 允许' },
type: 'primary',
size: 'medium',
value: { action: 'permit', requestId, allowed: true },
},
],
},
{
tag: 'column',
width: 'weighted',
weight: 1,
vertical_align: 'center',
elements: [
{
tag: 'button',
text: { tag: 'plain_text', content: '♾️ 永久允许' },
type: 'default',
size: 'medium',
value: { action: 'permit', requestId, allowed: true, rule: 'always' },
},
],
},
{
tag: 'column',
width: 'weighted',
weight: 1,
vertical_align: 'center',
elements: [
{
tag: 'button',
text: { tag: 'plain_text', content: '❌ 拒绝' },
type: 'danger',
size: 'medium',
value: { action: 'permit', requestId, allowed: false },
},
],
},
],
})
return {
schema: '2.0',
config: {
wide_screen_mode: false,
update_multi: true,
},
header: {
title: { tag: 'plain_text', content: '🔐 需要权限确认' },
subtitle: {
tag: 'plain_text',
content: crossDir ? '⚠️ 跨目录操作' : toolName,
},
template: crossDir ? 'red' : 'orange',
padding: '12px 12px 12px 12px',
icon: { tag: 'standard_icon', token: 'lock-chat_filled' },
},
body: { elements },
}
}
@ -278,42 +430,256 @@ describe('Feishu: event parsing', () => {
})
describe('Feishu: permission card', () => {
it('builds valid card structure', () => {
const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abcde')
// Helpers to reach into Schema 2.0 body.elements
function getBodyElements(card: Record<string, unknown>): any[] {
return ((card.body as any).elements ?? []) as any[]
}
function getActionRow(card: Record<string, unknown>): any {
return getBodyElements(card).find((el) => el.tag === 'column_set')
}
function getButtons(card: Record<string, unknown>): any[] {
return getActionRow(card).columns.map(
(c: any) => c.elements.find((e: any) => e.tag === 'button'),
)
}
// ----- Schema 2.0 regression -----
it('uses Schema 2.0 with body.elements wrapper (not top-level elements)', () => {
const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abc')
expect(card.schema).toBe('2.0')
expect((card.header as any).title.content).toContain('权限确认')
expect((card.elements as any[]).length).toBe(2) // markdown + action
const actionElement = (card.elements as any[])[1]
expect(actionElement.tag).toBe('action')
expect(actionElement.actions.length).toBe(2) // allow + deny buttons
expect(card.elements).toBeUndefined() // old bug had top-level elements
expect((card.body as any).elements).toBeDefined()
expect((card.config as any).update_multi).toBe(true)
expect((card.config as any).wide_screen_mode).toBe(false) // mobile-first
})
it('allow button has correct value', () => {
const card = buildPermissionCard('Read', {}, 'xyz12')
const allowBtn = (card.elements as any[])[1].actions[0]
expect(allowBtn.value.action).toBe('permit')
expect(allowBtn.value.requestId).toBe('xyz12')
expect(allowBtn.value.allowed).toBe(true)
it('header has title, subtitle, template, icon', () => {
const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abc')
const header = card.header as any
expect(header.title.content).toContain('权限确认')
expect(header.subtitle.content).toBe('Bash')
expect(header.template).toBe('orange')
expect(header.icon.tag).toBe('standard_icon')
})
it('deny button has correct value', () => {
const card = buildPermissionCard('Read', {}, 'xyz12')
const denyBtn = (card.elements as any[])[1].actions[1]
// ----- Three buttons -----
expect(denyBtn.value.action).toBe('permit')
expect(denyBtn.value.requestId).toBe('xyz12')
expect(denyBtn.value.allowed).toBe(false)
it('has three action buttons in order: 允许 | 永久允许 | 拒绝', () => {
const card = buildPermissionCard('Read', {}, 'xyz')
const [allow, always, deny] = getButtons(card)
expect(allow.text.content).toContain('允许')
expect(allow.type).toBe('primary')
expect(always.text.content).toContain('永久允许')
expect(always.type).toBe('default')
expect(deny.text.content).toContain('拒绝')
expect(deny.type).toBe('danger')
})
it('truncates long input preview', () => {
const longInput = { command: 'x'.repeat(500) }
const card = buildPermissionCard('Bash', longInput, 'abc')
const mdElement = (card.elements as any[])[0]
it('允许 button carries allowed=true and no rule', () => {
const card = buildPermissionCard('Read', {}, 'req-1')
const [allow] = getButtons(card)
expect(allow.value).toEqual({
action: 'permit',
requestId: 'req-1',
allowed: true,
})
expect(allow.value.rule).toBeUndefined()
})
expect(mdElement.content).toContain('…')
it('永久允许 button carries allowed=true + rule=always', () => {
const card = buildPermissionCard('Read', {}, 'req-2')
const always = getButtons(card)[1]
expect(always.value).toEqual({
action: 'permit',
requestId: 'req-2',
allowed: true,
rule: 'always',
})
})
it('拒绝 button carries allowed=false and no rule', () => {
const card = buildPermissionCard('Read', {}, 'req-3')
const deny = getButtons(card)[2]
expect(deny.value).toEqual({
action: 'permit',
requestId: 'req-3',
allowed: false,
})
})
// ----- Tool summary rendering -----
it('renders Write with ✏️ 写入文件 header and file path target', () => {
const card = buildPermissionCard(
'Write',
{ file_path: '/tmp/output.txt', content: 'hi' },
'req',
)
const elements = getBodyElements(card)
expect(elements[0].content).toContain('✏️')
expect(elements[0].content).toContain('写入文件')
expect(elements[0].content).toContain('`Write`')
// Target rendered as fenced code block
expect(elements[1].content).toContain('/tmp/output.txt')
expect(elements[1].content.startsWith('```')).toBe(true)
})
it('renders Edit with ✏️ 修改文件', () => {
const card = buildPermissionCard(
'Edit',
{ file_path: '/a/b.ts', old_string: 'x', new_string: 'y' },
'req',
)
expect(getBodyElements(card)[0].content).toContain('修改文件')
})
it('renders Bash with 🖥️ 执行命令 and command target', () => {
const card = buildPermissionCard(
'Bash',
{ command: 'rm -rf /tmp/x' },
'req',
)
const elements = getBodyElements(card)
expect(elements[0].content).toContain('🖥️')
expect(elements[0].content).toContain('执行命令')
expect(elements[1].content).toContain('rm -rf /tmp/x')
})
it('truncates very long Bash commands to 160 chars', () => {
const longCmd = 'echo ' + 'x'.repeat(500)
const card = buildPermissionCard('Bash', { command: longCmd }, 'req')
const targetEl = getBodyElements(card)[1]
expect(targetEl.content).toContain('…')
// Fenced code wraps ~10 extra chars
expect(targetEl.content.length).toBeLessThanOrEqual(180)
})
it('renders Grep with 🔍 搜索内容 and pattern target', () => {
const card = buildPermissionCard(
'Grep',
{ pattern: 'TODO', path: '/src' },
'req',
)
const elements = getBodyElements(card)
expect(elements[0].content).toContain('🔍')
expect(elements[1].content).toContain('TODO')
})
it('renders WebFetch with 🌐 访问网页 and url target', () => {
const card = buildPermissionCard(
'WebFetch',
{ url: 'https://example.com/api' },
'req',
)
const elements = getBodyElements(card)
expect(elements[0].content).toContain('🌐')
expect(elements[0].content).toContain('访问网页')
expect(elements[1].content).toContain('https://example.com/api')
})
it('falls back to 🔧 <toolName> for unknown tools', () => {
const card = buildPermissionCard('CustomTool', { foo: 'bar' }, 'req')
expect(getBodyElements(card)[0].content).toContain('🔧')
expect(getBodyElements(card)[0].content).toContain('CustomTool')
})
it('has no target line when input is empty', () => {
const card = buildPermissionCard('Bash', {}, 'req')
const elements = getBodyElements(card)
// elements: [header_md, hr, action_column_set]
expect(elements[1].tag).toBe('hr')
})
// ----- Cross-directory detection -----
it('does NOT show cross-dir warning when file is inside workDir', () => {
const card = buildPermissionCard(
'Write',
{ file_path: '/Users/me/proj/src/a.ts' },
'req',
'/Users/me/proj',
)
const elements = getBodyElements(card)
const hasWarn = elements.some(
(el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
)
expect(hasWarn).toBe(false)
expect((card.header as any).template).toBe('orange')
expect((card.header as any).subtitle.content).toBe('Write')
})
it('DOES show cross-dir warning when file is outside workDir (red template)', () => {
const card = buildPermissionCard(
'Write',
{ file_path: '/tmp/evil.sh' },
'req',
'/Users/me/proj',
)
const elements = getBodyElements(card)
const warn = elements.find(
(el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
)
expect(warn).toBeDefined()
expect((card.header as any).template).toBe('red')
expect((card.header as any).subtitle.content).toContain('跨目录')
})
it('does NOT check cross-dir for Bash (no filePath)', () => {
const card = buildPermissionCard(
'Bash',
{ command: 'rm -rf /tmp/x' },
'req',
'/Users/me/proj',
)
expect((card.header as any).template).toBe('orange')
})
it('does not warn when workDir is not provided', () => {
const card = buildPermissionCard(
'Write',
{ file_path: '/tmp/x.ts' },
'req',
// workDir omitted
)
const elements = getBodyElements(card)
const hasWarn = elements.some(
(el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
)
expect(hasWarn).toBe(false)
})
})
describe('Feishu: isOutsideWorkDir', () => {
it('returns false for file inside workDir', () => {
expect(isOutsideWorkDir('/Users/me/proj/src/a.ts', '/Users/me/proj')).toBe(false)
})
it('returns false for file directly in workDir', () => {
expect(isOutsideWorkDir('/Users/me/proj/a.ts', '/Users/me/proj')).toBe(false)
})
it('returns true for file in a sibling directory', () => {
expect(isOutsideWorkDir('/Users/me/other/a.ts', '/Users/me/proj')).toBe(true)
})
it('returns true for /tmp file', () => {
expect(isOutsideWorkDir('/tmp/evil.sh', '/Users/me/proj')).toBe(true)
})
it('handles workDir with trailing slash', () => {
expect(isOutsideWorkDir('/Users/me/proj/src/a.ts', '/Users/me/proj/')).toBe(false)
})
it('resolves relative paths against workDir', () => {
expect(isOutsideWorkDir('src/a.ts', '/Users/me/proj')).toBe(false)
expect(isOutsideWorkDir('../other/a.ts', '/Users/me/proj')).toBe(true)
})
it('does not match prefix collisions (proj vs proj2)', () => {
// /Users/me/proj2/a.ts starts with "/Users/me/proj" as a string
// but is NOT inside /Users/me/proj
expect(isOutsideWorkDir('/Users/me/proj2/a.ts', '/Users/me/proj')).toBe(true)
})
})

View File

@ -0,0 +1,192 @@
/**
* optimizeMarkdownForFeishu
*
* :
* - H1~H3 (workaround Feishu tag:'markdown' H1~H3 )
* - #
* -
* - 边界场景: 无标题不动H4+
*/
import { describe, it, expect } from 'bun:test'
import { optimizeMarkdownForFeishu } from '../markdown-style.js'
describe('optimizeMarkdownForFeishu: 标题降级', () => {
it('H1 → H4', () => {
const out = optimizeMarkdownForFeishu('# Title')
expect(out).toBe('#### Title')
})
it('H2 → H5', () => {
const out = optimizeMarkdownForFeishu('## Title')
expect(out).toBe('##### Title')
})
it('H3 → H5', () => {
const out = optimizeMarkdownForFeishu('### Title')
expect(out).toBe('##### Title')
})
it('混合 H1+H2+H3 全部降级', () => {
const input = '# H1\n## H2\n### H3'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('#### H1\n##### H2\n##### H3')
})
it('已存在的 H4 在没有 H1~H3 时原样保留(不触发降级管道)', () => {
// 触发条件是原文必须有 H1~H3。纯 H4 文档原样返回。
const input = '#### Already H4'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('#### Already H4')
})
it('H5 在没有 H1~H3 时保留', () => {
const input = '##### Already H5'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('##### Already H5')
})
it('同时存在 H1 和 H4: H1→H4原 H4 被规则降级为 H5', () => {
// H4~H6 也走 #{2,6} → H5 的管道(因为原文有 H1
const input = '# Top\n#### Sub'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('#### Top\n##### Sub')
})
it('标题文本前必须有空格才算标题(#xxx 不是标题)', () => {
const input = '#notaheading'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('#notaheading')
})
it('降级顺序: 不会把 # 先降成 ####,然后再被 #{2,6} 吃成 #####', () => {
// 这是 openclaw-lark 源码里的关键注释 —— 顺序不能颠倒
const out = optimizeMarkdownForFeishu('# Top')
expect(out).toBe('#### Top') // 必须恰好是 4 个 #,不是 5 个
})
it('无标题文本原样返回', () => {
const input = 'just some plain text\nwith a newline'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe(input)
})
})
describe('optimizeMarkdownForFeishu: 代码块保护', () => {
it('代码块内的 # 不会被降级', () => {
const input = '```\n# not a heading\n## also not\n```'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe(input)
})
it('有外部标题时,代码块内的 # 仍受保护', () => {
const input = '# Real heading\n\n```\n# inside code\n```'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('#### Real heading\n\n```\n# inside code\n```')
})
it('语言标记的 fenced 代码块也受保护', () => {
const input = '## Section\n\n```python\n# python comment\n### not a heading\n```'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('##### Section\n\n```python\n# python comment\n### not a heading\n```')
})
it('多个代码块按顺序保护与还原', () => {
const input = '# A\n```\n# b1\n```\n## C\n```\n### b2\n```'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('#### A\n```\n# b1\n```\n##### C\n```\n### b2\n```')
})
it('全文只有代码块时原样返回', () => {
const input = '```\n# in code\n## also in code\n```'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe(input)
})
})
describe('optimizeMarkdownForFeishu: 空行压缩', () => {
it('3 个换行 → 2 个', () => {
const input = 'line1\n\n\nline2'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('line1\n\nline2')
})
it('5 个换行 → 2 个', () => {
const input = 'line1\n\n\n\n\nline2'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('line1\n\nline2')
})
it('2 个换行保留', () => {
const input = 'line1\n\nline2'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('line1\n\nline2')
})
it('空行压缩不影响代码块内的连续换行', () => {
// 代码块内的换行应当完全保留
const input = '# Title\n\n```\nline1\n\n\nline2\n```'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('#### Title\n\n```\nline1\n\n\nline2\n```')
})
})
describe('optimizeMarkdownForFeishu: 真实场景', () => {
it('复现 screenshot 里的项目结构报告场景', () => {
// 用户 screenshot 里的真实 markdown 片段
const input = `## OpenCutSkill 项目架构概览
### 1.
Screen Studio
### 2.
\`\`\`
opencutskill/
cli/
core/
tests/
\`\`\``
const out = optimizeMarkdownForFeishu(input)
// 所有 H2~H3 应该被降级为 H5
expect(out).toContain('##### OpenCutSkill 项目架构概览')
expect(out).toContain('##### 1. 项目定位')
expect(out).toContain('##### 2. 模块结构')
// 代码块内容原封不动
expect(out).toContain('opencutskill/')
expect(out).toContain('├── cli/')
// 代码块围栏也原样
expect(out).toContain('```\nopencutskill/')
// 原始的 ## 字面量不应残留
expect(out).not.toMatch(/^## OpenCutSkill/m)
expect(out).not.toMatch(/^### 1\./m)
})
it('错误输入 fallback 到原文不抛错', () => {
// 即使输入是奇怪的字符串也不应抛错
const weird = '\u0000\uFFFF```unclosed'
expect(() => optimizeMarkdownForFeishu(weird)).not.toThrow()
})
it('空字符串返回空字符串', () => {
expect(optimizeMarkdownForFeishu('')).toBe('')
})
})
describe('optimizeMarkdownForFeishu: cardVersion 参数', () => {
it('cardVersion=1 (默认) 不加段落间距 <br>', () => {
const input = '# A\n## B'
const out = optimizeMarkdownForFeishu(input)
expect(out).not.toContain('<br>')
})
it('cardVersion=2 在连续降级标题之间加 <br>', () => {
const input = '# A\n# B'
const out = optimizeMarkdownForFeishu(input, 2)
// 两个 H1 都降级为 H4之间插入 <br>
expect(out).toContain('#### A\n<br>\n#### B')
})
})

View File

@ -18,11 +18,11 @@ import {
formatImHelp,
formatImStatus,
splitMessage,
truncateInput,
} from '../common/format.js'
import { SessionStore } from '../common/session-store.js'
import { AdapterHttpClient, type RecentProject } from '../common/http-client.js'
import { isAllowedUser, tryPair } from '../common/pairing.js'
import { optimizeMarkdownForFeishu } from './markdown-style.js'
// ---------- init ----------
@ -221,15 +221,22 @@ async function sendCard(chatId: string, card: Record<string, unknown>): Promise<
}
}
/** Build a simple streaming text card (Schema 1.0, patchable via im.message.patch). */
/** Build a simple streaming text card (Schema 1.0, patchable via im.message.patch).
*
* The `content` is run through `optimizeMarkdownForFeishu` first: Feishu's
* `tag:'markdown'` element has a known rendering bug where H1~H3 headings
* (`#`, `##`, `###`) show up as literal text instead of being styled.
* openclaw-lark works around this in `src/card/markdown-style.ts` by
* downgrading H1H4 and H2~H6H5. We do the same here. */
function buildStreamingCard(text: string): Record<string, unknown> {
const content = optimizeMarkdownForFeishu(text || ' ')
return {
config: {
wide_screen_mode: true,
update_multi: true,
},
elements: [
{ tag: 'markdown', content: text || ' ' },
{ tag: 'markdown', content },
],
}
}
@ -361,40 +368,217 @@ function buildProjectPickerCard(projects: RecentProject[]): Record<string, unkno
}
}
/** Build a permission request card. */
function buildPermissionCard(toolName: string, input: unknown, requestId: string): Record<string, unknown> {
const truncated = truncateInput(input, 300)
/** Human-readable summary of a tool call for display in the permission card. */
type ToolCallSummary = {
icon: string
label: string
/** Display string for the operation target (file path or command preview) */
target?: string
/** Absolute file path for cross-directory detection, when applicable */
filePath?: string
}
return {
schema: '2.0',
config: { wide_screen_mode: true },
header: {
title: { tag: 'plain_text', content: '🔐 需要权限确认' },
template: 'orange',
/** Map a Claude Code tool call to an icon + human-readable Chinese label.
* Unknown tools fall back to the raw tool name with a generic icon. */
function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary {
const rec: Record<string, unknown> =
input && typeof input === 'object' ? (input as Record<string, unknown>) : {}
const str = (key: string): string | undefined =>
typeof rec[key] === 'string' ? (rec[key] as string) : undefined
switch (toolName) {
case 'Write': {
const fp = str('file_path')
return { icon: '✏️', label: '写入文件', target: fp, filePath: fp }
}
case 'Edit':
case 'MultiEdit':
case 'NotebookEdit': {
const fp = str('file_path') ?? str('notebook_path')
return { icon: '✏️', label: '修改文件', target: fp, filePath: fp }
}
case 'Read': {
const fp = str('file_path')
return { icon: '📖', label: '读取文件', target: fp, filePath: fp }
}
case 'Bash':
case 'BashOutput': {
return { icon: '🖥️', label: '执行命令', target: str('command') }
}
case 'Grep': {
const pattern = str('pattern')
return {
icon: '🔍',
label: '搜索内容',
target: pattern ? `pattern: ${pattern}` : undefined,
filePath: str('path'),
}
}
case 'Glob': {
const pattern = str('pattern')
return {
icon: '📁',
label: '查找文件',
target: pattern ? `pattern: ${pattern}` : undefined,
filePath: str('path'),
}
}
case 'WebFetch':
return { icon: '🌐', label: '访问网页', target: str('url') }
case 'WebSearch':
return { icon: '🌐', label: '搜索网页', target: str('query') }
default:
return { icon: '🔧', label: toolName }
}
}
/** True if `filePath` resolves to a location outside of `workDir`.
* Relative paths are resolved against workDir first. */
function isOutsideWorkDir(filePath: string, workDir: string): boolean {
const abs = path.isAbsolute(filePath)
? path.normalize(filePath)
: path.resolve(workDir, filePath)
const normWork = path.normalize(workDir).replace(/\/+$/, '')
return abs !== normWork && !abs.startsWith(normWork + path.sep)
}
/** Truncate a single-line target preview (e.g. shell command) to maxLen. */
function truncateTarget(s: string, maxLen = 160): string {
if (s.length <= maxLen) return s
return s.slice(0, maxLen - 1) + '…'
}
/** Build a permission request card (Schema 2.0, mobile-friendly).
*
* Layout:
* header 🔐 (orange / red if cross-dir)
* body <icon> **<label>** `<toolName>`
* ```
* <target> (path or command, if present)
* ```
* (only when filePath escapes workDir)
*
* [ | | ]
*
* The button carries `rule: 'always'` in its value the server
* turns that into `updatedPermissions` using the CLI's permission_suggestions,
* so the same tool call won't prompt again in this session. */
function buildPermissionCard(
toolName: string,
input: unknown,
requestId: string,
workDir?: string,
): Record<string, unknown> {
const summary = summarizeToolCall(toolName, input)
const crossDir = Boolean(
workDir && summary.filePath && isOutsideWorkDir(summary.filePath, workDir),
)
const elements: Record<string, unknown>[] = [
// Header line: icon + human label + raw tool tag
{
tag: 'markdown',
content: `${summary.icon} **${summary.label}** \`${toolName}\``,
},
elements: [
]
// Target preview (file path / command / url …)
if (summary.target) {
const shown = summary.filePath
? prettyPath(summary.target, 80)
: truncateTarget(summary.target, 160)
elements.push({
tag: 'markdown',
content: '```\n' + shown + '\n```',
margin: '4px 0 0 0',
})
}
// Cross-directory warning (only when the file escapes the session's workDir)
if (crossDir) {
elements.push({
tag: 'markdown',
content: '⚠️ **该操作位于当前项目目录之外**',
margin: '8px 0 0 0',
text_size: 'notation',
})
}
// Divider
elements.push({ tag: 'hr', margin: '12px 0 0 0' })
// Action row — three equal columns: 允许 / 永久允许 / 拒绝
elements.push({
tag: 'column_set',
flex_mode: 'stretch',
horizontal_spacing: '8px',
margin: '8px 0 0 0',
columns: [
{
tag: 'markdown',
content: `**工具**: ${toolName}\n**内容**:\n\`\`\`\n${truncated}\n\`\`\``,
},
{
tag: 'action',
actions: [
tag: 'column',
width: 'weighted',
weight: 1,
vertical_align: 'center',
elements: [
{
tag: 'button',
text: { tag: 'plain_text', content: '✅ 允许' },
type: 'primary',
size: 'medium',
value: { action: 'permit', requestId, allowed: true },
},
],
},
{
tag: 'column',
width: 'weighted',
weight: 1,
vertical_align: 'center',
elements: [
{
tag: 'button',
text: { tag: 'plain_text', content: '♾️ 永久允许' },
type: 'default',
size: 'medium',
value: { action: 'permit', requestId, allowed: true, rule: 'always' },
},
],
},
{
tag: 'column',
width: 'weighted',
weight: 1,
vertical_align: 'center',
elements: [
{
tag: 'button',
text: { tag: 'plain_text', content: '❌ 拒绝' },
type: 'danger',
size: 'medium',
value: { action: 'permit', requestId, allowed: false },
},
],
},
],
})
return {
schema: '2.0',
config: {
wide_screen_mode: false,
update_multi: true,
},
header: {
title: { tag: 'plain_text', content: '🔐 需要权限确认' },
subtitle: {
tag: 'plain_text',
content: crossDir ? '⚠️ 跨目录操作' : toolName,
},
template: crossDir ? 'red' : 'orange',
padding: '12px 12px 12px 12px',
icon: { tag: 'standard_icon', token: 'lock-chat_filled' },
},
body: { elements },
}
}
@ -412,9 +596,14 @@ async function flushToFeishu(chatId: string, newText: string, isComplete: boolea
if (isComplete) {
if (!state.replyMessageId && fullText.trim()) {
// Fallback path: no streaming card was created for this text block
// (e.g. sendCard failed, or upstream never fired content_start{text}).
// Must go through sendCard(buildStreamingCard), NOT sendText — the
// latter uses msg_type=post + tag:'md' which is INLINE-ONLY and won't
// render block-level markdown (#, ```fenced```, tables, lists).
const chunks = splitMessage(fullText, 30000)
for (const chunk of chunks) {
await sendText(chatId, chunk)
await sendCard(chatId, buildStreamingCard(chunk))
}
}
accumulatedText.delete(chatId)
@ -600,7 +789,13 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<
case 'permission_request': {
runtime.pendingPermissionCount += 1
runtime.state = 'permission_pending'
const card = buildPermissionCard(msg.toolName, msg.input, msg.requestId)
const stored = sessionStore.get(chatId)
const card = buildPermissionCard(
msg.toolName,
msg.input,
msg.requestId,
stored?.workDir,
)
await sendCard(chatId, card)
break
}
@ -792,6 +987,7 @@ async function handleCardAction(data: any): Promise<any> {
action?: string
requestId?: string
allowed?: boolean
rule?: string
realPath?: string
projectName?: string
}
@ -806,15 +1002,20 @@ async function handleCardAction(data: any): Promise<any> {
if (action === 'permit') {
const requestId = event.action?.value?.requestId
const allowed = event.action?.value?.allowed ?? false
const rule = event.action?.value?.rule
if (!requestId) return
bridge.sendPermissionResponse(chatId, requestId, allowed)
bridge.sendPermissionResponse(chatId, requestId, allowed, rule)
const runtime = getRuntimeState(chatId)
runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1)
const statusText = allowed ? '✅ 已允许' : '❌ 已拒绝'
const statusText = allowed
? rule === 'always'
? '♾️ 已永久允许(本次会话内不再询问相同操作)'
: '✅ 已允许'
: '❌ 已拒绝'
await sendText(chatId, statusText)
return { toast: { type: 'info', content: statusText } }
return { toast: { type: 'info', content: allowed ? (rule === 'always' ? '♾️ 永久允许' : '✅ 已允许') : '❌ 已拒绝' } }
}
if (action === 'pick_project') {

View File

@ -0,0 +1,71 @@
/**
* Feishu Markdown
*
* 背景: 飞书卡片的 `tag: 'markdown'` H1~H3
* `#`/`##`/`###` H4/H5
*
* 实现参考: openclaw-lark/src/card/markdown-style.ts
*
* Schema 2.0
* <br> cardVersion 便
*/
/**
* `tag: 'markdown'`
*
* - 标题降级: 若原文包含 H1~H3 H2~H6 H5H1 H4
* -
* - 3+ 2
* - fallback
*
* @param text markdown
* @param cardVersion schema <br>
*/
export function optimizeMarkdownForFeishu(text: string, cardVersion = 1): string {
try {
return _optimizeMarkdownForFeishu(text, cardVersion)
} catch {
return text
}
}
function _optimizeMarkdownForFeishu(text: string, cardVersion: number): string {
// ── 1. 提取代码块,用占位符保护,处理后再还原 ─────────────────────
// 这样代码块内部的 `#` 不会被标题降级误伤
const MARK = '___CB_'
const codeBlocks: string[] = []
let r = text.replace(/```[\s\S]*?```/g, (m) => {
return `${MARK}${codeBlocks.push(m) - 1}___`
})
// ── 2. 标题降级 ────────────────────────────────────────────────────
// 只有当原文档(不是保护后的 r包含 H1~H3 时才执行降级
// 顺序: 先 H2~H6 → H5再 H1 → H4
// 若先 H1→H4####会被后面的 #{2,6} 再次匹配成 H5变 ##### Title 两次)
const hasH1toH3 = /^#{1,3} /m.test(text)
if (hasH1toH3) {
r = r.replace(/^#{2,6} (.+)$/gm, '##### $1') // H2~H6 → H5
r = r.replace(/^# (.+)$/gm, '#### $1') // H1 → H4
}
// ── 3. Schema 2.0 下可额外加段落间距 ────────────────────────────────
// 当前飞书 adapter 走 Schema 1.0 / buildStreamingCard这一块不启用
if (cardVersion >= 2) {
// 连续标题之间补 <br>
r = r.replace(/^(#{4,5} .+)\n{1,2}(#{4,5} )/gm, '$1\n<br>\n$2')
}
// ── 4. 压缩多余空行3 个以上连续换行 → 2 个)────────────────────
// 注意: 必须在还原代码块之前做,否则代码块内部的连续换行会被误伤。
// 此时代码块被占位符 `___CB_N___`(单行 token替代压缩只影响
// 真正的 markdown 段落间距。
r = r.replace(/\n{3,}/g, '\n\n')
// ── 5. 还原代码块 ─────────────────────────────────────────────────
// Schema 1.0 路径不在代码块前后加 <br>(维持现状,最小变更)
codeBlocks.forEach((block, i) => {
r = r.replace(`${MARK}${i}___`, block)
})
return r
}