diff --git a/adapters/feishu/__tests__/feishu.test.ts b/adapters/feishu/__tests__/feishu.test.ts index 1dcd15d4..86a09660 100644 --- a/adapters/feishu/__tests__/feishu.test.ts +++ b/adapters/feishu/__tests__/feishu.test.ts @@ -41,6 +41,112 @@ function stripMentions(text: string): string { return text.replace(/@_user_\d+/g, '').trim() } +type RecentProject = { + projectPath: string + realPath: string + projectName: string + isGit: boolean + repoName: string | null + branch: string | null + modifiedAt: string + sessionCount: number +} + +function prettyPath(realPath: string, maxLen = 64): string { + const home = process.env.HOME + let p = realPath + if (home) { + if (p === home) return '~' + if (p.startsWith(`${home}/`)) p = `~${p.slice(home.length)}` + } + if (p.length <= maxLen) return p + const tailLen = Math.floor(maxLen * 0.65) + const headLen = maxLen - tailLen - 1 + return `${p.slice(0, headLen)}…${p.slice(-tailLen)}` +} + +function buildProjectPickerCard(projects: RecentProject[]): Record { + const items = projects.slice(0, 10) + const total = projects.length + const subtitleText = + total > items.length + ? `共 ${total} 个最近项目,显示前 ${items.length}` + : `共 ${total} 个最近项目` + + const rows = items.map((p, i) => { + const branch = p.branch ? ` · *${p.branch}*` : '' + return { + tag: 'column_set', + flex_mode: 'stretch', + horizontal_spacing: '8px', + margin: i === 0 ? '0px 0 0 0' : '10px 0 0 0', + columns: [ + { + tag: 'column', + width: 'weighted', + weight: 1, + vertical_align: 'center', + elements: [ + { + tag: 'markdown', + content: `**${p.projectName}**${branch}`, + }, + { + tag: 'markdown', + content: prettyPath(p.realPath, 56), + text_size: 'notation', + margin: '2px 0 0 0', + }, + ], + }, + { + tag: 'column', + width: 'auto', + vertical_align: 'center', + elements: [ + { + tag: 'button', + text: { tag: 'plain_text', content: '选择' }, + type: i === 0 ? 'primary' : 'default', + size: 'small', + value: { + action: 'pick_project', + realPath: p.realPath, + projectName: p.projectName, + }, + }, + ], + }, + ], + } + }) + + return { + schema: '2.0', + config: { + wide_screen_mode: true, + update_multi: true, + }, + header: { + title: { tag: 'plain_text', content: '📁 选择项目' }, + subtitle: { tag: 'plain_text', content: subtitleText }, + template: 'blue', + }, + body: { + elements: [ + ...rows, + { tag: 'hr', margin: '14px 0 0 0' }, + { + tag: 'markdown', + content: '💡 点击右侧 **选择** 按钮,或发送 `/new <项目名>`', + text_size: 'notation', + margin: '6px 0 0 0', + }, + ], + }, + } +} + function buildPermissionCard(toolName: string, input: unknown, requestId: string): Record { const preview = typeof input === 'string' ? input : JSON.stringify(input, null, 2) const truncated = preview.length > 300 ? preview.slice(0, 300) + '…' : preview @@ -211,6 +317,189 @@ describe('Feishu: permission card', () => { }) }) +describe('Feishu: project picker card', () => { + const sampleProjects: RecentProject[] = [ + { + projectPath: '/Users/dev/claude-code-haha', + realPath: '/Users/dev/claude-code-haha', + projectName: 'claude-code-haha', + isGit: true, + repoName: 'claude-code-haha', + branch: 'main', + modifiedAt: '2026-04-11T00:00:00Z', + sessionCount: 3, + }, + { + projectPath: '/Users/dev/desktop', + realPath: '/Users/dev/desktop', + projectName: 'desktop', + isGit: false, + repoName: null, + branch: null, + modifiedAt: '2026-04-10T00:00:00Z', + sessionCount: 1, + }, + ] + + function getBodyElements(card: Record): any[] { + return ((card.body as any).elements ?? []) as any[] + } + + function getRows(card: Record): any[] { + return getBodyElements(card).filter((el) => el.tag === 'column_set') + } + + function getRowButton(row: any): any { + const buttonCol = row.columns.find((c: any) => + c.elements.some((e: any) => e.tag === 'button'), + ) + return buttonCol.elements.find((e: any) => e.tag === 'button') + } + + function getRowInfoElements(row: any): any[] { + const infoCol = row.columns.find((c: any) => + c.elements.every((e: any) => e.tag === 'markdown'), + ) + return infoCol.elements + } + + it('uses Schema 2.0 with body.elements wrapper', () => { + const card = buildProjectPickerCard(sampleProjects) + expect(card.schema).toBe('2.0') + expect((card.config as any).update_multi).toBe(true) + expect((card.body as any).elements).toBeDefined() + }) + + it('header has title and project-count subtitle', () => { + const card = buildProjectPickerCard(sampleProjects) + expect((card.header as any).title.content).toContain('选择项目') + expect((card.header as any).subtitle.content).toContain('2') + expect((card.header as any).subtitle.content).toContain('最近项目') + }) + + it('subtitle notes truncation when more than 10 projects exist', () => { + const many: RecentProject[] = Array.from({ length: 15 }, (_, i) => ({ + ...sampleProjects[0]!, + projectName: `proj-${i}`, + realPath: `/p/${i}`, + })) + const card = buildProjectPickerCard(many) + const subtitle = (card.header as any).subtitle.content + expect(subtitle).toContain('15') + expect(subtitle).toContain('显示前 10') + }) + + it('body contains one column_set row per project', () => { + const card = buildProjectPickerCard(sampleProjects) + expect(getRows(card).length).toBe(2) + }) + + it('each row has exactly 2 columns: info (weighted) + button (auto)', () => { + const card = buildProjectPickerCard(sampleProjects) + for (const row of getRows(card)) { + expect(row.columns.length).toBe(2) + expect(row.columns[0].width).toBe('weighted') + expect(row.columns[0].vertical_align).toBe('center') + expect(row.columns[1].width).toBe('auto') + expect(row.columns[1].vertical_align).toBe('center') + } + }) + + it('info column has title markdown + notation path markdown', () => { + const card = buildProjectPickerCard(sampleProjects) + const row1 = getRows(card)[0] + const info = getRowInfoElements(row1) + + expect(info.length).toBe(2) + // Title markdown + expect(info[0].tag).toBe('markdown') + expect(info[0].content).toContain('**claude-code-haha**') + expect(info[0].content).toContain('*main*') + // Path markdown (notation = small grey) + expect(info[1].tag).toBe('markdown') + expect(info[1].text_size).toBe('notation') + expect(info[1].content).toContain('claude-code-haha') + }) + + it('row without branch has no separator dot in title', () => { + const card = buildProjectPickerCard(sampleProjects) + const row2 = getRows(card)[1] + const title = getRowInfoElements(row2)[0].content + expect(title).toContain('**desktop**') + expect(title).not.toContain('·') + }) + + it('row button says 选择 with small size and carries per-project value', () => { + const card = buildProjectPickerCard(sampleProjects) + const rows = getRows(card) + + const btn1 = getRowButton(rows[0]) + expect(btn1.text.content).toBe('选择') + expect(btn1.size).toBe('small') + expect(btn1.value.action).toBe('pick_project') + expect(btn1.value.realPath).toBe('/Users/dev/claude-code-haha') + expect(btn1.value.projectName).toBe('claude-code-haha') + + const btn2 = getRowButton(rows[1]) + expect(btn2.value.realPath).toBe('/Users/dev/desktop') + }) + + it('first row button is primary, rest are default', () => { + const card = buildProjectPickerCard(sampleProjects) + const rows = getRows(card) + expect(getRowButton(rows[0]).type).toBe('primary') + expect(getRowButton(rows[1]).type).toBe('default') + }) + + it('body tail has hr and notation footer hint', () => { + const card = buildProjectPickerCard(sampleProjects) + const elements = getBodyElements(card) + const hrIdx = elements.findIndex((el) => el.tag === 'hr') + expect(hrIdx).toBeGreaterThan(0) + expect(elements[hrIdx + 1].tag).toBe('markdown') + expect(elements[hrIdx + 1].text_size).toBe('notation') + }) + + it('caps to first 10 projects', () => { + const many: RecentProject[] = Array.from({ length: 15 }, (_, i) => ({ + ...sampleProjects[0]!, + projectName: `proj-${i}`, + realPath: `/p/${i}`, + })) + const card = buildProjectPickerCard(many) + const rows = getRows(card) + expect(rows.length).toBe(10) + expect(getRowButton(rows[9]).value.realPath).toBe('/p/9') + }) + + it('uses ~ shortcut when path is under $HOME', () => { + const home = process.env.HOME + if (!home) return + const project: RecentProject = { + ...sampleProjects[0]!, + realPath: `${home}/some/sub/dir`, + projectName: 'sub-dir', + } + const card = buildProjectPickerCard([project]) + const pathEl = getRowInfoElements(getRows(card)[0])[1] + expect(pathEl.content).toBe('~/some/sub/dir') + }) + + it('middle-truncates very long paths with ellipsis', () => { + const veryLong = '/x/'.repeat(40) + 'project' // ~123 chars + const project: RecentProject = { + ...sampleProjects[0]!, + realPath: veryLong, + projectName: 'project', + } + const card = buildProjectPickerCard([project]) + const content = getRowInfoElements(getRows(card)[0])[1].content + expect(content).toContain('…') + expect(content.length).toBeLessThanOrEqual(56) + expect(content.endsWith('project')).toBe(true) + }) +}) + describe('Feishu: card.action.trigger parsing', () => { it('parses permit action from event', () => { const event = { @@ -225,10 +514,28 @@ describe('Feishu: card.action.trigger parsing', () => { expect(event.context.open_chat_id).toBe('oc_chat_123') }) - it('ignores non-permit actions', () => { + it('parses pick_project action from event', () => { + const event = { + operator: { open_id: 'ou_user_1' }, + action: { + value: { + action: 'pick_project', + realPath: '/Users/dev/claude-code-haha', + projectName: 'claude-code-haha', + }, + }, + context: { open_chat_id: 'oc_chat_123' }, + } + + expect(event.action.value.action).toBe('pick_project') + expect(event.action.value.realPath).toBe('/Users/dev/claude-code-haha') + expect(event.action.value.projectName).toBe('claude-code-haha') + }) + + it('ignores non-handled actions', () => { const event = { action: { value: { action: 'other_action' } }, } - expect(event.action.value.action).not.toBe('permit') + expect(['permit', 'pick_project']).not.toContain(event.action.value.action) }) }) diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index 1baeda09..5328f579 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -21,7 +21,7 @@ import { truncateInput, } from '../common/format.js' import { SessionStore } from '../common/session-store.js' -import { AdapterHttpClient } from '../common/http-client.js' +import { AdapterHttpClient, type RecentProject } from '../common/http-client.js' import { isAllowedUser, tryPair } from '../common/pairing.js' // ---------- init ---------- @@ -221,19 +221,143 @@ async function sendCard(chatId: string, card: Record): Promise< } } -/** Update a message's content (patch). */ +/** Build a simple streaming text card (Schema 1.0, patchable via im.message.patch). */ +function buildStreamingCard(text: string): Record { + return { + config: { + wide_screen_mode: true, + update_multi: true, + }, + elements: [ + { tag: 'markdown', content: text || ' ' }, + ], + } +} + +/** Update a streaming card message's content (patch). + * Note: im.message.patch only works on interactive cards, so the source + * message MUST have been sent via sendCard(buildStreamingCard(...)). */ async function patchMessage(messageId: string, text: string): Promise { try { await larkClient.im.message.patch({ path: { message_id: messageId }, data: { - content: JSON.stringify({ - zh_cn: { content: [[{ tag: 'md', text }]] }, - }), + content: JSON.stringify(buildStreamingCard(text)), }, }) - } catch { - // patch may fail if message format changed — ignore + } catch (err) { + // patch may fail (rate limit, message expired, etc.) — log and ignore + console.error('[Feishu] patchMessage error:', err instanceof Error ? err.message : err) + } +} + +/** Pretty-print an absolute path for IM display. + * - Replace $HOME with `~` + * - Middle-truncate if it's still very long, keeping the project tail visible */ +function prettyPath(realPath: string, maxLen = 64): string { + const home = process.env.HOME + let p = realPath + if (home) { + if (p === home) return '~' + if (p.startsWith(`${home}/`)) p = `~${p.slice(home.length)}` + } + if (p.length <= maxLen) return p + // Project name lives at the tail — keep more of the tail than the head. + const tailLen = Math.floor(maxLen * 0.65) + const headLen = maxLen - tailLen - 1 + return `${p.slice(0, headLen)}…${p.slice(-tailLen)}` +} + +/** Build an interactive project picker card — mobile-first layout. + * + * Design: one column_set per project with exactly 2 columns: + * - Col 1 (weighted): project info (title markdown + small grey path) + * - Col 2 (auto): "选择" button, vertically centered + * + * Only 2 columns with one weighted + one auto means the weight distribution + * is trivial (auto takes its natural width, weighted takes the rest). This + * avoids the layout issues seen in 3-column attempts. */ +function buildProjectPickerCard(projects: RecentProject[]): Record { + const items = projects.slice(0, 10) + const total = projects.length + const subtitleText = + total > items.length + ? `共 ${total} 个最近项目,显示前 ${items.length}` + : `共 ${total} 个最近项目` + + const rows = items.map((p, i) => { + const branch = p.branch ? ` · *${p.branch}*` : '' + return { + tag: 'column_set', + flex_mode: 'stretch', + horizontal_spacing: '8px', + margin: i === 0 ? '0px 0 0 0' : '10px 0 0 0', + columns: [ + // Col 1 — project info (title + notation path, stacked) + { + tag: 'column', + width: 'weighted', + weight: 1, + vertical_align: 'center', + elements: [ + { + tag: 'markdown', + content: `**${p.projectName}**${branch}`, + }, + { + tag: 'markdown', + content: prettyPath(p.realPath, 56), + text_size: 'notation', + margin: '2px 0 0 0', + }, + ], + }, + // Col 2 — action button (auto width, vertically centered) + { + tag: 'column', + width: 'auto', + vertical_align: 'center', + elements: [ + { + tag: 'button', + text: { tag: 'plain_text', content: '选择' }, + type: i === 0 ? 'primary' : 'default', + size: 'small', + value: { + action: 'pick_project', + realPath: p.realPath, + projectName: p.projectName, + }, + }, + ], + }, + ], + } + }) + + return { + schema: '2.0', + config: { + wide_screen_mode: true, + update_multi: true, + }, + header: { + title: { tag: 'plain_text', content: '📁 选择项目' }, + subtitle: { tag: 'plain_text', content: subtitleText }, + template: 'blue', + }, + body: { + elements: [ + ...rows, + { tag: 'hr', margin: '14px 0 0 0' }, + { + tag: 'markdown', + content: '💡 点击右侧 **选择** 按钮,或发送 `/new <项目名>`', + text_size: 'notation', + margin: '6px 0 0 0', + }, + ], + }, } } @@ -346,11 +470,15 @@ async function showProjectPicker(chatId: string): Promise { '没有找到最近的项目。请先在 Desktop App 中打开一个项目,或在设置中配置默认项目。') return } - const lines = projects.slice(0, 10).map((p, i) => - `${i + 1}. **${p.projectName}**${p.branch ? ` (${p.branch})` : ''}\n ${p.realPath}` - ) pendingProjectSelection.set(chatId, true) - await sendText(chatId, `选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n💡 下次可直接 /new <编号或名称> 快速新建会话`) + const cardId = await sendCard(chatId, buildProjectPickerCard(projects)) + if (!cardId) { + // Fallback to text picker if card delivery failed (permissions, etc.) + const lines = projects.slice(0, 10).map((p, i) => + `${i + 1}. **${p.projectName}**${p.branch ? ` (${p.branch})` : ''}\n ${p.realPath}` + ) + await sendText(chatId, `选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n💡 下次可直接 /new <编号或名称> 快速新建会话`) + } } catch (err) { await sendText(chatId, `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`) } @@ -414,7 +542,7 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< runtime.state = msg.state runtime.verb = typeof msg.verb === 'string' ? msg.verb : undefined if (msg.state === 'thinking' && !state.replyMessageId) { - const mid = await sendText(chatId, '💭 思考中...') + const mid = await sendCard(chatId, buildStreamingCard('💭 思考中...')) if (mid) { state.replyMessageId = mid accumulatedText.set(chatId, '') @@ -425,7 +553,7 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< case 'content_start': if (msg.blockType === 'text') { if (!state.replyMessageId) { - const mid = await sendText(chatId, '▍') + const mid = await sendCard(chatId, buildStreamingCard('▍')) if (mid) { state.replyMessageId = mid accumulatedText.set(chatId, '') @@ -659,48 +787,75 @@ async function handleMessage(data: any): Promise { async function handleCardAction(data: any): Promise { const event = data as { operator?: { open_id?: string } - action?: { value?: { action?: string; requestId?: string; allowed?: boolean } } + action?: { + value?: { + action?: string + requestId?: string + allowed?: boolean + realPath?: string + projectName?: string + } + } context?: { open_chat_id?: string } } const action = event.action?.value?.action - if (action !== 'permit') return - - const requestId = event.action?.value?.requestId - const allowed = event.action?.value?.allowed ?? false const chatId = event.context?.open_chat_id + if (!chatId) return - if (!requestId || !chatId) return + if (action === 'permit') { + const requestId = event.action?.value?.requestId + const allowed = event.action?.value?.allowed ?? false + if (!requestId) return - bridge.sendPermissionResponse(chatId, requestId, allowed) - const runtime = getRuntimeState(chatId) - runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1) + bridge.sendPermissionResponse(chatId, requestId, allowed) + const runtime = getRuntimeState(chatId) + runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1) - const statusText = allowed ? '✅ 已允许' : '❌ 已拒绝' - await sendText(chatId, statusText) + const statusText = allowed ? '✅ 已允许' : '❌ 已拒绝' + await sendText(chatId, statusText) + return { toast: { type: 'info', content: statusText } } + } - return { - toast: { type: 'info', content: statusText }, + if (action === 'pick_project') { + const realPath = event.action?.value?.realPath + const projectName = event.action?.value?.projectName ?? realPath ?? '(unknown)' + if (!realPath) return + + pendingProjectSelection.delete(chatId) + // createSessionForChat handles its own error messaging on failure + const ok = await createSessionForChat(chatId, realPath) + if (ok) { + await sendText(chatId, `✅ 已新建会话:**${projectName}**`) + } + return { toast: { type: 'info', content: `📁 ${projectName}` } } } } // ---------- resolve bot identity ---------- async function resolveBotOpenId(retries = 3): Promise { + // Feishu has no "me" user_id literal — use /open-apis/bot/v3/info to fetch + // the bot's identity via tenant_access_token. Response shape: + // { code: 0, msg: 'ok', bot: { open_id: 'ou_xxx', ... } } for (let i = 0; i < retries; i++) { try { - const resp = await larkClient.contact.user.get({ - path: { user_id: 'me' }, - params: { user_id_type: 'open_id' }, + const resp = await (larkClient as any).request({ + method: 'GET', + url: '/open-apis/bot/v3/info', }) - botOpenId = (resp.data?.user as any)?.open_id ?? null - if (botOpenId) { + const openId = resp?.bot?.open_id ?? resp?.data?.bot?.open_id ?? null + if (openId) { + botOpenId = openId console.log(`[Feishu] Bot open_id: ${botOpenId}`) return } - } catch { + } catch (err) { if (i < retries - 1) { - console.warn(`[Feishu] Could not resolve bot open_id, retrying (${i + 1}/${retries})...`) + console.warn( + `[Feishu] Could not resolve bot open_id, retrying (${i + 1}/${retries})...`, + err instanceof Error ? err.message : err, + ) await new Promise((r) => setTimeout(r, 2000 * (i + 1))) } }