From 4b7845e1b57074af8cb4188bd18b841981a8632b Mon Sep 17 00:00:00 2001 From: Leng Date: Tue, 1 Sep 2026 11:10:48 +0800 Subject: [PATCH 1/3] feat(im): wire Feishu /project channel switching Signed-off-by: Leng --- .../im/feishu/__tests__/feishuAdapter.test.ts | 29 +++++++++++++++++++ apps/desktop/src/main/im/feishu/adapter.ts | 2 ++ apps/desktop/src/main/im/feishu/uiText.ts | 16 ++++++++++ 3 files changed, 47 insertions(+) diff --git a/apps/desktop/src/main/im/feishu/__tests__/feishuAdapter.test.ts b/apps/desktop/src/main/im/feishu/__tests__/feishuAdapter.test.ts index ddabdc6290a..8169936663e 100644 --- a/apps/desktop/src/main/im/feishu/__tests__/feishuAdapter.test.ts +++ b/apps/desktop/src/main/im/feishu/__tests__/feishuAdapter.test.ts @@ -176,6 +176,35 @@ describe('feishu ImChannelAdapter characterization', () => { }); }); + it('/project 已接线: projectSwitching 为 true', () => { + expect(adapter.projectSwitching).toBe(true); + }); + + it('/project 卡文案已提供(orchestrator projectSwitching 断言不炸)', () => { + const project = adapter.ui.cards.project!; + expect(project.title).toBeTruthy(); + expect(project.hint).toBeTypeOf('function'); + expect(project.emptyBody).toBeTruthy(); + expect(project.btnDialogue).toBeTruthy(); + expect(project.btnCancel).toBeTruthy(); + expect(project.resolvedPick).toBeTypeOf('function'); + expect(project.resolvedDialogue).toBeTruthy(); + expect(project.resolvedCancel).toBeTruthy(); + expect(project.switchFailed).toBeTypeOf('function'); + expect(project.attachedUnsupported).toBeTruthy(); + expect(project.dialogueName).toBeTruthy(); + }); + + it('群/话题 lane 隔离: 两个话题各自 sessionId 独立, 切不同项目互不影响', () => { + const topicA = adapter.sessions.sessionIdFor('cli_abc', 'g/oc_chat1/t1'); + const topicB = adapter.sessions.sessionIdFor('cli_abc', 'g/oc_chat1/t2'); + const dm = adapter.sessions.sessionIdFor('cli_abc', 'ou_owner'); + expect(topicA).toBe('feishu_cli_abc_g-oc_chat1-t1'); + expect(topicB).toBe('feishu_cli_abc_g-oc_chat1-t2'); + expect(topicA).not.toBe(topicB); + expect(topicA).not.toBe(dm); + }); + it('默认 title 为 [飞书·DM] {openId 后 6 位}; ack emoji 为 SMUG', () => { expect(adapter.sessions.defaultTitle('ou_1234567890')).toBe('[飞书·DM] 567890'); expect(adapter.processingEmoji).toBe('SMUG'); diff --git a/apps/desktop/src/main/im/feishu/adapter.ts b/apps/desktop/src/main/im/feishu/adapter.ts index df8ec214480..4f16876f0fb 100644 --- a/apps/desktop/src/main/im/feishu/adapter.ts +++ b/apps/desktop/src/main/im/feishu/adapter.ts @@ -331,6 +331,8 @@ export function buildFeishuAdapter( }, processingEmoji: REACTION_PROCESSING, buildVendorOptions: (userId) => ({ feishuChatId: userId, source: 'feishu' }), + // /project: 把当前 lane 的会话切到 desktop 项目目录(lane 级独立, 话题互不影响)。 + projectSwitching: true, // 群轮次(speaker 存在)统一挂强确认策略 — 群历史前缀携带成员可控文本, // 注入可借 owner 轮次的宽松档执行危险操作; 确认卡经 deliverToOwnerDm diff --git a/apps/desktop/src/main/im/feishu/uiText.ts b/apps/desktop/src/main/im/feishu/uiText.ts index 7823a9fda74..82956921daf 100644 --- a/apps/desktop/src/main/im/feishu/uiText.ts +++ b/apps/desktop/src/main/im/feishu/uiText.ts @@ -173,6 +173,22 @@ export const ui = { resolved: '✅ 已切到「自动审批」— 回群里继续发消息吧~', failed: (reason: string) => `❌ 没切过去:${reason}。可以发 /permission auto 手动切换`, }, + project: { + title: '📂 换个项目目录', + hint: (currentName: string) => + `**当前**:${currentName}\n选一个项目,接下来的消息就在那个目录里干活(会重开上下文,模型和权限设置保留)`, + emptyBody: '_desktop 端还没有活跃的项目工作区~ 先在 desktop 里打开一个项目再来_', + btnDialogue: '💬 回到对话', + btnCancel: '🚪 算了', + resolvedPick: (displayName: string) => + `📂 已切到 **${displayName}** —— 新上下文已就位,直接发指令开干。想回普通聊天发 /project 选「回到对话」`, + resolvedDialogue: '💬 已回到对话目录,上下文重新开始~', + resolvedCancel: '🚪 没切,保持现状', + switchFailed: (reason: string) => `❌ 没切过去:${reason}(目录可能已被移动或删除)`, + attachedUnsupported: + '🎮 你正在 /ctr 接管一个 desktop 任务——接管期间消息直接进那个任务,不需要 /project。想切项目先 `/exctr` 退出接管', + dialogueName: '对话(托管目录)', + }, control: { title: '🎮 挑个工作区上号', emptyBody: '_暂时还没有可接管的工作区~ 在 desktop 端打开/创建一个任务再来_', From 229d0d85aa594f05d459782a350df3ce317b1eaf Mon Sep 17 00:00:00 2001 From: Leng Date: Tue, 1 Sep 2026 11:49:45 +0800 Subject: [PATCH 2/3] fix(im): reject stale group-card /project switches when lane is lost Signed-off-by: Leng --- apps/desktop/src/main/im/feishu/uiText.ts | 5 ++ .../cardActionGroupLaneGuard.test.ts | 52 +++++++++++++++++++ .../src/main/im/shared/cardActionHandler.ts | 12 ++++- apps/desktop/src/main/im/shared/types.ts | 2 + 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/im/feishu/uiText.ts b/apps/desktop/src/main/im/feishu/uiText.ts index 82956921daf..46e8bc6940c 100644 --- a/apps/desktop/src/main/im/feishu/uiText.ts +++ b/apps/desktop/src/main/im/feishu/uiText.ts @@ -187,6 +187,11 @@ export const ui = { switchFailed: (reason: string) => `❌ 没切过去:${reason}(目录可能已被移动或删除)`, attachedUnsupported: '🎮 你正在 /ctr 接管一个 desktop 任务——接管期间消息直接进那个任务,不需要 /project。想切项目先 `/exctr` 退出接管', + // 群卡认不出自己在哪条话题(应用重启过, 卡片和话题的对应关系只存在内存里)。 + // 跟 /ctr 同款 fail-closed: 宁可不切, 也不把项目切到私聊身份的会话行上。 + staleGroupCard: + '❌ 没切过去:这张卡片是应用重启前发的,已经认不出它属于哪条话题了。\n' + + '请在你想切项目的那条话题里重新发一次 `/project`(切换只跟话题走,不会串到别处)', dialogueName: '对话(托管目录)', }, control: { diff --git a/apps/desktop/src/main/im/shared/__tests__/cardActionGroupLaneGuard.test.ts b/apps/desktop/src/main/im/shared/__tests__/cardActionGroupLaneGuard.test.ts index bdff1ab8d17..f47e9c05bfd 100644 --- a/apps/desktop/src/main/im/shared/__tests__/cardActionGroupLaneGuard.test.ts +++ b/apps/desktop/src/main/im/shared/__tests__/cardActionGroupLaneGuard.test.ts @@ -171,6 +171,13 @@ const CONTROL_NEW: Partial = { payload: { botAppId: 'cli_abc', workingDir: '/tmp/proj', displayName: 'proj' }, }; +const PROJECT_PICK: Partial = { + messageId: 'om_picker', + buttonId: 'project:pick', + chatId: 'oc_chat1', + payload: { botAppId: 'cli_abc', workingDir: '/tmp/proj', displayName: 'proj' }, +}; + beforeEach(() => { vi.clearAllMocks(); activateImAccountBoundary(); @@ -240,3 +247,48 @@ describe('飞书群卡片 lane 丢失时的 /ctr fail-closed', () => { ); }); }); + +describe('飞书群卡片 lane 丢失时的 /project fail-closed', () => { + it('project:pick: 群卡 + senderId 是私聊 open_id ⇒ 不切目录, 提示重发 /project', async () => { + const im = makeIm(); + + await press(im, { ...PROJECT_PICK, senderId: 'ou_owner' }); + + // 关键断言: 绝不能切 —— 否则项目切到 owner 私聊身份的会话行上。 + const { switchSessionWorkingDir } = await import('../sessionRepo'); + expect(switchSessionWorkingDir).not.toHaveBeenCalled(); + expect(im.updateInteractiveCard).toHaveBeenCalledWith( + 'om_picker', + expect.objectContaining({ body: feishuUi.cards.project!.staleGroupCard }), + ); + }); + + it('project:dialogue: 同样拒绝', async () => { + const im = makeIm(); + + await press(im, { + ...PROJECT_PICK, + buttonId: 'project:dialogue', + senderId: 'ou_owner', + }); + + const { switchSessionWorkingDir } = await import('../sessionRepo'); + expect(switchSessionWorkingDir).not.toHaveBeenCalled(); + expect(im.updateInteractiveCard).toHaveBeenCalledWith( + 'om_picker', + expect.objectContaining({ body: feishuUi.cards.project!.staleGroupCard }), + ); + }); + + it('senderId 是话题 lane 时不拦(正常路径不受 guard 影响)', async () => { + const im = makeIm(); + + await press(im, { ...PROJECT_PICK, senderId: 'g/oc_chat1/omt_t1' }); + + // guard 没触发: 卡片不会被 patch 成 staleGroupCard 提示(后续步骤会走 + // resolveRouteTarget / fs.statSync, 这里只钉住 guard 本身没误拦)。 + const staleBody = feishuUi.cards.project!.staleGroupCard!; + const calls = (im.updateInteractiveCard as ReturnType).mock.calls; + expect(calls.every(([, spec]) => (spec as { body?: string }).body !== staleBody)).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/im/shared/cardActionHandler.ts b/apps/desktop/src/main/im/shared/cardActionHandler.ts index 036d609bd22..624fc1c54d1 100644 --- a/apps/desktop/src/main/im/shared/cardActionHandler.ts +++ b/apps/desktop/src/main/im/shared/cardActionHandler.ts @@ -150,16 +150,18 @@ export function createCardActionHandler( im: ChannelIM, event: IMCardActionEvent, where: string, + body?: string, ): Promise { log.warn( `${where}: group card lane unresolved (senderId=...${event.senderId.slice(-8)} is not a lane) — ` + 'refusing to attach so the takeover cannot land on the DM identity', ); - const body = + const text = + body ?? ui.cards.control.staleGroupCard ?? ui.cards.control.attachFailed('card lane unresolved (app restarted)'); try { - await im.updateInteractiveCard(event.messageId, cards.buildResolvedCard(body)); + await im.updateInteractiveCard(event.messageId, cards.buildResolvedCard(text)); } catch (err) { const msg = err instanceof Error ? err.message : String(err); log.warn(`${where}: stale group card patch failed (non-fatal): ${msg}`); @@ -1232,6 +1234,12 @@ export function createCardActionHandler( log.warn('project switch missing botAppId — ignoring'); return; } + // 群卡认不出自己在哪条话题(app 重启过)时绝不切 —— 切换会落到私聊身份的 + // 会话行上, 卡片还报「成功」。fail-closed, 让用户在目标话题里重发 /project。 + if (isGroupCardWithLostLane(event)) { + await rejectStaleGroupCard(im, event, 'project:switch', projectUi.staleGroupCard); + return; + } // 接管期间语义冲突(slash 层已拦, 这里兜旧卡片迟到按压)。 const attached = bindingStore.get({ channel, diff --git a/apps/desktop/src/main/im/shared/types.ts b/apps/desktop/src/main/im/shared/types.ts index f514b3cb45a..7088e55f558 100644 --- a/apps/desktop/src/main/im/shared/types.ts +++ b/apps/desktop/src/main/im/shared/types.ts @@ -400,6 +400,8 @@ export interface ImUiTextPack { switchFailed: (reason: string) => string; /** /ctr 接管期间不支持切项目(先 /exctr)。 */ attachedUnsupported: string; + /** 群卡认不出自己在哪条话题(应用重启过)时的 fail-closed 提示。 */ + staleGroupCard?: string; /** 当前目录显示名为托管对话目录时的称呼。 */ dialogueName: string; }; From 42db29a88080234baaa695d7a41bd9f82ddb290e Mon Sep 17 00:00:00 2001 From: Leng Date: Tue, 1 Sep 2026 13:18:45 +0800 Subject: [PATCH 3/3] chore: trigger CI re-run (upstream Windows flake) Signed-off-by: Leng