0a9588abb7
- 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
292 lines
8.8 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|