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
This commit is contained in:
Developer
2026-04-23 17:19:11 +08:00
commit 0a9588abb7
492 changed files with 112453 additions and 0 deletions
@@ -0,0 +1,36 @@
/**
* Assessment Command DTO
* 定义飞书机器人测评命令的类型和接口
*/
export enum AssessmentCommandType {
START = 'start',
ANSWER = 'answer',
STATUS = 'status',
RESULT = 'result',
HELP = 'help',
CANCEL = 'cancel',
}
export interface AssessmentCommand {
type: AssessmentCommandType;
parameters: string[];
rawMessage: string;
timestamp: Date;
}
export class AssessmentCommandDto {
type: AssessmentCommandType;
parameters: string[];
rawMessage: string;
constructor(
type: AssessmentCommandType,
parameters: string[],
rawMessage: string,
) {
this.type = type;
this.parameters = parameters;
this.rawMessage = rawMessage;
}
}
+11
View File
@@ -0,0 +1,11 @@
import { IsString, IsNotEmpty, IsUUID } from 'class-validator';
export class BindFeishuBotDto {
@IsUUID()
@IsNotEmpty()
botId: string;
@IsString()
@IsNotEmpty()
verificationCode?: string; // Optional: used to validate the binding relationship
}
+35
View File
@@ -0,0 +1,35 @@
import { IsString, IsNotEmpty, IsOptional, IsBoolean } from 'class-validator';
export class CreateFeishuBotDto {
@IsString()
@IsNotEmpty()
appId: string;
@IsString()
@IsNotEmpty()
appSecret: string;
@IsString()
@IsOptional()
verificationToken?: string;
@IsString()
@IsOptional()
encryptKey?: string;
@IsString()
@IsOptional()
botName?: string;
@IsBoolean()
@IsOptional()
enabled?: boolean;
@IsString()
@IsOptional()
knowledgeBaseId?: string;
@IsString()
@IsOptional()
knowledgeGroupId?: string;
}
+20
View File
@@ -0,0 +1,20 @@
import { IsString, IsOptional } from 'class-validator';
export class CreateSignatureDto {
@IsString()
@IsOptional()
timestamp?: string;
@IsString()
@IsOptional()
nonce?: string;
}
export class VerifyWebhookDto {
@IsString()
token: string;
@IsString()
@IsOptional()
challenge?: string;
}
+39
View File
@@ -0,0 +1,39 @@
export enum ConnectionState {
DISCONNECTED = 'disconnected',
CONNECTING = 'connecting',
CONNECTED = 'connected',
ERROR = 'error',
}
export interface ConnectionStatus {
botId: string;
state: ConnectionState;
connectedAt?: Date;
lastHeartbeat?: Date;
error?: string;
}
export class WsStatusResponseDto {
botId: string;
state: ConnectionState;
connectedAt?: string;
lastHeartbeat?: string;
error?: string;
}
export class WsConnectResponseDto {
success: boolean;
botId: string;
status: ConnectionState | string;
error?: string;
}
export class WsDisconnectResponseDto {
success: boolean;
botId: string;
status: ConnectionState | string;
}
export class AllWsStatusResponseDto {
connections: WsStatusResponseDto[];
}
@@ -0,0 +1,46 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { FeishuBot } from './feishu-bot.entity';
@Entity('feishu_assessment_sessions')
export class FeishuAssessmentSession {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'bot_id' })
botId: string;
@Column({ name: 'open_id' })
openId: string;
@Column({ name: 'assessment_session_id' })
assessmentSessionId: string;
@Column({
type: 'varchar',
enum: ['active', 'completed', 'cancelled'],
default: 'active',
})
status: 'active' | 'completed' | 'cancelled';
@Column({ name: 'current_question_index', default: 0 })
currentQuestionIndex: number;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// 关联关系
@ManyToOne(() => FeishuBot, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'bot_id' })
bot: FeishuBot;
}
@@ -0,0 +1,74 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../user/user.entity';
@Entity('feishu_bots')
export class FeishuBot {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id' })
userId: string;
@Column({ name: 'tenant_id', nullable: true })
tenantId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ name: 'app_id', length: 64 })
appId: string;
@Column({ name: 'app_secret', length: 256 })
appSecret: string;
@Column({ name: 'tenant_access_token', nullable: true, type: 'text' })
tenantAccessToken: string;
@Column({ name: 'token_expires_at', nullable: true, type: 'datetime' })
tokenExpiresAt: Date;
@Column({ name: 'verification_token', nullable: true, length: 128 })
verificationToken: string;
@Column({ name: 'encrypt_key', nullable: true, length: 256 })
encryptKey: string;
@Column({ name: 'bot_name', nullable: true, length: 128 })
botName: string;
@Column({ default: true })
enabled: boolean;
@Column({ name: 'is_default', default: false })
isDefault: boolean;
@Column({ name: 'webhook_url', nullable: true, type: 'text' })
webhookUrl: string;
@Column({ name: 'use_web_socket', default: false })
useWebSocket: boolean;
@Column({ name: 'ws_connection_state', nullable: true, length: 32 })
wsConnectionState: string;
@Column({ name: 'knowledge_base_id', nullable: true, length: 36 })
knowledgeBaseId: string;
@Column({ name: 'knowledge_group_id', nullable: true, length: 36 })
knowledgeGroupId: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
+237
View File
@@ -0,0 +1,237 @@
import { Injectable, Logger } from '@nestjs/common';
import {
WSClient,
EventDispatcher,
LoggerLevel,
} from '@larksuiteoapi/node-sdk';
import { FeishuBot } from './entities/feishu-bot.entity';
import { ConnectionState, ConnectionStatus } from './dto/ws-status.dto';
interface BotConnection {
client: WSClient;
status: ConnectionStatus;
}
@Injectable()
export class FeishuWsManager {
private readonly logger = new Logger(FeishuWsManager.name);
private connections: Map<string, BotConnection> = new Map();
private reconnectAttempts: Map<string, number> = new Map();
private readonly MAX_RECONNECT_ATTEMPTS = 5;
private readonly RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000];
// Injected after construction to avoid circular dep
private _feishuService: any;
setFeishuService(service: any): void {
this._feishuService = service;
}
/**
* Start WebSocket connection for a bot
*/
async connect(bot: FeishuBot): Promise<void> {
const botId = bot.id;
// Check if already connected or connecting
const existing = this.connections.get(botId);
if (
existing &&
(existing.status.state === ConnectionState.CONNECTED ||
existing.status.state === ConnectionState.CONNECTING)
) {
this.logger.warn(`Bot ${botId} is already connecting or connected`);
return;
}
// Mark as connecting immediately to prevent race conditions
this.connections.set(botId, {
client: null as any,
status: { botId, state: ConnectionState.CONNECTING },
});
try {
const wsClient = new WSClient({
appId: bot.appId,
appSecret: bot.appSecret,
loggerLevel: LoggerLevel.info,
logger: {
debug: (msg: string) => this.logger.debug(msg),
info: (msg: string) => this.logger.log(msg),
warn: (msg: string) => this.logger.warn(msg),
error: (msg: string) => this.logger.error(msg),
trace: (msg: string) => this.logger.verbose(msg),
},
});
// Register the main message event handler
wsClient.start({
eventDispatcher: new EventDispatcher({}).register({
'im.message.receive_v1': async (data: any) => {
await this._handleMessage(bot, data);
},
}),
});
const conn = this.connections.get(botId);
if (conn) {
conn.client = wsClient;
conn.status = {
botId,
state: ConnectionState.CONNECTED,
connectedAt: new Date(),
};
}
this.reconnectAttempts.set(botId, 0);
this.logger.log(`WebSocket connected for bot ${botId}`);
} catch (error: any) {
this.logger.error(`Failed to connect WebSocket for bot ${botId}`, error);
this._setStatus(botId, {
botId,
state: ConnectionState.ERROR,
error: error?.message || 'Connection failed',
});
throw error;
}
}
/**
* Disconnect WebSocket for a bot
*/
async disconnect(botId: string): Promise<void> {
const connection = this.connections.get(botId);
if (!connection) {
this.logger.warn(`No connection found for bot ${botId}`);
return;
}
try {
// Lark.WSClient does not expose a public stop() in all versions;
// we simply remove the reference and let GC clean up.
this.connections.delete(botId);
this.reconnectAttempts.delete(botId);
this.logger.log(`WebSocket disconnected for bot ${botId}`);
} catch (error) {
this.logger.error(`Error disconnecting bot ${botId}`, error);
throw error;
}
}
/**
* Get connection status for a specific bot
*/
getStatus(botId: string): ConnectionStatus | null {
return this.connections.get(botId)?.status ?? null;
}
/**
* Get all active connection statuses
*/
getAllStatuses(): ConnectionStatus[] {
return Array.from(this.connections.values()).map((c) => c.status);
}
/**
* Returns true if the bot has an active connection
*/
isConnected(botId: string): boolean {
return (
this.connections.get(botId)?.status.state === ConnectionState.CONNECTED
);
}
// ─── Private Helpers ──────────────────────────────────────────────────────
private _setStatus(botId: string, status: ConnectionStatus): void {
const connection = this.connections.get(botId);
if (connection) {
connection.status = status;
}
// If connection not stored yet (during initial connect), we just skip —
// the status will be set when the connection map entry is created.
}
/**
* Handle an incoming im.message.receive_v1 event from Feishu WebSocket.
*/
private async _handleMessage(bot: FeishuBot, data: any): Promise<void> {
this.logger.log(`Received WS message for bot ${bot.id}`);
try {
const event = data?.event ?? data;
const message = event?.message;
if (!message) {
this.logger.warn('No message field in WS event');
return;
}
const messageId: string = message.message_id;
const openId: string | undefined = event?.sender?.sender_id?.open_id;
if (!openId) {
this.logger.warn('No sender open_id in WS event');
return;
}
let userText = '';
try {
const content = JSON.parse(message.content || '{}');
userText = content.text || '';
} catch {
this.logger.warn('Failed to parse WS message content');
return;
}
if (!userText.trim()) return;
if (this._feishuService) {
await this._feishuService.handleIncomingMessage(
bot,
openId,
messageId,
userText,
);
} else {
this.logger.error('FeishuService not injected into FeishuWsManager');
}
} catch (error) {
this.logger.error('Error in _handleMessage', error);
}
}
/**
* Schedule an auto-reconnect with exponential backoff.
*/
async attemptReconnect(bot: FeishuBot): Promise<void> {
const botId = bot.id;
const attempts = this.reconnectAttempts.get(botId) ?? 0;
if (attempts >= this.MAX_RECONNECT_ATTEMPTS) {
this.logger.error(`Max reconnect attempts reached for bot ${botId}`);
this._setStatus(botId, {
botId,
state: ConnectionState.ERROR,
error: 'Max reconnect attempts reached',
});
return;
}
const delay =
this.RECONNECT_DELAYS[attempts] ??
this.RECONNECT_DELAYS[this.RECONNECT_DELAYS.length - 1];
this.logger.log(
`Reconnecting bot ${botId} in ${delay}ms (attempt ${attempts + 1})`,
);
this.reconnectAttempts.set(botId, attempts + 1);
setTimeout(async () => {
try {
await this.connect(bot);
} catch (error) {
this.logger.error(`Reconnect failed for bot ${botId}`, error);
}
}, delay);
}
}
+291
View File
@@ -0,0 +1,291 @@
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);
}
}
}
+32
View File
@@ -0,0 +1,32 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeishuController } from './feishu.controller';
import { FeishuService } from './feishu.service';
import { FeishuBot } from './entities/feishu-bot.entity';
import { FeishuAssessmentSession } from './entities/feishu-assessment-session.entity';
import { FeishuWsManager } from './feishu-ws.manager';
import { FeishuAssessmentService } from './services/feishu-assessment.service';
import { AssessmentCommandParser } from './services/assessment-command.parser';
import { ChatModule } from '../chat/chat.module';
import { UserModule } from '../user/user.module';
import { ModelConfigModule } from '../model-config/model-config.module';
import { AssessmentModule } from '../assessment/assessment.module';
@Module({
imports: [
TypeOrmModule.forFeature([FeishuBot, FeishuAssessmentSession]),
forwardRef(() => ChatModule),
forwardRef(() => UserModule),
forwardRef(() => ModelConfigModule),
forwardRef(() => AssessmentModule),
],
controllers: [FeishuController],
providers: [
FeishuService,
FeishuWsManager,
FeishuAssessmentService,
AssessmentCommandParser,
],
exports: [FeishuService, FeishuAssessmentService, TypeOrmModule],
})
export class FeishuModule {}
+583
View File
@@ -0,0 +1,583 @@
import {
Injectable,
Logger,
forwardRef,
Inject,
OnModuleInit,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import axios from 'axios';
import { FeishuBot } from './entities/feishu-bot.entity';
import { CreateFeishuBotDto } from './dto/create-bot.dto';
import { ChatService } from '../chat/chat.service';
import { ModelConfigService } from '../model-config/model-config.service';
import { UserService } from '../user/user.service';
import { ModelType } from '../types';
import { FeishuWsManager } from './feishu-ws.manager';
import { ConnectionStatus } from './dto/ws-status.dto';
import { FeishuAssessmentService } from './services/feishu-assessment.service';
import { tenantStore } from '../tenant/tenant.store';
import { i18nStore } from '../i18n/i18n.store';
@Injectable()
export class FeishuService implements OnModuleInit {
private readonly logger = new Logger(FeishuService.name);
private readonly feishuApiBase = 'https://open.feishu.cn/open-apis';
constructor(
@InjectRepository(FeishuBot)
private botRepository: Repository<FeishuBot>,
@Inject(forwardRef(() => ChatService))
private chatService: ChatService,
@Inject(forwardRef(() => ModelConfigService))
private modelConfigService: ModelConfigService,
@Inject(forwardRef(() => UserService))
private userService: UserService,
private wsManager: FeishuWsManager,
@Inject(forwardRef(() => FeishuAssessmentService))
private feishuAssessmentService: FeishuAssessmentService,
) {}
onModuleInit(): void {
// Break circular dep: inject self into manager after module is ready
this.wsManager.setFeishuService(this);
}
async createBot(
userId: string,
tenantId: string,
dto: CreateFeishuBotDto,
): Promise<FeishuBot> {
const existing = await this.botRepository.findOne({
where: { userId, appId: dto.appId, tenantId },
});
if (existing) {
Object.assign(existing, dto);
return this.botRepository.save(existing);
}
const bot = this.botRepository.create({ userId, tenantId, ...dto });
return this.botRepository.save(bot);
}
async getUserBots(userId: string, tenantId: string): Promise<FeishuBot[]> {
return this.botRepository.find({
where: { userId, tenantId },
relations: ['user'],
});
}
async getBotById(botId: string): Promise<FeishuBot | null> {
return this.botRepository.findOne({
where: { id: botId },
relations: ['user'],
});
}
async getBotByAppId(appId: string): Promise<FeishuBot | null> {
return this.botRepository.findOne({
where: { appId },
relations: ['user'],
});
}
async setBotEnabled(botId: string, enabled: boolean): Promise<FeishuBot> {
const bot = await this.botRepository.findOne({ where: { id: botId } });
if (!bot) throw new Error('Bot not found');
bot.enabled = enabled;
return this.botRepository.save(bot);
}
async deleteBot(userId: string, botId: string): Promise<void> {
await this.botRepository.delete({ id: botId, userId });
}
// ─── Feishu API Calls ─────────────────────────────────────────────────────────
/**
* Get or refresh tenant_access_token, cached per bot in DB
*/
async getValidToken(bot: FeishuBot): Promise<string> {
if (
bot.tokenExpiresAt &&
bot.tenantAccessToken &&
new Date(bot.tokenExpiresAt) > new Date(Date.now() + 30 * 60 * 1000)
) {
return bot.tenantAccessToken;
}
this.logger.log(`Refreshing access token for bot: ${bot.appId}`);
const { data } = await axios.post<{
code: number;
msg: string;
tenant_access_token: string;
expire: number;
}>(`${this.feishuApiBase}/auth/v3/tenant_access_token/internal`, {
app_id: bot.appId,
app_secret: bot.appSecret,
});
if (data.code !== 0) {
throw new Error(`Failed to get Feishu token: ${data.msg}`);
}
bot.tenantAccessToken = data.tenant_access_token;
bot.tokenExpiresAt = new Date(Date.now() + data.expire * 1000);
await this.botRepository.save(bot);
return data.tenant_access_token;
}
/**
* Send a card message to a Feishu user
*/
async sendCardMessage(
bot: FeishuBot,
receiveIdType: 'open_id' | 'user_id' | 'chat_id',
receiveId: string,
card: any,
): Promise<string> {
const token = await this.getValidToken(bot);
const { data } = await axios.post<{
code: number;
msg: string;
data: { message_id: string };
}>(
`${this.feishuApiBase}/im/v1/messages?receive_id_type=${receiveIdType}`,
{
receive_id: receiveId,
msg_type: 'interactive',
content: JSON.stringify(card),
},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (data.code !== 0) {
throw new Error(`Failed to send Feishu card: ${data.msg}`);
}
return data.data.message_id;
}
/**
* Send a simple text message to a Feishu user
*/
async sendTextMessage(
bot: FeishuBot,
receiveIdType: 'open_id' | 'user_id' | 'chat_id',
receiveId: string,
text: string,
): Promise<string> {
const token = await this.getValidToken(bot);
const { data } = await axios.post<{
code: number;
msg: string;
data: { message_id: string };
}>(
`${this.feishuApiBase}/im/v1/messages?receive_id_type=${receiveIdType}`,
{
receive_id: receiveId,
msg_type: 'text',
content: JSON.stringify({ text }),
},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (data.code !== 0) {
throw new Error(`Failed to send Feishu message: ${data.msg}`);
}
return data.data.message_id;
}
/**
* Update an already-sent message (supports interactive cards)
*/
async updateMessage(
bot: FeishuBot,
messageId: string,
content: any,
msgType: 'text' | 'interactive' = 'interactive',
): Promise<void> {
const token = await this.getValidToken(bot);
const { data } = await axios.patch<{ code: number; msg: string }>(
`${this.feishuApiBase}/im/v1/messages/${messageId}`,
{ msg_type: msgType, content: JSON.stringify(content) },
{ headers: { Authorization: `Bearer ${token}` } },
);
if (data.code !== 0) {
this.logger.warn(`Failed to update Feishu message: ${data.msg}`);
}
}
/**
* Build a professional Feishu card
*/
private buildFeishuCard(
content: string,
title = 'AuraK AI 助手',
isFinal = false,
) {
return {
config: {
wide_screen_mode: true,
},
header: {
template: isFinal ? 'blue' : 'orange',
title: {
content: title + (isFinal ? '' : ' (正在生成...)'),
tag: 'plain_text',
},
},
elements: [
{
tag: 'div',
text: {
content: content || '...',
tag: 'lark_md',
},
},
{
tag: 'hr',
},
{
tag: 'note',
elements: [
{
content: `由 AuraK 知识库驱动 · ${new Date().toLocaleTimeString()}`,
tag: 'plain_text',
},
],
},
],
};
}
// ─── Chunk 5: ChatService RAG Integration ─────────────────────────────────────
private processedMessages = new Map<
string,
{ time: number; responseId?: string }
>();
/**
* Check if message is an assessment command
*/
isAssessmentCommand(message: string): boolean {
const trimmed = message.trim().toLowerCase();
const commandPrefixes = ['/assessment', '/测评', '/eval', '/测评评估'];
return commandPrefixes.some((prefix) =>
trimmed.startsWith(prefix.toLowerCase()),
);
}
async handleIncomingMessage(
bot: FeishuBot,
openId: string,
messageId: string,
userText: string,
): Promise<void> {
// Strip Feishu AT tags and trim
userText = userText.replace(/<at [^>]*><\/at>/g, '').trim();
if (!userText) return;
// 1. Deduplication: check if we are already processing this message
const now = Date.now();
const existing = this.processedMessages.get(messageId);
if (existing && now - existing.time < 1000 * 60 * 10) {
this.logger.warn(`Ignoring duplicate Feishu message: ${messageId}`);
return;
}
// Mark as being processed
this.processedMessages.set(messageId, { time: now });
// Cleanup old cache (simple)
if (this.processedMessages.size > 1000) {
for (const [key, val] of this.processedMessages) {
if (now - val.time > 1000 * 60 * 30) this.processedMessages.delete(key);
}
}
// Get bot owner context
const userId = bot.userId;
const tenantId = bot.tenantId || 'default';
// We still fetch the user for language preference, but tenant/user IDs are from bot
const user = await this.userService.findOneById(userId);
const language = user?.userSetting?.language || 'zh';
try {
// Establish context for all downstream services and TypeORM subscribers
await tenantStore.run({ tenantId, userId }, async () => {
await i18nStore.run({ language }, async () => {
// Check if message is an assessment command
if (this.isAssessmentCommand(userText)) {
this.logger.log(
`Routing assessment command [${messageId}] for bot ${bot.appId}`,
);
// Delegate to assessment service (will run in the same context)
await this.feishuAssessmentService.handleCommand(
bot,
openId,
userText,
);
} else {
// Delegate to standard RAG pipeline (will run in the same context)
await this.processChatMessage(
bot,
openId,
messageId,
userText,
true,
);
}
});
});
} catch (error) {
this.logger.error(
`Message routing failed [${messageId}]: ${error.message}`,
error,
);
try {
await this.sendTextMessage(
bot,
'open_id',
openId,
'抱歉,处理您的消息时遇到了错误,请稍后重试。',
);
} catch (sendError) {
this.logger.error('Failed to send error message to Feishu', sendError);
}
}
}
/**
* Process a user message via the AuraK RAG pipeline and send the result back.
* This is the core of the Feishu integration.
*/
async processChatMessage(
bot: FeishuBot,
openId: string,
messageId: string,
userMessage: string,
alreadyDeduplicated = false,
): Promise<void> {
this.logger.log(
`Processing Feishu message [${messageId}] for bot ${bot.appId}`,
);
const now = Date.now();
if (!alreadyDeduplicated) {
// 1. Deduplication: check if we are already processing this message
const now = Date.now();
const existing = this.processedMessages.get(messageId);
if (existing && now - existing.time < 1000 * 60 * 10) {
this.logger.warn(`Ignoring duplicate Feishu message: ${messageId}`);
return;
}
// Mark as being processed
this.processedMessages.set(messageId, { time: now });
}
// Cleanup old cache (simple)
if (this.processedMessages.size > 1000) {
for (const [key, val] of this.processedMessages) {
if (now - val.time > 1000 * 60 * 30) this.processedMessages.delete(key);
}
}
// Get bot owner context
const userId = bot.userId;
const tenantId = bot.tenantId || 'default';
// Fetch user for language preference
const user = await this.userService.findOneById(userId);
const language = user?.userSetting?.language || 'zh';
// Get the user's default LLM model
const llmModel = await this.modelConfigService.findDefaultByType(
tenantId,
ModelType.LLM,
);
if (!llmModel) {
await this.sendTextMessage(
bot,
'open_id',
openId,
'❌ 请先在 AuraK 中配置 LLM 模型才能使用机器人。',
);
return;
}
// Send initial "thinking" card
const cardTitle = 'AuraK 知识检索';
const initialCard = this.buildFeishuCard(
'⏳ 正在检索知识库,请稍候...',
cardTitle,
false,
);
const msgId = await this.sendCardMessage(
bot,
'open_id',
openId,
initialCard,
);
// Save the response message ID for potential future deduplication debugging
this.processedMessages.set(messageId, { time: now, responseId: msgId });
// Run the RAG pipeline in the background so we don't block the Feishu event handler
// This prevents Feishu from retrying the event if it takes > 3s.
// Handle knowledge source selection
let selectedFiles: string[] | undefined = undefined;
let selectedGroups: string[] | undefined = undefined;
if (bot.knowledgeBaseId) {
selectedFiles = [bot.knowledgeBaseId];
} else if (bot.knowledgeGroupId) {
selectedGroups = [bot.knowledgeGroupId];
}
this._runRagBackground(
bot,
msgId,
userMessage,
userId,
llmModel,
language,
tenantId,
cardTitle,
selectedFiles,
selectedGroups,
);
}
/**
* Internal background task for RAG processing
*/
private async _runRagBackground(
bot: FeishuBot,
msgId: string,
userMessage: string,
userId: string,
llmModel: any,
language: string,
tenantId: string,
cardTitle: string,
selectedFiles?: string[],
selectedGroups?: string[],
) {
let fullResponse = '';
let lastUpdateTime = Date.now();
const UPDATE_INTERVAL = 1500;
try {
// Stream from ChatService RAG pipeline
const stream = this.chatService.streamChat(
userMessage,
[],
userId,
llmModel,
language,
undefined,
undefined,
undefined,
undefined,
false,
undefined,
undefined,
undefined,
10,
0.7,
undefined,
undefined,
undefined,
tenantId,
);
for await (const chunk of stream) {
if (chunk.type === 'content') {
fullResponse += chunk.data;
const now = Date.now();
if (
now - lastUpdateTime > UPDATE_INTERVAL &&
fullResponse.length > 50
) {
const loadingCard = this.buildFeishuCard(
fullResponse,
cardTitle,
false,
);
await this.updateMessage(bot, msgId, loadingCard);
lastUpdateTime = now;
}
}
}
} catch (err) {
this.logger.error('RAG stream error for Feishu message', err);
fullResponse = `抱歉,处理您的问题时遇到了错误:${err?.message || '未知错误'}`;
}
const MAX_LENGTH = 4500;
const finalContent =
fullResponse.length > MAX_LENGTH
? fullResponse.substring(0, MAX_LENGTH) + '\n\n...(内容过长,已截断)'
: fullResponse || '抱歉,未能生成有效回复,请稍后再试。';
const finalCard = this.buildFeishuCard(finalContent, cardTitle, true);
await this.updateMessage(bot, msgId, finalCard);
}
// ─── WebSocket Connection Management ─────────────────────────────────────────
/**
* Start WebSocket connection for a bot
*/
async startWsConnection(botId: string): Promise<void> {
const bot = await this.getBotById(botId);
if (!bot) throw new Error('Bot not found');
if (!bot.enabled) throw new Error('Bot is disabled');
bot.useWebSocket = true;
await this.botRepository.save(bot);
await this.wsManager.connect(bot);
}
/**
* Stop WebSocket connection for a bot
*/
async stopWsConnection(botId: string): Promise<void> {
const bot = await this.getBotById(botId);
if (!bot) throw new Error('Bot not found');
bot.useWebSocket = false;
await this.botRepository.save(bot);
await this.wsManager.disconnect(botId);
}
/**
* Get WebSocket connection status for a specific bot
*/
async getWsStatus(botId: string): Promise<ConnectionStatus | null> {
return this.wsManager.getStatus(botId);
}
/**
* Get all WebSocket connection statuses
*/
async getAllWsStatuses(): Promise<ConnectionStatus[]> {
return this.wsManager.getAllStatuses();
}
}
@@ -0,0 +1,61 @@
import { AssessmentCommandParser } from './assessment-command.parser';
import { AssessmentCommandType } from '../dto/assessment-command.dto';
describe('AssessmentCommandParser', () => {
let parser: AssessmentCommandParser;
beforeEach(() => {
parser = new AssessmentCommandParser();
});
describe('parse', () => {
it('should parse start command without parameters', () => {
const result = parser.parse('/assessment start');
expect(result).toMatchObject({
type: AssessmentCommandType.START,
parameters: [],
});
});
it('should parse start command with parameters', () => {
const result = parser.parse('/assessment start kb_123');
expect(result).toMatchObject({
type: AssessmentCommandType.START,
parameters: ['kb_123'],
});
});
it('should parse answer command', () => {
const result = parser.parse('/assessment answer my answer');
expect(result).toMatchObject({
type: AssessmentCommandType.ANSWER,
parameters: ['my', 'answer'],
});
});
it('should parse Chinese commands', () => {
const result = parser.parse('/测评 开始');
expect(result).toMatchObject({
type: AssessmentCommandType.START,
parameters: [],
});
});
it('should return null for non-assessment commands', () => {
const result = parser.parse('hello world');
expect(result).toBeNull();
});
});
describe('isAssessmentCommand', () => {
it('should return true for valid commands', () => {
expect(parser.isAssessmentCommand('/assessment status')).toBe(true);
expect(parser.isAssessmentCommand('/测评 状态')).toBe(true);
});
it('should return false for invalid commands', () => {
expect(parser.isAssessmentCommand('status')).toBe(false);
expect(parser.isAssessmentCommand('/status')).toBe(false);
});
});
});
@@ -0,0 +1,135 @@
import { Injectable, Logger } from '@nestjs/common';
import {
AssessmentCommand,
AssessmentCommandType,
AssessmentCommandDto,
} from '../dto/assessment-command.dto';
@Injectable()
export class AssessmentCommandParser {
private readonly logger = new Logger(AssessmentCommandParser.name);
// 支持的命令前缀
private readonly commandPrefixes = [
'/assessment',
'/测评',
'/eval',
'/测评评估',
];
// 命令映射
private readonly commandMap: Record<string, AssessmentCommandType> = {
start: AssessmentCommandType.START,
开始: AssessmentCommandType.START,
answer: AssessmentCommandType.ANSWER,
回答: AssessmentCommandType.ANSWER,
status: AssessmentCommandType.STATUS,
状态: AssessmentCommandType.STATUS,
result: AssessmentCommandType.RESULT,
结果: AssessmentCommandType.RESULT,
help: AssessmentCommandType.HELP,
帮助: AssessmentCommandType.HELP,
cancel: AssessmentCommandType.CANCEL,
取消: AssessmentCommandType.CANCEL,
};
/**
* 解析消息是否为测评命令
*/
parse(message: string): AssessmentCommand | null {
const trimmed = message.trim();
// 检查是否是测评命令
const isCommand = this.commandPrefixes.some((prefix) =>
trimmed.toLowerCase().startsWith(prefix.toLowerCase()),
);
if (!isCommand) {
return null;
}
try {
// 解析命令
const parts = trimmed.split(/\s+/);
const commandTypeStr = parts[1]?.toLowerCase();
// 查找命令类型
const commandType = this.commandMap[commandTypeStr];
if (!commandType) {
// 未知命令,返回帮助
return {
type: AssessmentCommandType.HELP,
parameters: [],
rawMessage: message,
timestamp: new Date(),
};
}
// 获取参数(跳过命令前缀和命令类型)
const parameters = parts.slice(2);
return {
type: commandType,
parameters,
rawMessage: message,
timestamp: new Date(),
};
} catch (error) {
this.logger.error(
`Failed to parse assessment command: ${error.message}`,
error,
);
return null;
}
}
/**
* 检查消息是否为测评命令
*/
isAssessmentCommand(message: string): boolean {
const trimmed = message.trim().toLowerCase();
return this.commandPrefixes.some((prefix) =>
trimmed.startsWith(prefix.toLowerCase()),
);
}
/**
* 获取命令帮助文本
*/
getHelpText(language: string = 'zh'): string {
if (language === 'zh') {
return `
**人才测评机器人帮助**
命令格式:
- \`/assessment start [templateId]\` - 开始测评
- \`/assessment answer [answer]\` - 提交答案
- \`/assessment status\` - 查看测评状态
- \`/assessment result\` - 获取测评结果
- \`/assessment help\` - 显示帮助
- \`/assessment cancel\` - 取消测评
说明:
- 如果未指定知识库/模板,将使用机器人配置的默认知识库
- 也可直接回复答案,无需命令前缀
`.trim();
} else {
return `
**Assessment Bot Help**
Commands:
- \`/assessment start [templateId]\` - Start assessment
- \`/assessment answer [answer]\` - Submit answer
- \`/assessment status\` - Check assessment status
- \`/assessment result\` - Get assessment results
- \`/assessment help\` - Show help
- \`/assessment cancel\` - Cancel assessment
Note:
- If no knowledge base/template is specified, the bot's default knowledge base will be used
- You can also reply directly with your answer without command prefix
`.trim();
}
}
}
@@ -0,0 +1,619 @@
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FeishuBot } from '../entities/feishu-bot.entity';
import { FeishuAssessmentSession } from '../entities/feishu-assessment-session.entity';
import { FeishuService } from '../feishu.service';
import { AssessmentService } from '../../assessment/assessment.service';
import { AssessmentCommandParser } from './assessment-command.parser';
import { AssessmentCommandType } from '../dto/assessment-command.dto';
import { i18nStore } from '../../i18n/i18n.store';
import { DEFAULT_LANGUAGE } from '../../common/constants';
@Injectable()
export class FeishuAssessmentService {
private readonly logger = new Logger(FeishuAssessmentService.name);
constructor(
@InjectRepository(FeishuAssessmentSession)
private sessionRepository: Repository<FeishuAssessmentSession>,
@Inject(forwardRef(() => AssessmentService))
private assessmentService: AssessmentService,
@Inject(forwardRef(() => FeishuService))
private feishuService: FeishuService,
private commandParser: AssessmentCommandParser,
) {}
/**
* 处理测评命令
*/
async handleCommand(
bot: FeishuBot,
openId: string,
message: string,
): Promise<void> {
// Ensure bot user relation is loaded (might be missing if fetched from an old cache or WS connection)
if (!bot.user) {
this.logger.log(`Reloading bot ${bot.id} to fetch user relation`);
const loadedBot = await this.feishuService.getBotById(bot.id);
if (loadedBot) {
bot = loadedBot;
}
}
const command = this.commandParser.parse(message);
if (!command) {
// 不是测评命令,使用默认聊天处理
await this.feishuService.processChatMessage(bot, openId, '', message);
return;
}
try {
switch (command.type) {
case AssessmentCommandType.START:
await this.startAssessment(bot, openId, command.parameters);
break;
case AssessmentCommandType.ANSWER:
await this.submitAnswer(bot, openId, command.parameters.join(' '));
break;
case AssessmentCommandType.STATUS:
await this.getStatus(bot, openId);
break;
case AssessmentCommandType.RESULT:
await this.getResult(bot, openId);
break;
case AssessmentCommandType.HELP:
await this.sendHelp(bot, openId);
break;
case AssessmentCommandType.CANCEL:
await this.cancelAssessment(bot, openId);
break;
default:
await this.sendHelp(bot, openId);
}
} catch (error) {
this.logger.error(
`Failed to handle assessment command: ${error.message}`,
error,
);
await this.feishuService.sendTextMessage(
bot,
'open_id',
openId,
`处理测评命令时出错: ${error.message}`,
);
}
}
/**
* 开始测评
*/
async startAssessment(
bot: FeishuBot,
openId: string,
parameters: string[],
): Promise<void> {
// 检查是否已有进行中的测评
const existingSession = await this.getActiveSession(bot.id, openId);
if (existingSession) {
await this.feishuService.sendTextMessage(
bot,
'open_id',
openId,
'您已有进行中的测评会话,请先完成当前测评或发送 /assessment cancel 取消。',
);
return;
}
// 解析参数
const [kbIdOrTemplateId] = parameters;
let knowledgeBaseId: string | undefined;
let templateId: string | undefined;
// 统一作为模板ID处理,因为模板包含了更完整的测评配置
// 且知识库ID也多为UUID,按长度判断不准确
if (kbIdOrTemplateId) {
templateId = kbIdOrTemplateId;
}
// 使用机器人配置的知识库或知识组(如果未指定)
if (!knowledgeBaseId && !templateId) {
if (bot.knowledgeBaseId) {
knowledgeBaseId = bot.knowledgeBaseId;
} else if (bot.knowledgeGroupId) {
knowledgeBaseId = bot.knowledgeGroupId;
}
}
this.logger.log(
`Starting assessment: bot=${bot.id}, openId=${openId}, kb=${knowledgeBaseId}, template=${templateId}`,
);
// 发送"正在创建"消息
await this.feishuService.sendTextMessage(
bot,
'open_id',
openId,
'⏳ 正在创建测评会话,请稍候...',
);
try {
const language = i18nStore.getStore()?.language || 'zh';
const session = await this.assessmentService.startSession(
bot.userId,
knowledgeBaseId,
bot.tenantId || 'default',
language,
templateId,
);
// 触发问题生成(startSession 仅创建会话,getSessionState 会触发 agent 生成问题)
this.logger.log(
`Triggering question generation for session ${session.id}`,
);
const state = await this.assessmentService.getSessionState(
session.id,
bot.userId,
);
const questions = state.questions || [];
// 存储飞书会话关联
const feishuSession = this.sessionRepository.create({
botId: bot.id,
openId,
assessmentSessionId: session.id,
status: 'active',
currentQuestionIndex: 0,
});
await this.sessionRepository.save(feishuSession);
// 发送第一个问题
if (questions && questions.length > 0) {
const firstQuestion = questions[0];
const totalQuestions = state.questionCount || questions.length;
const card = this.buildQuestionCard(
firstQuestion,
session.id,
1,
totalQuestions,
);
await this.feishuService.sendCardMessage(bot, 'open_id', openId, card);
} else {
await this.feishuService.sendTextMessage(
bot,
'open_id',
openId,
'测评会话已创建,但未能生成问题。',
);
}
} catch (error) {
this.logger.error(`Failed to start assessment: ${error.message}`, error);
await this.feishuService.sendTextMessage(
bot,
'open_id',
openId,
`创建测评会话失败: ${error.message}`,
);
}
}
/**
* 提交答案
*/
async submitAnswer(
bot: FeishuBot,
openId: string,
answer: string,
): Promise<void> {
const session = await this.getActiveSession(bot.id, openId);
if (!session) {
await this.feishuService.sendTextMessage(
bot,
'open_id',
openId,
'没有进行中的测评会话。请发送 /assessment start 开始测评。',
);
return;
}
if (!answer || answer.trim() === '') {
await this.feishuService.sendTextMessage(
bot,
'open_id',
openId,
'请提供答案。',
);
return;
}
this.logger.log(
`Submitting answer for session ${session.assessmentSessionId}`,
);
// 发送"正在评估"消息
await this.feishuService.sendTextMessage(
bot,
'open_id',
openId,
'⏳ 正在评估答案...',
);
try {
const language = i18nStore.getStore()?.language || 'zh';
const result = await this.assessmentService.submitAnswer(
session.assessmentSessionId,
bot.userId,
answer,
language,
);
this.logger.log(
`Assessment result for session ${session.assessmentSessionId}: score=${result.finalScore}, hasReport=${!!result.report}, questionsLen=${result.questions?.length}, questionsJsonLen=${result.questions_json?.length}`,
);
this.logger.debug(`Result keys: ${Object.keys(result).join(', ')}`);
if (result.report) {
this.logger.debug(
`Result report snippet: ${result.report.substring(0, 100)}...`,
);
}
// 更新会话状态
session.currentQuestionIndex = result.currentQuestionIndex || 0;
// 检查是否完成
if (result.report) {
session.status = 'completed';
await this.sessionRepository.save(session);
// 发送测评结果
await this.sendAssessmentResult(bot, openId, result);
} else if (result.questions && result.questions.length > 0) {
// 更新并保存会话
await this.sessionRepository.save(session);
// 发送下一个问题
const currentQuestionIndex = result.currentQuestionIndex || 0;
const nextQuestion = result.questions[currentQuestionIndex];
const totalQuestions = result.questionCount || result.questions.length;
const card = this.buildQuestionCard(
nextQuestion,
session.assessmentSessionId,
currentQuestionIndex + 1,
totalQuestions,
);
await this.feishuService.sendCardMessage(bot, 'open_id', openId, card);
} else if (result.questions_json && result.questions_json.length > 0) {
// 有些版本返回 questions_json
await this.sessionRepository.save(session);
const currentQuestionIndex = result.currentQuestionIndex || 0;
const nextQuestion = result.questions_json[currentQuestionIndex];
const totalQuestions =
result.questionCount || result.questions_json.length;
const card = this.buildQuestionCard(
nextQuestion,
session.assessmentSessionId,
currentQuestionIndex + 1,
totalQuestions,
);
await this.feishuService.sendCardMessage(bot, 'open_id', openId, card);
} else {
// 没有更多问题,完成测评
session.status = 'completed';
await this.sessionRepository.save(session);
await this.sendAssessmentResult(bot, openId, result);
}
} catch (error) {
this.logger.error(`Failed to submit answer: ${error.message}`, error);
await this.feishuService.sendTextMessage(
bot,
'open_id',
openId,
`提交答案失败: ${error.message}`,
);
}
}
/**
* 获取测评状态
*/
async getStatus(bot: FeishuBot, openId: string): Promise<void> {
const session = await this.getActiveSession(bot.id, openId);
if (!session) {
await this.feishuService.sendTextMessage(
bot,
'open_id',
openId,
'没有进行中的测评会话。',
);
return;
}
try {
const assessmentState = await this.assessmentService.getSessionState(
session.assessmentSessionId,
bot.userId,
);
const currentQuestionIndex = assessmentState.currentQuestionIndex || 0;
const totalQuestions = assessmentState.questions?.length || 0;
const message =
`测评状态:\n` +
`- 进度: ${currentQuestionIndex + 1}/${totalQuestions}\n` +
`- 状态: ${session.status === 'active' ? '进行中' : '已完成'}\n` +
`- 开始时间: ${session.createdAt.toLocaleString('zh-CN')}`;
await this.feishuService.sendTextMessage(bot, 'open_id', openId, message);
} catch (error) {
this.logger.error(`Failed to get status: ${error.message}`, error);
await this.feishuService.sendTextMessage(
bot,
'open_id',
openId,
`获取状态失败: ${error.message}`,
);
}
}
/**
* 获取测评结果
*/
async getResult(bot: FeishuBot, openId: string): Promise<void> {
const session = await this.getActiveSession(bot.id, openId);
if (!session) {
await this.feishuService.sendTextMessage(
bot,
'open_id',
openId,
'没有进行中的测评会话。',
);
return;
}
if (session.status !== 'completed') {
await this.feishuService.sendTextMessage(
bot,
'open_id',
openId,
'测评尚未完成,请先完成所有问题。',
);
return;
}
try {
const assessmentState = await this.assessmentService.getSessionState(
session.assessmentSessionId,
bot.userId,
);
await this.sendAssessmentResult(bot, openId, assessmentState);
} catch (error) {
this.logger.error(`Failed to get result: ${error.message}`, error);
await this.feishuService.sendTextMessage(
bot,
'open_id',
openId,
`获取结果失败: ${error.message}`,
);
}
}
/**
* 取消测评
*/
async cancelAssessment(bot: FeishuBot, openId: string): Promise<void> {
const session = await this.getActiveSession(bot.id, openId);
if (!session) {
await this.feishuService.sendTextMessage(
bot,
'open_id',
openId,
'没有进行中的测评会话。',
);
return;
}
try {
session.status = 'cancelled';
await this.sessionRepository.save(session);
await this.feishuService.sendTextMessage(
bot,
'open_id',
openId,
'测评会话已取消。发送 /assessment start 开始新的测评。',
);
} catch (error) {
this.logger.error(`Failed to cancel assessment: ${error.message}`, error);
await this.feishuService.sendTextMessage(
bot,
'open_id',
openId,
`取消测评失败: ${error.message}`,
);
}
}
/**
* 发送帮助信息
*/
async sendHelp(bot: FeishuBot, openId: string): Promise<void> {
const language = i18nStore.getStore()?.language || 'zh';
const helpText = this.commandParser.getHelpText(language);
await this.feishuService.sendTextMessage(bot, 'open_id', openId, helpText);
}
/**
* 获取活跃会话
*/
private async getActiveSession(
botId: string,
openId: string,
): Promise<FeishuAssessmentSession | null> {
return this.sessionRepository.findOne({
where: {
botId,
openId,
status: 'active',
},
order: { createdAt: 'DESC' },
});
}
/**
* 构建问题卡片
*/
private buildQuestionCard(
question: any,
sessionId: string,
currentIndex: number,
totalQuestions: number,
): any {
const difficultyColors: Record<string, string> = {
: 'green',
: 'blue',
: 'orange',
: 'red',
Easy: 'green',
Medium: 'blue',
Hard: 'orange',
Advanced: 'orange',
Expert: 'red',
Specialist: 'red',
};
const difficulty = question.difficulty || '普通';
const headerColor = difficultyColors[difficulty] || 'blue';
return {
config: { wide_screen_mode: true },
header: {
template: headerColor,
title: {
content: `人才测评 (${currentIndex}/${totalQuestions})`,
tag: 'plain_text',
},
},
elements: [
{
tag: 'div',
text: {
content: `**问题 ${currentIndex}:** ${question.questionText || question.text || question.content || '无问题内容'}`,
tag: 'lark_md',
},
},
...(question.options
? [
{
tag: 'div',
text: {
content: `选项:\n${question.options
.map(
(opt: string, i: number) =>
`${String.fromCharCode(65 + i)}. ${opt}`,
)
.join('\n')}`,
tag: 'lark_md',
},
},
]
: []),
{
tag: 'div',
text: {
content: `难度: ${difficulty} | 分值: ${question.score || 1}`,
tag: 'lark_md',
},
},
{
tag: 'hr',
},
{
tag: 'note',
elements: [
{
content: `直接回复答案或使用 /assessment answer [你的答案]`,
tag: 'plain_text',
},
],
},
],
};
}
/**
* 发送测评结果
*/
private async sendAssessmentResult(
bot: FeishuBot,
openId: string,
result: any,
): Promise<void> {
const report = result.report || result.finalReport;
const score = result.finalScore || result.score;
const resultCard = {
config: { wide_screen_mode: true },
header: {
template: 'green',
title: {
content: '测评完成',
tag: 'plain_text',
},
},
elements: [
{
tag: 'div',
text: {
content: `**测评结果**`,
tag: 'lark_md',
},
},
...(score !== undefined
? [
{
tag: 'div',
text: {
content: `**总分**: ${score.toFixed(1)}`,
tag: 'lark_md',
},
},
]
: []),
{
tag: 'div',
text: {
content: `**报告**:\n${report && report.trim().length > 0 ? report : '未生成详细报告。'}`,
tag: 'lark_md',
},
},
{
tag: 'hr',
},
{
tag: 'note',
elements: [
{
content: `发送 /assessment start 开始新的测评`,
tag: 'plain_text',
},
],
},
],
};
await this.feishuService.sendCardMessage(
bot,
'open_id',
openId,
resultCard,
);
}
}