Files
aurak/server/src/feishu/feishu.controller.ts
T
Developer 0a9588abb7 feat: implement QuestionBank CRUD with pagination and template query
- Add pagination support to findAll (page, limit query params)
- Add findByTemplateId method to service
- Add GET /by-template/:templateId endpoint to controller
- Service already includes CRUD for QuestionBank and QuestionBankItem
2026-04-23 17:19:11 +08:00

292 lines
8.8 KiB
TypeScript

import {
Controller,
Post,
Get,
Delete,
Body,
Param,
Headers,
UseGuards,
Request,
Logger,
Patch,
Res,
} from '@nestjs/common';
import { Response } from 'express';
import { FeishuService } from './feishu.service';
import { FeishuAssessmentService } from './services/feishu-assessment.service';
import { CreateFeishuBotDto } from './dto/create-bot.dto';
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
import { Public } from '../auth/public.decorator';
import * as fs from 'fs';
import * as path from 'path';
@Controller('feishu')
export class FeishuController {
private readonly logger = new Logger(FeishuController.name);
constructor(
private readonly feishuService: FeishuService,
private readonly feishuAssessmentService: FeishuAssessmentService,
) {}
// ─── Bot Management Endpoints (JWT-protected) ─────────────────────────────
/** GET /feishu/bots - List user's bots, masking sensitive fields */
@Get('bots')
@UseGuards(CombinedAuthGuard)
async listBots(@Request() req) {
const bots = await this.feishuService.getUserBots(
req.user.id,
req.user.tenantId,
);
return bots.map((bot) => ({
id: bot.id,
appId: bot.appId,
botName: bot.botName,
enabled: bot.enabled,
isDefault: bot.isDefault,
webhookUrl: `/api/feishu/webhook/${bot.appId}`,
createdAt: bot.createdAt,
}));
}
/** POST /feishu/bots - Create or update a bot */
@Post('bots')
@UseGuards(CombinedAuthGuard)
async createBot(@Request() req, @Body() dto: CreateFeishuBotDto) {
const bot = await this.feishuService.createBot(
req.user.id,
req.user.tenantId,
dto,
);
return {
id: bot.id,
appId: bot.appId,
botName: bot.botName,
enabled: bot.enabled,
webhookUrl: `/api/feishu/webhook/${bot.appId}`,
};
}
/** PATCH /feishu/bots/:id/toggle - Enable or disable a bot */
@Patch('bots/:id/toggle')
@UseGuards(CombinedAuthGuard)
async toggleBot(
@Request() req,
@Param('id') botId: string,
@Body() body: { enabled: boolean },
) {
const bot = await this.feishuService.setBotEnabled(botId, body.enabled);
return { id: bot.id, enabled: bot.enabled };
}
/** DELETE /feishu/bots/:id - Delete a bot */
@Delete('bots/:id')
@UseGuards(CombinedAuthGuard)
async deleteBot(@Request() req, @Param('id') botId: string) {
await this.feishuService.deleteBot(req.user.id, botId);
return { success: true };
}
// ─── WebSocket Management Endpoints ────────────────────────────────────
/** POST /feishu/bots/:id/ws/connect - Start WebSocket connection */
@Post('bots/:id/ws/connect')
@UseGuards(CombinedAuthGuard)
async connectWs(@Request() req, @Param('id') botId: string) {
const bot = await this.feishuService.getBotById(botId);
if (!bot || bot.userId !== req.user.id) {
return { success: false, error: 'Bot not found' };
}
try {
await this.feishuService.startWsConnection(botId);
return { success: true, botId, status: 'connecting' };
} catch (error: any) {
return {
success: false,
botId,
error: error?.message || 'Failed to connect',
};
}
}
/** POST /feishu/bots/:id/ws/disconnect - Stop WebSocket connection */
@Post('bots/:id/ws/disconnect')
@UseGuards(CombinedAuthGuard)
async disconnectWs(@Request() req, @Param('id') botId: string) {
const bot = await this.feishuService.getBotById(botId);
if (!bot || bot.userId !== req.user.id) {
return { success: false, error: 'Bot not found' };
}
try {
await this.feishuService.stopWsConnection(botId);
return { success: true, botId, status: 'disconnected' };
} catch (error: any) {
return {
success: false,
botId,
error: error?.message || 'Failed to disconnect',
};
}
}
/** GET /feishu/bots/:id/ws/status - Get connection status */
@Get('bots/:id/ws/status')
@UseGuards(CombinedAuthGuard)
async getWsStatus(@Request() req, @Param('id') botId: string) {
const bot = await this.feishuService.getBotById(botId);
if (!bot || bot.userId !== req.user.id) {
return { success: false, error: 'Bot not found' };
}
const status = await this.feishuService.getWsStatus(botId);
if (!status) {
return { botId, state: 'disconnected' };
}
return {
botId: status.botId,
state: status.state,
connectedAt: status.connectedAt?.toISOString(),
lastHeartbeat: status.lastHeartbeat?.toISOString(),
error: status.error,
};
}
/** GET /feishu/ws/status - Get all active WS connection statuses */
@Get('ws/status')
@UseGuards(CombinedAuthGuard)
async getAllWsStatus() {
const statuses = await this.feishuService.getAllWsStatuses();
return {
connections: statuses.map((s) => ({
botId: s.botId,
state: s.state,
connectedAt: s.connectedAt?.toISOString(),
lastHeartbeat: s.lastHeartbeat?.toISOString(),
error: s.error,
})),
};
}
// ─── Feishu Webhook Endpoint (Public) ────────────────────────────────────
@Get('webhook/:appId')
@Post('webhook/:appId')
@Public()
async handleWebhook(
@Param('appId') appId: string,
@Body() body: any,
@Headers() headers: any,
@Request() req: any,
@Res() res: Response,
) {
const logEntry = `\n[${new Date().toISOString()}] ${req.method} /api/feishu/webhook/${appId}\nHeaders: ${JSON.stringify(headers)}\nBody: ${JSON.stringify(body)}\n`;
fs.appendFileSync('feishu_webhook.log', logEntry);
this.logger.log(
`Incoming Feishu webhook [${req.method}] for appId: ${appId}`,
);
// GET request for simple connection test
if (req.method === 'GET') {
return res.status(200).json({
status: 'ok',
message: 'AuraK Feishu Webhook is active.',
appId,
timestamp: new Date().toISOString(),
});
}
// Step 1: URL verification handshake
const challenge = body?.challenge || body?.event?.challenge;
if (body?.type === 'url_verification' || challenge) {
this.logger.log(
`URL verification active for appId: ${appId}, challenge: ${challenge}`,
);
return res.status(200).json({ challenge });
}
// Step 2: Return 200 immediately for all other events
res.status(200).json({ success: true });
// Step 3: Process the event asynchronously
if (body?.type === 'event_callback' || body?.header?.event_type) {
setImmediate(() =>
this._processEvent(appId, body).catch((e) =>
this.logger.error('Failed to process Feishu event async', e),
),
);
}
}
// ─── Private Event Processor ─────────────────────────────────────────────
private async _processEvent(appId: string, body: any): Promise<void> {
const { type, event, header } = body;
if (type !== 'event_callback') return;
const eventType = header?.event_type || body.event_type;
const eventId = header?.event_id;
this.logger.log(
`Processing Feishu event [${eventId}]: ${eventType} for appId: ${appId}`,
);
const bot = await this.feishuService.getBotByAppId(appId);
if (!bot || !bot.enabled) {
this.logger.warn(`Bot not found or disabled for appId: ${appId}`);
return;
}
switch (eventType) {
case 'im.message.receive_v1':
case 'im.message.p2p_msg_received':
case 'im.message.group_at_msg_received':
await this._handleMessage(bot, event);
break;
default:
this.logger.log(`Unhandled event type: ${eventType}`);
}
}
/**
* Parse incoming IM message and route to chatService via FeishuService.
* Implements Chunk 5 integration.
*/
private async _handleMessage(bot: any, event: any): Promise<void> {
const message = event?.message;
if (!message) return;
const messageId = message.message_id;
const openId = event?.sender?.sender_id?.open_id;
if (!openId) {
this.logger.warn('No sender open_id found in Feishu event');
return;
}
// Parse text content
let userText = '';
try {
const content = JSON.parse(message.content || '{}');
userText = content.text || '';
} catch {
this.logger.warn('Failed to parse Feishu message content');
return;
}
if (!userText.trim()) return;
try {
// Centralized routing via FeishuService
await this.feishuService.handleIncomingMessage(
bot,
openId,
messageId,
userText,
);
} catch (error) {
this.logger.error('Message handling failed', error);
}
}
}