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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-09 06:31:45 -07:00
parent 75f20075f6
commit e2057bfe68
2 changed files with 70 additions and 10 deletions

View File

@ -38,8 +38,8 @@ export class SystemPromptBuilder {
*/ */
build(context: SystemPromptContext): string { build(context: SystemPromptContext): string {
const parts: string[] = []; const parts: string[] = [];
// Build the DingTalk OAuth trigger instruction once, reused in the prompt below. // Build channel OAuth trigger instructions.
// Voice sessions (sessionId present): use Bash/wget to call the internal endpoint — // Voice sessions (sessionId present): use Bash/wget to call the internal session endpoint —
// works for BOTH claude_api and claude_agent_sdk engines. // works for BOTH claude_api and claude_agent_sdk engines.
// Text sessions (no sessionId): use the initiate_dingtalk_binding tool (claude_api only). // Text sessions (no sessionId): use the initiate_dingtalk_binding tool (claude_api only).
const dingtalkOauthStep = context.sessionId const dingtalkOauthStep = context.sessionId
@ -56,6 +56,27 @@ export class SystemPromptBuilder {
` Say: "请查看屏幕上弹出的授权卡片,点击「立即授权」按钮,在钉钉里完成一键授权就好了。"\n` + ` Say: "请查看屏幕上弹出的授权卡片,点击「立即授权」按钮,在钉钉里完成一键授权就好了。"\n` +
` Do NOT read out the URL — the card handles it automatically.`; ` 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":"<instanceId>","instanceName":"<name>"}' \\\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/<instanceId>\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/<instanceId>\n` +
` Parse JSON: { "code": "<CODE>", "expiresAt": <ms> }\n` +
` Say: "飞书绑定验证码是「<CODE>」有效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/<instanceId>\n` +
` If { "bound": true }: "太好了!飞书绑定成功了!"`;
// Base instruction // Base instruction
parts.push( parts.push(
'You are iAgent, an AI-powered server cluster operations assistant built on IT0. ' + '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 { "bound": true }: "钉钉绑定成功!"\n' +
' If still false: "授权还没完成,请确认是否点击了钉钉里的授权按钮。"\n\n' + ' If still false: "授权还没完成,请确认是否点击了钉钉里的授权按钮。"\n\n' +
' IF user chose 飞书 (Feishu) OR both:\n' + ' IF user chose 飞书 (Feishu) OR both:\n' +
' *** Use ONLY feishu endpoints — /channels/feishu/... ***\n' + `${feishuOauthStep}\n\n` +
' Get bind code (ONLY feishu endpoint):\n' +
' wget -q -O- --post-data="" http://localhost:3002/api/v1/agent/channels/feishu/bind/<instanceId>\n' +
' Parse JSON: { "code": "<CODE>", "expiresAt": <ms> }\n' +
' Say: "飞书绑定验证码是「<CODE>」有效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/<instanceId>\n' +
' If { "bound": true }: "太好了!飞书绑定成功了!"\n\n' +
' IF user chose skip / neither: "好的,之后想绑定随时可以在 IT0 App 里操作。"\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' + ' 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' + ' Check/unbind:\n' +

View File

@ -22,6 +22,7 @@ import { SessionRepository } from '../../../infrastructure/repositories/session.
import { SystemPromptBuilder } from '../../../infrastructure/engines/claude-code-cli/system-prompt-builder'; import { SystemPromptBuilder } from '../../../infrastructure/engines/claude-code-cli/system-prompt-builder';
import { AgentStreamGateway } from '../../ws/agent-stream.gateway'; import { AgentStreamGateway } from '../../ws/agent-stream.gateway';
import { DingTalkRouterService } from '../../../infrastructure/dingtalk/dingtalk-router.service'; 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 { AgentInstanceRepository } from '../../../infrastructure/repositories/agent-instance.repository';
import { AgentEngineType } from '../../../domain/value-objects/agent-engine-type.vo'; import { AgentEngineType } from '../../../domain/value-objects/agent-engine-type.vo';
import { AgentSession } from '../../../domain/entities/agent-session.entity'; import { AgentSession } from '../../../domain/entities/agent-session.entity';
@ -50,6 +51,7 @@ export class VoiceSessionController {
private readonly systemPromptBuilder: SystemPromptBuilder, private readonly systemPromptBuilder: SystemPromptBuilder,
private readonly gateway: AgentStreamGateway, private readonly gateway: AgentStreamGateway,
private readonly dingTalkRouter: DingTalkRouterService, private readonly dingTalkRouter: DingTalkRouterService,
private readonly feishuRouter: FeishuRouterService,
private readonly instanceRepo: AgentInstanceRepository, private readonly instanceRepo: AgentInstanceRepository,
) {} ) {}
@ -260,4 +262,48 @@ export class VoiceSessionController {
return { triggered: true, instanceId: body.instanceId, instanceName }; 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 };
}
} }