feat(feishu): add interactive project picker and fix bot flows

- Resolve bot open_id via /open-apis/bot/v3/info — the SDK's
  contact.user.get({user_id: 'me'}) path is invalid on Feishu and
  returned 99992351 on every startup.
- Stream replies via interactive cards instead of post messages:
  im.message.patch only accepts msg_type=interactive, so the old
  path-rich-text placeholder caused "This message is NOT a card"
  (230001) on every flush. Added buildStreamingCard helper.
- Add project picker card: one column_set row per project with
  [info (name + branch + path) | select button], Schema 2.0,
  mobile-friendly path display (~ substitution + middle-truncation),
  header subtitle with project count. pick_project actions route
  through the existing handleCardAction.

Card patterns verified against openclaw-lark's Schema 2.0 usage in
src/tools/oauth-cards.ts and src/card/builder.ts. 34 unit tests
cover the new card structure and action routing.
This commit is contained in:
程序员阿江(Relakkes) 2026-04-11 14:25:55 +08:00
parent fc7d6d487c
commit 77974f7052
2 changed files with 497 additions and 35 deletions

View File

@ -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<string, unknown> {
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<string, unknown> {
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<string, unknown>): any[] {
return ((card.body as any).elements ?? []) as any[]
}
function getRows(card: Record<string, unknown>): 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)
})
})

View File

@ -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<string, unknown>): 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<string, unknown> {
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<void> {
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<string, unknown> {
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<void> {
'没有找到最近的项目。请先在 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<void> {
async function handleCardAction(data: any): Promise<any> {
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<void> {
// 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)))
}
}