cc-haha/adapters/feishu/__tests__/feishu.test.ts
程序员阿江(Relakkes) 82e6e27687 feat: add IM adapter integration (Telegram + Feishu) with web settings UI
Implement IM adapters allowing users to chat with Claude Code from Telegram
and Feishu/Lark. Includes persistent session management (chatId→sessionId
mapping), project selection via /projects command, and a web UI settings page
for configuring bot tokens, allowed users, and default project directory.

Key changes:
- adapters/: Telegram and Feishu adapter scripts with shared common modules
  (WsBridge, MessageBuffer, SessionStore, HttpClient, config, formatting)
- Backend: adapterService + REST API (GET/PUT /api/adapters) with secret masking
- Frontend: AdapterSettings page in Settings tab with i18n support
- DirectoryPicker: use React Portal for dropdown to fix overflow clipping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:38:51 +08:00

235 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 飞书 Adapter 翻译逻辑测试
*
* 不启动真实 Bot只测试事件解析和消息翻译逻辑。
*/
import { describe, it, expect } from 'bun:test'
// ---------- helpers extracted from feishu/index.ts for testability ----------
function extractText(content: string, msgType: string): string | null {
try {
const parsed = JSON.parse(content)
if (msgType === 'text') {
return parsed.text ?? null
}
if (msgType === 'post') {
const zhContent = parsed.zh_cn?.content ?? parsed.en_us?.content ?? []
return zhContent
.flat()
.filter((n: any) => n.tag === 'text' || n.tag === 'md')
.map((n: any) => n.text ?? n.content ?? '')
.join('')
.trim() || null
}
return null
} catch {
return null
}
}
function isBotMentioned(
mentions: Array<{ id?: { open_id?: string } }> | undefined,
botOpenId: string,
): boolean {
if (!mentions || !botOpenId) return false
return mentions.some((m) => m.id?.open_id === botOpenId)
}
function stripMentions(text: string): string {
return text.replace(/@_user_\d+/g, '').trim()
}
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
return {
schema: '2.0',
config: { wide_screen_mode: true },
header: {
title: { tag: 'plain_text', content: '🔐 需要权限确认' },
template: 'orange',
},
elements: [
{
tag: 'markdown',
content: `**工具**: ${toolName}\n**内容**:\n\`\`\`\n${truncated}\n\`\`\``,
},
{
tag: 'action',
actions: [
{
tag: 'button',
text: { tag: 'plain_text', content: '✅ 允许' },
type: 'primary',
value: { action: 'permit', requestId, allowed: true },
},
{
tag: 'button',
text: { tag: 'plain_text', content: '❌ 拒绝' },
type: 'danger',
value: { action: 'permit', requestId, allowed: false },
},
],
},
],
}
}
// ---------- tests ----------
describe('Feishu: event parsing', () => {
describe('extractText', () => {
it('extracts text from text message', () => {
const content = JSON.stringify({ text: 'hello world' })
expect(extractText(content, 'text')).toBe('hello world')
})
it('extracts text from post message (zh_cn)', () => {
const content = JSON.stringify({
zh_cn: {
content: [[
{ tag: 'text', text: 'Hello ' },
{ tag: 'text', text: 'World' },
]],
},
})
expect(extractText(content, 'post')).toBe('Hello World')
})
it('extracts text from post message with md tag', () => {
const content = JSON.stringify({
zh_cn: {
content: [[{ tag: 'md', text: '**bold** text' }]],
},
})
expect(extractText(content, 'post')).toBe('**bold** text')
})
it('returns null for unsupported message types', () => {
expect(extractText('{}', 'image')).toBeNull()
expect(extractText('{}', 'audio')).toBeNull()
})
it('returns null for malformed content', () => {
expect(extractText('not-json', 'text')).toBeNull()
})
it('returns null for empty text', () => {
const content = JSON.stringify({ text: '' })
// empty string is falsy, so ?? null returns ''
expect(extractText(content, 'text')).toBe('')
})
})
describe('isBotMentioned', () => {
const botId = 'ou_bot_123'
it('returns true when bot is mentioned', () => {
const mentions = [
{ id: { open_id: 'ou_user_1' } },
{ id: { open_id: 'ou_bot_123' } },
]
expect(isBotMentioned(mentions, botId)).toBe(true)
})
it('returns false when bot is not mentioned', () => {
const mentions = [
{ id: { open_id: 'ou_user_1' } },
{ id: { open_id: 'ou_user_2' } },
]
expect(isBotMentioned(mentions, botId)).toBe(false)
})
it('returns false for undefined mentions', () => {
expect(isBotMentioned(undefined, botId)).toBe(false)
})
it('returns false for empty mentions', () => {
expect(isBotMentioned([], botId)).toBe(false)
})
})
describe('stripMentions', () => {
it('removes @_user_N patterns', () => {
expect(stripMentions('@_user_1 hello world')).toBe('hello world')
})
it('removes multiple mentions', () => {
expect(stripMentions('@_user_1 @_user_2 test')).toBe('test')
})
it('leaves text without mentions unchanged', () => {
expect(stripMentions('hello world')).toBe('hello world')
})
it('trims whitespace', () => {
expect(stripMentions(' @_user_1 hello ')).toBe('hello')
})
})
})
describe('Feishu: permission card', () => {
it('builds valid card structure', () => {
const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abcde')
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
})
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('deny button has correct value', () => {
const card = buildPermissionCard('Read', {}, 'xyz12')
const denyBtn = (card.elements as any[])[1].actions[1]
expect(denyBtn.value.action).toBe('permit')
expect(denyBtn.value.requestId).toBe('xyz12')
expect(denyBtn.value.allowed).toBe(false)
})
it('truncates long input preview', () => {
const longInput = { command: 'x'.repeat(500) }
const card = buildPermissionCard('Bash', longInput, 'abc')
const mdElement = (card.elements as any[])[0]
expect(mdElement.content).toContain('…')
})
})
describe('Feishu: card.action.trigger parsing', () => {
it('parses permit action from event', () => {
const event = {
operator: { open_id: 'ou_user_1' },
action: { value: { action: 'permit', requestId: 'abcde', allowed: true } },
context: { open_chat_id: 'oc_chat_123' },
}
expect(event.action.value.action).toBe('permit')
expect(event.action.value.requestId).toBe('abcde')
expect(event.action.value.allowed).toBe(true)
expect(event.context.open_chat_id).toBe('oc_chat_123')
})
it('ignores non-permit actions', () => {
const event = {
action: { value: { action: 'other_action' } },
}
expect(event.action.value.action).not.toBe('permit')
})
})