forked from hangshuo652/aurak
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user