mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
fc7d6d487c
commit
77974f7052
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user