|
|
@@ -0,0 +1,945 @@
|
|
|
+# Feishu Bot Integration Implementation Plan
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** Enable users to bind their Feishu (飞书) bots to their AuraK accounts and interact with the RAG system and talent assessment features through Feishu messages.
|
|
|
+
|
|
|
+**Architecture:** Create a new `feishu` module that acts as a bridge between Feishu Open Platform and existing ChatService/AssessmentService. Users create Feishu custom apps, bind them in AuraK, and receive/push messages through Feishu's webhook-based event subscription.
|
|
|
+
|
|
|
+**Tech Stack:**
|
|
|
+- NestJS (existing)
|
|
|
+- Feishu Open Platform APIs (im/v1/messages, auth/v3/tenant_access_token)
|
|
|
+- Event subscription (Webhooks)
|
|
|
+- Existing: ChatService, AssessmentService, JWT Auth
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Architecture Overview
|
|
|
+
|
|
|
+```
|
|
|
+┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
|
|
+│ Feishu User│─────▶│ Feishu Server│─────▶│ AuraK API │
|
|
|
+│ (User A) │◀─────│ Webhook │◀─────│ /feishu/* │
|
|
|
+└─────────────┘ └──────────────┘ └─────────────┘
|
|
|
+ │
|
|
|
+ ▼
|
|
|
+ ┌─────────────┐
|
|
|
+ │ FeishuModule│
|
|
|
+ │ - Bot Entity│
|
|
|
+ │ - Message │
|
|
|
+ │ - Events │
|
|
|
+ └─────────────┘
|
|
|
+ │
|
|
|
+ ┌───────────────────────┼───────────────────────┐
|
|
|
+ ▼ ▼ ▼
|
|
|
+ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
|
+ │ ChatService │ │AssessmentSvc│ │ UserService│
|
|
|
+ │ (RAG Q&A) │ │ (评测对话) │ │ (绑定管理) │
|
|
|
+ └─────────────┘ └─────────────┘ └─────────────┘
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## File Structure
|
|
|
+
|
|
|
+### New Files to Create
|
|
|
+
|
|
|
+```
|
|
|
+server/src/
|
|
|
+├── feishu/
|
|
|
+│ ├── feishu.module.ts # Module definition
|
|
|
+│ ├── feishu.controller.ts # Webhook endpoints
|
|
|
+│ ├── feishu.service.ts # Business logic
|
|
|
+│ ├── feishu.gateway.ts # Event handling (optional)
|
|
|
+│ ├── entities/
|
|
|
+│ │ └── feishu-bot.entity.ts # Bot configuration entity
|
|
|
+│ ├── dto/
|
|
|
+│ │ ├── create-bot.dto.ts # Create bot DTO
|
|
|
+│ │ ├── bind-bot.dto.ts # Bind bot DTO
|
|
|
+│ │ └── feishu-webhook.dto.ts # Webhook event DTO
|
|
|
+│ └── interfaces/
|
|
|
+│ └── feishu.interface.ts # TypeScript interfaces
|
|
|
+```
|
|
|
+
|
|
|
+### Existing Files to Modify
|
|
|
+
|
|
|
+```
|
|
|
+server/src/
|
|
|
+├── app.module.ts # Import FeishuModule
|
|
|
+├── user/
|
|
|
+│ └── user.entity.ts # Add one-to-many relation to FeishuBot
|
|
|
+├── user/user.service.ts # Add methods to get/set Feishu binding
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Chunk 1: Entity and DTO Definitions
|
|
|
+
|
|
|
+### Task 1.1: Create FeishuBot Entity
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `server/src/feishu/entities/feishu-bot.entity.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: Create the FeishuBot entity file**
|
|
|
+
|
|
|
+```typescript
|
|
|
+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;
|
|
|
+
|
|
|
+ @ManyToOne(() => User)
|
|
|
+ @JoinColumn({ name: 'user_id' })
|
|
|
+ user: User;
|
|
|
+
|
|
|
+ @Column({ name: 'app_id', length: 64 })
|
|
|
+ appId: string; // 飞书应用 App ID (cli_xxx)
|
|
|
+
|
|
|
+ @Column({ name: 'app_secret', length: 128 })
|
|
|
+ appSecret: string; // 飞书应用 App Secret (加密存储)
|
|
|
+
|
|
|
+ @Column({ name: 'tenant_access_token', nullable: true })
|
|
|
+ tenantAccessToken: string;
|
|
|
+
|
|
|
+ @Column({ name: 'token_expires_at', nullable: true })
|
|
|
+ tokenExpiresAt: Date;
|
|
|
+
|
|
|
+ @Column({ name: 'verification_token', nullable: true })
|
|
|
+ verificationToken: string; // 用于验证回调请求
|
|
|
+
|
|
|
+ @Column({ name: 'encrypt_key', nullable: true })
|
|
|
+ encryptKey: string; // 飞书加密 key (可选)
|
|
|
+
|
|
|
+ @Column({ name: 'bot_name', nullable: true })
|
|
|
+ botName: string; // 机器人名称显示
|
|
|
+
|
|
|
+ @Column({ default: true })
|
|
|
+ enabled: boolean;
|
|
|
+
|
|
|
+ @Column({ name: 'is_default', default: false })
|
|
|
+ isDefault: boolean; // 用户是否有多个机器人
|
|
|
+
|
|
|
+ @CreateDateColumn({ name: 'created_at' })
|
|
|
+ createdAt: Date;
|
|
|
+
|
|
|
+ @UpdateDateColumn({ name: 'updated_at' })
|
|
|
+ updatedAt: Date;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Add FeishuBot relation to User entity**
|
|
|
+
|
|
|
+Modify: `server/src/user/user.entity.ts:73-74`
|
|
|
+
|
|
|
+Add after userSetting relation:
|
|
|
+```typescript
|
|
|
+ @OneToMany(() => FeishuBot, (bot) => bot.user)
|
|
|
+ feishuBots: FeishuBot[];
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Create DTOs**
|
|
|
+
|
|
|
+Create: `server/src/feishu/dto/create-bot.dto.ts`
|
|
|
+```typescript
|
|
|
+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;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create: `server/src/feishu/dto/bind-bot.dto.ts`
|
|
|
+```typescript
|
|
|
+import { IsString, IsNotEmpty, IsUUID } from 'class-validator';
|
|
|
+
|
|
|
+export class BindFeishuBotDto {
|
|
|
+ @IsUUID()
|
|
|
+ @IsNotEmpty()
|
|
|
+ botId: string;
|
|
|
+
|
|
|
+ @IsString()
|
|
|
+ @IsNotEmpty()
|
|
|
+ verificationCode?: string; // Optional: 用于验证绑定关系
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Chunk 2: Core Service Implementation
|
|
|
+
|
|
|
+### Task 2.1: Create Feishu Service
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `server/src/feishu/feishu.service.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: Implement FeishuService with token management and message sending**
|
|
|
+
|
|
|
+```typescript
|
|
|
+import { Injectable, Logger } from '@nestjs/common';
|
|
|
+import { InjectRepository } from '@nestjs/typeorm';
|
|
|
+import { Repository } from 'typeorm';
|
|
|
+import { ConfigService } from '@nestjs/config';
|
|
|
+import { HttpService } from '@nestjs/axios';
|
|
|
+import { FeishuBot } from './entities/feishu-bot.entity';
|
|
|
+import { CreateFeishuBotDto } from './dto/create-bot.dto';
|
|
|
+
|
|
|
+@Injectable()
|
|
|
+export class FeishuService {
|
|
|
+ private readonly logger = new Logger(FeishuService.name);
|
|
|
+ private readonly feishuApiBase = 'https://open.feishu.cn/open-apis';
|
|
|
+
|
|
|
+ constructor(
|
|
|
+ @InjectRepository(FeishuBot)
|
|
|
+ private botRepository: Repository<FeishuBot>,
|
|
|
+ private httpService: HttpService,
|
|
|
+ private configService: ConfigService,
|
|
|
+ ) {}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Create or update a Feishu bot for a user
|
|
|
+ */
|
|
|
+ async createBot(userId: string, dto: CreateFeishuBotDto): Promise<FeishuBot> {
|
|
|
+ // Check if bot already exists for this user with same appId
|
|
|
+ const existing = await this.botRepository.findOne({
|
|
|
+ where: { userId, appId: dto.appId },
|
|
|
+ });
|
|
|
+
|
|
|
+ if (existing) {
|
|
|
+ // Update existing bot
|
|
|
+ Object.assign(existing, dto);
|
|
|
+ return this.botRepository.save(existing);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Create new bot
|
|
|
+ const bot = this.botRepository.create({
|
|
|
+ userId,
|
|
|
+ ...dto,
|
|
|
+ });
|
|
|
+ return this.botRepository.save(bot);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Get all bots for a user
|
|
|
+ */
|
|
|
+ async getUserBots(userId: string): Promise<FeishuBot[]> {
|
|
|
+ return this.botRepository.find({ where: { userId } });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Get bot by ID
|
|
|
+ */
|
|
|
+ async getBotById(botId: string): Promise<FeishuBot | null> {
|
|
|
+ return this.botRepository.findOne({ where: { id: botId } });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Get bot by appId
|
|
|
+ */
|
|
|
+ async getBotByAppId(appId: string): Promise<FeishuBot | null> {
|
|
|
+ return this.botRepository.findOne({ where: { appId } });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Get or refresh tenant_access_token
|
|
|
+ */
|
|
|
+ async getValidToken(bot: FeishuBot): Promise<string> {
|
|
|
+ // Check if token is still valid (expire in 2 hours, refresh at 1.5 hours)
|
|
|
+ if (
|
|
|
+ bot.tokenExpiresAt &&
|
|
|
+ bot.tenantAccessToken &&
|
|
|
+ new Date(bot.tokenExpiresAt) > new Date(Date.now() + 30 * 60 * 1000)
|
|
|
+ ) {
|
|
|
+ return bot.tenantAccessToken;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Refresh token
|
|
|
+ const response = await this.httpService
|
|
|
+ .post(`${this.feishuApiBase}/auth/v3/tenant_access_token/internal`, {
|
|
|
+ app_id: bot.appId,
|
|
|
+ app_secret: bot.appSecret,
|
|
|
+ })
|
|
|
+ .toPromise();
|
|
|
+
|
|
|
+ if (response.data.code !== 0) {
|
|
|
+ throw new Error(`Failed to get token: ${response.data.msg}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ const { tenant_access_token, expire } = response.data;
|
|
|
+
|
|
|
+ // Update bot with new token
|
|
|
+ bot.tenantAccessToken = tenant_access_token;
|
|
|
+ bot.tokenExpiresAt = new Date(Date.now() + expire * 1000);
|
|
|
+ await this.botRepository.save(bot);
|
|
|
+
|
|
|
+ return tenant_access_token;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Send message to Feishu user
|
|
|
+ */
|
|
|
+ async sendMessage(
|
|
|
+ bot: FeishuBot,
|
|
|
+ receiveIdType: 'open_id' | 'user_id' | 'union_id' | 'chat_id',
|
|
|
+ receiveId: string,
|
|
|
+ messageType: 'text' | 'interactive' | 'post',
|
|
|
+ content: any,
|
|
|
+ ): Promise<string> {
|
|
|
+ const token = await this.getValidToken(bot);
|
|
|
+
|
|
|
+ const response = await this.httpService
|
|
|
+ .post(
|
|
|
+ `${this.feishuApiBase}/im/v1/messages?receive_id_type=${receiveIdType}`,
|
|
|
+ {
|
|
|
+ receive_id: receiveId,
|
|
|
+ msg_type: messageType,
|
|
|
+ content: JSON.stringify(content),
|
|
|
+ },
|
|
|
+ {
|
|
|
+ headers: {
|
|
|
+ Authorization: `Bearer ${token}`,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .toPromise();
|
|
|
+
|
|
|
+ if (response.data.code !== 0) {
|
|
|
+ throw new Error(`Failed to send message: ${response.data.msg}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ return response.data.data.message_id;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Send text message (convenience method)
|
|
|
+ */
|
|
|
+ async sendTextMessage(
|
|
|
+ bot: FeishuBot,
|
|
|
+ receiveIdType: 'open_id' | 'user_id' | 'chat_id',
|
|
|
+ receiveId: string,
|
|
|
+ text: string,
|
|
|
+ ): Promise<string> {
|
|
|
+ return this.sendMessage(bot, receiveIdType, receiveId, 'text', { text });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Reply to a message
|
|
|
+ */
|
|
|
+ async replyMessage(
|
|
|
+ bot: FeishuBot,
|
|
|
+ messageId: string,
|
|
|
+ messageType: 'text' | 'interactive' | 'post',
|
|
|
+ content: any,
|
|
|
+ ): Promise<string> {
|
|
|
+ const token = await this.getValidToken(bot);
|
|
|
+
|
|
|
+ const response = await this.httpService
|
|
|
+ .post(
|
|
|
+ `${this.feishuApiBase}/im/v1/messages/${messageId}/reply`,
|
|
|
+ {
|
|
|
+ msg_type: messageType,
|
|
|
+ content: JSON.stringify(content),
|
|
|
+ },
|
|
|
+ {
|
|
|
+ headers: {
|
|
|
+ Authorization: `Bearer ${token}`,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .toPromise();
|
|
|
+
|
|
|
+ if (response.data.code !== 0) {
|
|
|
+ throw new Error(`Failed to reply message: ${response.data.msg}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ return response.data.data.message_id;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Upload image for sending
|
|
|
+ */
|
|
|
+ async uploadImage(
|
|
|
+ bot: FeishuBot,
|
|
|
+ imageType: 'message' | 'avatar',
|
|
|
+ image: Buffer,
|
|
|
+ imageName: string,
|
|
|
+ ): Promise<string> {
|
|
|
+ const token = await this.getValidToken(bot);
|
|
|
+
|
|
|
+ const FormData = require('form-data');
|
|
|
+ const form = new FormData();
|
|
|
+ form.append('image_type', imageType);
|
|
|
+ form.append('image', image, { filename: imageName });
|
|
|
+
|
|
|
+ const response = await this.httpService
|
|
|
+ .post(`${this.feishuApiBase}/im/v1/images`, form, {
|
|
|
+ headers: {
|
|
|
+ Authorization: `Bearer ${token}`,
|
|
|
+ ...form.getHeaders(),
|
|
|
+ },
|
|
|
+ })
|
|
|
+ .toPromise();
|
|
|
+
|
|
|
+ if (response.data.code !== 0) {
|
|
|
+ throw new Error(`Failed to upload image: ${response.data.msg}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ return response.data.data.image_key;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Delete bot
|
|
|
+ */
|
|
|
+ async deleteBot(botId: string): Promise<void> {
|
|
|
+ await this.botRepository.delete(botId);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Enable/disable bot
|
|
|
+ */
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Chunk 3: Controller and Webhook Endpoints
|
|
|
+
|
|
|
+### Task 3.1: Create Feishu Controller
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `server/src/feishu/feishu.controller.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: Implement webhook endpoints**
|
|
|
+
|
|
|
+```typescript
|
|
|
+import {
|
|
|
+ Controller,
|
|
|
+ Post,
|
|
|
+ Get,
|
|
|
+ Delete,
|
|
|
+ Body,
|
|
|
+ Param,
|
|
|
+ Query,
|
|
|
+ Headers,
|
|
|
+ UseGuards,
|
|
|
+ Request,
|
|
|
+ RawBodyRequest,
|
|
|
+ Req,
|
|
|
+} from '@nestjs/common';
|
|
|
+import { Request as ExpressRequest } from 'express';
|
|
|
+import { FeishuService } from './feishu.service';
|
|
|
+import { CreateFeishuBotDto } from './dto/create-bot.dto';
|
|
|
+import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|
|
+import { CreateSignatureDto, VerifyWebhookDto } from './dto/webhook.dto';
|
|
|
+
|
|
|
+@Controller('feishu')
|
|
|
+export class FeishuController {
|
|
|
+ constructor(private readonly feishuService: FeishuService) {}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * GET /feishu/bots - List user's bots
|
|
|
+ * Requires JWT auth
|
|
|
+ */
|
|
|
+ @Get('bots')
|
|
|
+ @UseGuards(JwtAuthGuard)
|
|
|
+ async listBots(@Request() req) {
|
|
|
+ const bots = await this.feishuService.getUserBots(req.user.id);
|
|
|
+ // Mask sensitive data
|
|
|
+ return bots.map((bot) => ({
|
|
|
+ id: bot.id,
|
|
|
+ appId: bot.appId,
|
|
|
+ botName: bot.botName,
|
|
|
+ enabled: bot.enabled,
|
|
|
+ isDefault: bot.isDefault,
|
|
|
+ createdAt: bot.createdAt,
|
|
|
+ }));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * POST /feishu/bots - Create a new bot
|
|
|
+ * Requires JWT auth
|
|
|
+ */
|
|
|
+ @Post('bots')
|
|
|
+ @UseGuards(JwtAuthGuard)
|
|
|
+ async createBot(@Request() req, @Body() dto: CreateFeishuBotDto) {
|
|
|
+ const bot = await this.feishuService.createBot(req.user.id, dto);
|
|
|
+ return {
|
|
|
+ id: bot.id,
|
|
|
+ appId: bot.appId,
|
|
|
+ botName: bot.botName,
|
|
|
+ enabled: bot.enabled,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * DELETE /feishu/bots/:id - Delete a bot
|
|
|
+ * Requires JWT auth
|
|
|
+ */
|
|
|
+ @Delete('bots/:id')
|
|
|
+ @UseGuards(JwtAuthGuard)
|
|
|
+ async deleteBot(@Request() req, @Param('id') botId: string) {
|
|
|
+ await this.feishuService.deleteBot(botId);
|
|
|
+ return { success: true };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * POST /feishu/webhook - Feishu webhook endpoint
|
|
|
+ * Public endpoint - no auth required (uses verification token)
|
|
|
+ */
|
|
|
+ @Post('webhook')
|
|
|
+ async handleWebhook(
|
|
|
+ @Body() body: any,
|
|
|
+ @Headers('x-xsign') xSign?: string,
|
|
|
+ @Headers('x-timESTAMP') xTimestamp?: string,
|
|
|
+ ) {
|
|
|
+ this.logger.log(`Received webhook: ${JSON.stringify(body)}`);
|
|
|
+
|
|
|
+ const { type, schema, event } = body;
|
|
|
+
|
|
|
+ // Handle URL verification (飞书首次配置时验证)
|
|
|
+ if (type === 'url_verification') {
|
|
|
+ return {
|
|
|
+ challenge: body.challenge,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Handle event callback
|
|
|
+ if (type === 'event_callback') {
|
|
|
+ const { event_type, token } = body;
|
|
|
+
|
|
|
+ // Verify token
|
|
|
+ if (token !== body?.verify_token) {
|
|
|
+ this.logger.warn('Webhook token verification failed');
|
|
|
+ return { success: false };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Handle different event types
|
|
|
+ switch (event_type) {
|
|
|
+ case 'im.message.p2p_msg_received':
|
|
|
+ // Handle private message
|
|
|
+ await this.handleP2PMsg(event);
|
|
|
+ break;
|
|
|
+
|
|
|
+ case 'im.message.group_at_msg_received':
|
|
|
+ // Handle group @message
|
|
|
+ await this.handleGroupMsg(event);
|
|
|
+ break;
|
|
|
+
|
|
|
+ default:
|
|
|
+ this.logger.log(`Unhandled event type: ${event_type}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return { success: true };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Handle private message
|
|
|
+ */
|
|
|
+ private async handleP2PMsg(event: any) {
|
|
|
+ const { message } = event;
|
|
|
+ const { message_id, chat_id, sender, message_type, body } = message;
|
|
|
+
|
|
|
+ // Get message content
|
|
|
+ const content = JSON.parse(message.content || '{}');
|
|
|
+ const text = content.text || '';
|
|
|
+
|
|
|
+ // Find bot by app_id (from chat or event)
|
|
|
+ const openId = sender?.id?.open_id;
|
|
|
+ if (!openId) {
|
|
|
+ this.logger.warn('No sender open_id found');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Find user's bot
|
|
|
+ const bot = await this.feishuService.getBotByAppId(sender?.sender_id?.app_id);
|
|
|
+ if (!bot || !bot.enabled) {
|
|
|
+ this.logger.warn('Bot not found or disabled');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Process message through RAG/Chat service
|
|
|
+ await this.processMessage(bot, openId, message_id, text);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Handle group message (@bot)
|
|
|
+ */
|
|
|
+ private async handleGroupMsg(event: any) {
|
|
|
+ const { message } = event;
|
|
|
+ const { message_id, chat_id, sender, message_type, content } = message;
|
|
|
+
|
|
|
+ // Check if bot was mentioned
|
|
|
+ const msgContent = JSON.parse(content || '{}');
|
|
|
+ // Group messages often require specific handling
|
|
|
+
|
|
|
+ // Similar to P2P but with chat_id context
|
|
|
+ await this.handleP2PMsg(event);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Process message through ChatService
|
|
|
+ */
|
|
|
+ private async processMessage(
|
|
|
+ bot: any,
|
|
|
+ openId: string,
|
|
|
+ messageId: string,
|
|
|
+ text: string,
|
|
|
+ ) {
|
|
|
+ // TODO: Integrate with ChatService
|
|
|
+ // This will be implemented in Chunk 5
|
|
|
+
|
|
|
+ // For now, echo back (placeholder)
|
|
|
+ try {
|
|
|
+ await this.feishuService.sendTextMessage(
|
|
|
+ bot,
|
|
|
+ 'open_id',
|
|
|
+ openId,
|
|
|
+ `Received: ${text}`,
|
|
|
+ );
|
|
|
+ } catch (error) {
|
|
|
+ this.logger.error('Failed to send response', error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Create webhook DTOs**
|
|
|
+
|
|
|
+Create: `server/src/feishu/dto/webhook.dto.ts`
|
|
|
+```typescript
|
|
|
+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;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Chunk 4: Module Registration
|
|
|
+
|
|
|
+### Task 4.1: Register FeishuModule
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `server/src/feishu/feishu.module.ts`
|
|
|
+- Modify: `server/src/app.module.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: Create FeishuModule**
|
|
|
+
|
|
|
+```typescript
|
|
|
+import { Module, forwardRef } from '@nestjs/common';
|
|
|
+import { TypeOrmModule } from '@nestjs/typeorm';
|
|
|
+import { HttpModule } from '@nestjs/axios';
|
|
|
+import { FeishuController } from './feishu.controller';
|
|
|
+import { FeishuService } from './feishu.service';
|
|
|
+import { FeishuBot } from './entities/feishu-bot.entity';
|
|
|
+import { ChatModule } from '../chat/chat.module';
|
|
|
+import { UserModule } from '../user/user.module';
|
|
|
+
|
|
|
+@Module({
|
|
|
+ imports: [
|
|
|
+ TypeOrmModule.forFeature([FeishuBot]),
|
|
|
+ HttpModule,
|
|
|
+ forwardRef(() => ChatModule),
|
|
|
+ forwardRef(() => UserModule),
|
|
|
+ ],
|
|
|
+ controllers: [FeishuController],
|
|
|
+ providers: [FeishuService],
|
|
|
+ exports: [FeishuService, TypeOrmModule],
|
|
|
+})
|
|
|
+export class FeishuModule {}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Add FeishuModule to AppModule**
|
|
|
+
|
|
|
+Modify: `server/src/app.module.ts`
|
|
|
+
|
|
|
+Add import:
|
|
|
+```typescript
|
|
|
+import { FeishuModule } from './feishu/feishu.module';
|
|
|
+```
|
|
|
+
|
|
|
+Add to imports array:
|
|
|
+```typescript
|
|
|
+FeishuModule,
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Chunk 5: Integration with ChatService
|
|
|
+
|
|
|
+### Task 5.1: Connect Feishu messages to ChatService
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `server/src/feishu/feishu.controller.ts`
|
|
|
+- Modify: `server/src/feishu/feishu.service.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: Extend FeishuService to handle chat integration**
|
|
|
+
|
|
|
+Add to FeishuService:
|
|
|
+```typescript
|
|
|
+ // Import these
|
|
|
+ import { ChatService } from '../chat/chat.service';
|
|
|
+ import { ModelConfigService } from '../model-config/model-config.service';
|
|
|
+ import { TenantService } from '../tenant/tenant.service';
|
|
|
+ import { UserService } from '../user/user.service';
|
|
|
+ import { ModelType } from '../types';
|
|
|
+
|
|
|
+ // Add to constructor
|
|
|
+ constructor(
|
|
|
+ // ... existing
|
|
|
+ private chatService: ChatService,
|
|
|
+ private modelConfigService: ModelConfigService,
|
|
|
+ private tenantService: TenantService,
|
|
|
+ private userService: UserService,
|
|
|
+ ) {}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Process chat message through RAG
|
|
|
+ */
|
|
|
+ async processChatMessage(
|
|
|
+ bot: FeishuBot,
|
|
|
+ openId: string,
|
|
|
+ messageId: string,
|
|
|
+ userMessage: string,
|
|
|
+ ): Promise<void> {
|
|
|
+ // Get user by Feishu open_id mapping (future: map table)
|
|
|
+ // For now, use userId from bot
|
|
|
+ const userId = bot.userId;
|
|
|
+
|
|
|
+ // Get user's tenant
|
|
|
+ const user = await this.userService.findById(userId);
|
|
|
+ const tenantId = user?.tenantId || 'default';
|
|
|
+
|
|
|
+ // Get user's LLM config
|
|
|
+ const llmModel = await this.modelConfigService.findDefaultByType(
|
|
|
+ tenantId,
|
|
|
+ ModelType.LLM,
|
|
|
+ );
|
|
|
+
|
|
|
+ if (!llmModel) {
|
|
|
+ await this.sendTextMessage(bot, 'open_id', openId, '请先配置 LLM 模型');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Send "thinking" message
|
|
|
+ await this.sendTextMessage(bot, 'open_id', openId, '正在思考...');
|
|
|
+
|
|
|
+ // Stream chat response
|
|
|
+ const stream = this.chatService.streamChat(
|
|
|
+ userMessage,
|
|
|
+ [], // No history for now (future: persist per openId)
|
|
|
+ userId,
|
|
|
+ llmModel as any,
|
|
|
+ user?.userSetting?.language || 'zh',
|
|
|
+ undefined, // selectedEmbeddingId
|
|
|
+ undefined, // selectedGroups
|
|
|
+ undefined, // selectedFiles
|
|
|
+ undefined, // historyId
|
|
|
+ false, // enableRerank
|
|
|
+ undefined, // selectedRerankId
|
|
|
+ undefined, // temperature
|
|
|
+ undefined, // maxTokens
|
|
|
+ 10, // topK
|
|
|
+ 0.7, // similarityThreshold
|
|
|
+ undefined, // rerankSimilarityThreshold
|
|
|
+ undefined, // enableQueryExpansion
|
|
|
+ undefined, // enableHyDE
|
|
|
+ tenantId,
|
|
|
+ );
|
|
|
+
|
|
|
+ let fullResponse = '';
|
|
|
+ for await (const chunk of stream) {
|
|
|
+ if (chunk.type === 'content') {
|
|
|
+ fullResponse += chunk.data;
|
|
|
+ // Could send incrementally, but Feishu prefers complete messages
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Send final response
|
|
|
+ // Truncate if too long (Feishu has limits)
|
|
|
+ const maxLength = 5000;
|
|
|
+ const finalMessage = fullResponse.length > maxLength
|
|
|
+ ? fullResponse.substring(0, maxLength) + '...(内容过长)'
|
|
|
+ : fullResponse;
|
|
|
+
|
|
|
+ await this.sendTextMessage(bot, 'open_id', openId, finalMessage);
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Update controller to use chat integration**
|
|
|
+
|
|
|
+Modify the `processMessage` method in FeishuController to call `feishuService.processChatMessage()`:
|
|
|
+
|
|
|
+```typescript
|
|
|
+ private async processMessage(
|
|
|
+ bot: any,
|
|
|
+ openId: string,
|
|
|
+ messageId: string,
|
|
|
+ text: string,
|
|
|
+ ) {
|
|
|
+ try {
|
|
|
+ await this.feishuService.processChatMessage(bot, openId, messageId, text);
|
|
|
+ } catch (error) {
|
|
|
+ this.logger.error('Failed to process message', error);
|
|
|
+ try {
|
|
|
+ await this.feishuService.sendTextMessage(
|
|
|
+ bot,
|
|
|
+ 'open_id',
|
|
|
+ openId,
|
|
|
+ '抱歉,处理消息时发生错误,请稍后重试。',
|
|
|
+ );
|
|
|
+ } catch (sendError) {
|
|
|
+ this.logger.error('Failed to send error message', sendError);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Chunk 6: Frontend Integration (Optional)
|
|
|
+
|
|
|
+### Task 6.1: Add Feishu settings UI
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `web/src/pages/Settings/FeishuSettings.tsx` (or similar)
|
|
|
+- Modify: `web/src/services/api.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: Add API endpoints to frontend**
|
|
|
+
|
|
|
+Modify: `web/src/services/api.ts`
|
|
|
+```typescript
|
|
|
+// Add Feishu API calls
|
|
|
+export const feishuApi = {
|
|
|
+ listBots: () => api.get('/feishu/bots'),
|
|
|
+ createBot: (data: CreateBotDto) => api.post('/feishu/bots', data),
|
|
|
+ deleteBot: (botId: string) => api.delete(`/feishu/bots/${botId}`),
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Create settings component**
|
|
|
+
|
|
|
+Create a simple settings page where users can:
|
|
|
+1. View their bound Feishu bots
|
|
|
+2. Add new bot (paste App ID, App Secret)
|
|
|
+3. Delete bots
|
|
|
+4. See setup instructions
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Testing Strategy
|
|
|
+
|
|
|
+### Unit Tests
|
|
|
+- `feishu.service.spec.ts` - Test token refresh, message sending
|
|
|
+- `feishu.controller.spec.ts` - Test webhook endpoints
|
|
|
+
|
|
|
+### Integration Tests
|
|
|
+- Test full flow: Feishu message → Webhook → ChatService → Response
|
|
|
+
|
|
|
+### Manual Testing
|
|
|
+1. Create Feishu app in 开放平台
|
|
|
+2. Configure webhook URL (use ngrok for local)
|
|
|
+3. Subscribe to message events
|
|
|
+4. Send message to bot
|
|
|
+5. Verify RAG response
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Security Considerations
|
|
|
+
|
|
|
+1. **Token Storage**: Encrypt `app_secret` before storing in DB
|
|
|
+2. **Webhook Verification**: Always verify `verification_token` from Feishu
|
|
|
+3. **Rate Limiting**: Implement queue for sending messages (Feishu limits)
|
|
|
+4. **User Mapping**: Consider secure mapping between Feishu open_id and AuraK user
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Future Enhancements
|
|
|
+
|
|
|
+1. **Assessment Integration**: Bind assessment sessions to Feishu conversations
|
|
|
+2. **Rich Responses**: Use Feishu interactive cards for better UI
|
|
|
+3. **Multi-bot Support**: Users can have multiple bots for different purposes
|
|
|
+4. **Group Chats**: Support bot in group chats
|
|
|
+5. **Voice Messages**: Handle voice message transcription
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Dependencies
|
|
|
+
|
|
|
+- `@nestjs/axios` - For HTTP requests to Feishu API
|
|
|
+- `form-data` - For file/image uploads
|
|
|
+- Optional: `crypto` (built-in) - For signature verification
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Reference Links
|
|
|
+
|
|
|
+- [飞书开放平台 - 机器人文档](https://open.feishu.cn/document/faq/bot)
|
|
|
+- [飞书事件订阅](https://open.feishu.cn/document/server-docs/event-subscription-guide/overview)
|
|
|
+- [消息发送 API](https://open.feishu.cn/document/server-docs/im-v1/message-content-description)
|
|
|
+- [获取 tenant_access_token](https://open.feishu.cn/document/server-docs/authentication-management/access-token)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+> **Plan created:** 2026-03-16
|
|
|
+> **Based on:** Feishu integration analysis
|