From e2057bfe68cc8a83db33c295af2c7981393125fd Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 9 Mar 2026 06:31:45 -0700 Subject: [PATCH] feat(feishu): add Feishu OAuth trigger for voice sessions - Add POST /sessions/:sessionId/feishu/oauth-trigger endpoint (mirrors DingTalk) which emits oauth_prompt WS event so Flutter opens the Feishu authorization page automatically instead of asking the user to enter a bind code - Update SystemPromptBuilder: voice sessions now use the Feishu OAuth trigger endpoint; text sessions still use the code-based flow as fallback Co-Authored-By: Claude Sonnet 4.6 --- .../claude-code-cli/system-prompt-builder.ts | 34 ++++++++++---- .../controllers/voice-session.controller.ts | 46 +++++++++++++++++++ 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/packages/services/agent-service/src/infrastructure/engines/claude-code-cli/system-prompt-builder.ts b/packages/services/agent-service/src/infrastructure/engines/claude-code-cli/system-prompt-builder.ts index 836378b..e6b794c 100644 --- a/packages/services/agent-service/src/infrastructure/engines/claude-code-cli/system-prompt-builder.ts +++ b/packages/services/agent-service/src/infrastructure/engines/claude-code-cli/system-prompt-builder.ts @@ -38,8 +38,8 @@ export class SystemPromptBuilder { */ build(context: SystemPromptContext): string { const parts: string[] = []; - // Build the DingTalk OAuth trigger instruction once, reused in the prompt below. - // Voice sessions (sessionId present): use Bash/wget to call the internal endpoint — + // Build channel OAuth trigger instructions. + // Voice sessions (sessionId present): use Bash/wget to call the internal session endpoint — // works for BOTH claude_api and claude_agent_sdk engines. // Text sessions (no sessionId): use the initiate_dingtalk_binding tool (claude_api only). const dingtalkOauthStep = context.sessionId @@ -56,6 +56,27 @@ export class SystemPromptBuilder { ` Say: "请查看屏幕上弹出的授权卡片,点击「立即授权」按钮,在钉钉里完成一键授权就好了。"\n` + ` Do NOT read out the URL — the card handles it automatically.`; + const feishuOauthStep = context.sessionId + ? ` *** Use ONLY feishu endpoints — /channels/feishu/... ***\n` + + ` Step 3a — Trigger Feishu OAuth (Bash/wget):\n` + + ` wget -q -O- --post-data='{"instanceId":"","instanceName":""}' \\\n` + + ` --header="Content-Type: application/json" \\\n` + + ` http://localhost:3002/api/v1/agent/sessions/${context.sessionId}/feishu/oauth-trigger\n` + + ` This automatically pushes a Feishu OAuth authorization page to the user's screen.\n` + + ` Say: "请在弹出的飞书授权页面点击「同意授权」,完成后绑定会自动生效。"\n` + + ` Do NOT read out the URL — the page opens automatically.\n` + + ` Step 3b — Poll to confirm (ONLY feishu endpoint, every 5s up to 3 min):\n` + + ` wget -q -O- http://localhost:3002/api/v1/agent/channels/feishu/status/\n` + + ` If { "bound": true }: "太好了!飞书绑定成功了!"` + : ` *** Use ONLY feishu endpoints — /channels/feishu/... ***\n` + + ` Get bind code (ONLY feishu endpoint):\n` + + ` wget -q -O- --post-data="" http://localhost:3002/api/v1/agent/channels/feishu/bind/\n` + + ` Parse JSON: { "code": "", "expiresAt": }\n` + + ` Say: "飞书绑定验证码是「」,有效15分钟。请打开飞书,找到 iAgent 机器人,把这个验证码发给它,就完成绑定啦。"\n` + + ` Poll to confirm (ONLY feishu endpoint, every 5s up to 3 min):\n` + + ` wget -q -O- http://localhost:3002/api/v1/agent/channels/feishu/status/\n` + + ` If { "bound": true }: "太好了!飞书绑定成功了!"`; + // Base instruction parts.push( 'You are iAgent, an AI-powered server cluster operations assistant built on IT0. ' + @@ -88,14 +109,7 @@ export class SystemPromptBuilder { ' If { "bound": true }: "钉钉绑定成功!"\n' + ' If still false: "授权还没完成,请确认是否点击了钉钉里的授权按钮。"\n\n' + ' IF user chose 飞书 (Feishu) OR both:\n' + - ' *** Use ONLY feishu endpoints — /channels/feishu/... ***\n' + - ' Get bind code (ONLY feishu endpoint):\n' + - ' wget -q -O- --post-data="" http://localhost:3002/api/v1/agent/channels/feishu/bind/\n' + - ' Parse JSON: { "code": "", "expiresAt": }\n' + - ' Say: "飞书绑定验证码是「」,有效15分钟。请打开飞书,找到 iAgent 机器人,把这个验证码发给它,就完成绑定啦。"\n' + - ' Poll to confirm (ONLY feishu endpoint, every 5s up to 3 min):\n' + - ' wget -q -O- http://localhost:3002/api/v1/agent/channels/feishu/status/\n' + - ' If { "bound": true }: "太好了!飞书绑定成功了!"\n\n' + + `${feishuOauthStep}\n\n` + ' IF user chose skip / neither: "好的,之后想绑定随时可以在 IT0 App 里操作。"\n\n' + ' CRITICAL: dingtalk endpoints (/channels/dingtalk/) and feishu endpoints (/channels/feishu/) are completely separate. Never call a dingtalk endpoint for feishu or vice versa.\n\n' + ' Check/unbind:\n' + diff --git a/packages/services/agent-service/src/interfaces/rest/controllers/voice-session.controller.ts b/packages/services/agent-service/src/interfaces/rest/controllers/voice-session.controller.ts index 6a62543..efcdaf9 100644 --- a/packages/services/agent-service/src/interfaces/rest/controllers/voice-session.controller.ts +++ b/packages/services/agent-service/src/interfaces/rest/controllers/voice-session.controller.ts @@ -22,6 +22,7 @@ import { SessionRepository } from '../../../infrastructure/repositories/session. import { SystemPromptBuilder } from '../../../infrastructure/engines/claude-code-cli/system-prompt-builder'; import { AgentStreamGateway } from '../../ws/agent-stream.gateway'; import { DingTalkRouterService } from '../../../infrastructure/dingtalk/dingtalk-router.service'; +import { FeishuRouterService } from '../../../infrastructure/feishu/feishu-router.service'; import { AgentInstanceRepository } from '../../../infrastructure/repositories/agent-instance.repository'; import { AgentEngineType } from '../../../domain/value-objects/agent-engine-type.vo'; import { AgentSession } from '../../../domain/entities/agent-session.entity'; @@ -50,6 +51,7 @@ export class VoiceSessionController { private readonly systemPromptBuilder: SystemPromptBuilder, private readonly gateway: AgentStreamGateway, private readonly dingTalkRouter: DingTalkRouterService, + private readonly feishuRouter: FeishuRouterService, private readonly instanceRepo: AgentInstanceRepository, ) {} @@ -260,4 +262,48 @@ export class VoiceSessionController { return { triggered: true, instanceId: body.instanceId, instanceName }; } + + /** + * Internal Feishu OAuth trigger — called by Claude via Bash/wget. + * + * Mirrors the DingTalk oauth-trigger. Generates a Feishu OAuth URL and + * emits it as an `oauth_prompt` WS event so the Flutter app opens the + * authorization page automatically. + */ + @Post(':sessionId/feishu/oauth-trigger') + async triggerFeishuOAuth( + @Param('sessionId') sessionId: string, + @Body() body: { instanceId: string; instanceName?: string }, + ) { + if (!body.instanceId) { + throw new BadRequestException('instanceId is required'); + } + + if (!this.feishuRouter.isEnabled()) { + return { triggered: false, reason: 'Feishu integration not configured' }; + } + + const inst = await this.instanceRepo.findById(body.instanceId); + if (!inst) { + throw new NotFoundException(`Instance ${body.instanceId} not found`); + } + + const instanceName = body.instanceName || inst.name || body.instanceId; + const { oauthUrl } = this.feishuRouter.generateOAuthUrl(body.instanceId); + + this.oauthPendingSessions.set(sessionId, Date.now() + OAUTH_GRACE_MS); + + this.gateway.emitStreamEvent(sessionId, { + type: 'oauth_prompt', + url: oauthUrl, + instanceId: body.instanceId, + instanceName, + }); + + this.logger.log( + `[VoiceSession ${sessionId}] Feishu OAuth triggered for instance=${body.instanceId}`, + ); + + return { triggered: true, instanceId: body.instanceId, instanceName }; + } }