mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
77974f7052
commit
4ccda92cfe
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
192
adapters/feishu/__tests__/markdown-style.test.ts
Normal file
192
adapters/feishu/__tests__/markdown-style.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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 H1→H4 and H2~H6→H5. 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') {
|
||||
|
||||
71
adapters/feishu/markdown-style.ts
Normal file
71
adapters/feishu/markdown-style.ts
Normal 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 → H5,H1 → 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user