feishu.controller.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. import {
  2. Controller,
  3. Post,
  4. Get,
  5. Delete,
  6. Body,
  7. Param,
  8. Headers,
  9. UseGuards,
  10. Request,
  11. Logger,
  12. Patch,
  13. Res,
  14. } from '@nestjs/common';
  15. import { Response } from 'express';
  16. import { FeishuService } from './feishu.service';
  17. import { FeishuAssessmentService } from './services/feishu-assessment.service';
  18. import { CreateFeishuBotDto } from './dto/create-bot.dto';
  19. import { CombinedAuthGuard } from '../auth/combined-auth.guard';
  20. import { Public } from '../auth/public.decorator';
  21. import * as fs from 'fs';
  22. import * as path from 'path';
  23. @Controller('feishu')
  24. export class FeishuController {
  25. private readonly logger = new Logger(FeishuController.name);
  26. constructor(
  27. private readonly feishuService: FeishuService,
  28. private readonly feishuAssessmentService: FeishuAssessmentService,
  29. ) {}
  30. // ─── Bot Management Endpoints (JWT-protected) ─────────────────────────────
  31. /** GET /feishu/bots - List user's bots, masking sensitive fields */
  32. @Get('bots')
  33. @UseGuards(CombinedAuthGuard)
  34. async listBots(@Request() req) {
  35. const bots = await this.feishuService.getUserBots(
  36. req.user.id,
  37. req.user.tenantId,
  38. );
  39. return bots.map((bot) => ({
  40. id: bot.id,
  41. appId: bot.appId,
  42. botName: bot.botName,
  43. enabled: bot.enabled,
  44. isDefault: bot.isDefault,
  45. webhookUrl: `/api/feishu/webhook/${bot.appId}`,
  46. createdAt: bot.createdAt,
  47. }));
  48. }
  49. /** POST /feishu/bots - Create or update a bot */
  50. @Post('bots')
  51. @UseGuards(CombinedAuthGuard)
  52. async createBot(@Request() req, @Body() dto: CreateFeishuBotDto) {
  53. const bot = await this.feishuService.createBot(
  54. req.user.id,
  55. req.user.tenantId,
  56. dto,
  57. );
  58. return {
  59. id: bot.id,
  60. appId: bot.appId,
  61. botName: bot.botName,
  62. enabled: bot.enabled,
  63. webhookUrl: `/api/feishu/webhook/${bot.appId}`,
  64. };
  65. }
  66. /** PATCH /feishu/bots/:id/toggle - Enable or disable a bot */
  67. @Patch('bots/:id/toggle')
  68. @UseGuards(CombinedAuthGuard)
  69. async toggleBot(
  70. @Request() req,
  71. @Param('id') botId: string,
  72. @Body() body: { enabled: boolean },
  73. ) {
  74. const bot = await this.feishuService.setBotEnabled(botId, body.enabled);
  75. return { id: bot.id, enabled: bot.enabled };
  76. }
  77. /** DELETE /feishu/bots/:id - Delete a bot */
  78. @Delete('bots/:id')
  79. @UseGuards(CombinedAuthGuard)
  80. async deleteBot(@Request() req, @Param('id') botId: string) {
  81. await this.feishuService.deleteBot(req.user.id, botId);
  82. return { success: true };
  83. }
  84. // ─── WebSocket Management Endpoints ────────────────────────────────────
  85. /** POST /feishu/bots/:id/ws/connect - Start WebSocket connection */
  86. @Post('bots/:id/ws/connect')
  87. @UseGuards(CombinedAuthGuard)
  88. async connectWs(@Request() req, @Param('id') botId: string) {
  89. const bot = await this.feishuService.getBotById(botId);
  90. if (!bot || bot.userId !== req.user.id) {
  91. return { success: false, error: 'Bot not found' };
  92. }
  93. try {
  94. await this.feishuService.startWsConnection(botId);
  95. return { success: true, botId, status: 'connecting' };
  96. } catch (error: any) {
  97. return {
  98. success: false,
  99. botId,
  100. error: error?.message || 'Failed to connect',
  101. };
  102. }
  103. }
  104. /** POST /feishu/bots/:id/ws/disconnect - Stop WebSocket connection */
  105. @Post('bots/:id/ws/disconnect')
  106. @UseGuards(CombinedAuthGuard)
  107. async disconnectWs(@Request() req, @Param('id') botId: string) {
  108. const bot = await this.feishuService.getBotById(botId);
  109. if (!bot || bot.userId !== req.user.id) {
  110. return { success: false, error: 'Bot not found' };
  111. }
  112. try {
  113. await this.feishuService.stopWsConnection(botId);
  114. return { success: true, botId, status: 'disconnected' };
  115. } catch (error: any) {
  116. return {
  117. success: false,
  118. botId,
  119. error: error?.message || 'Failed to disconnect',
  120. };
  121. }
  122. }
  123. /** GET /feishu/bots/:id/ws/status - Get connection status */
  124. @Get('bots/:id/ws/status')
  125. @UseGuards(CombinedAuthGuard)
  126. async getWsStatus(@Request() req, @Param('id') botId: string) {
  127. const bot = await this.feishuService.getBotById(botId);
  128. if (!bot || bot.userId !== req.user.id) {
  129. return { success: false, error: 'Bot not found' };
  130. }
  131. const status = await this.feishuService.getWsStatus(botId);
  132. if (!status) {
  133. return { botId, state: 'disconnected' };
  134. }
  135. return {
  136. botId: status.botId,
  137. state: status.state,
  138. connectedAt: status.connectedAt?.toISOString(),
  139. lastHeartbeat: status.lastHeartbeat?.toISOString(),
  140. error: status.error,
  141. };
  142. }
  143. /** GET /feishu/ws/status - Get all active WS connection statuses */
  144. @Get('ws/status')
  145. @UseGuards(CombinedAuthGuard)
  146. async getAllWsStatus() {
  147. const statuses = await this.feishuService.getAllWsStatuses();
  148. return {
  149. connections: statuses.map((s) => ({
  150. botId: s.botId,
  151. state: s.state,
  152. connectedAt: s.connectedAt?.toISOString(),
  153. lastHeartbeat: s.lastHeartbeat?.toISOString(),
  154. error: s.error,
  155. })),
  156. };
  157. }
  158. // ─── Feishu Webhook Endpoint (Public) ────────────────────────────────────
  159. @Get('webhook/:appId')
  160. @Post('webhook/:appId')
  161. @Public()
  162. async handleWebhook(
  163. @Param('appId') appId: string,
  164. @Body() body: any,
  165. @Headers() headers: any,
  166. @Request() req: any,
  167. @Res() res: Response,
  168. ) {
  169. const logEntry = `\n[${new Date().toISOString()}] ${req.method} /api/feishu/webhook/${appId}\nHeaders: ${JSON.stringify(headers)}\nBody: ${JSON.stringify(body)}\n`;
  170. fs.appendFileSync('feishu_webhook.log', logEntry);
  171. this.logger.log(
  172. `Incoming Feishu webhook [${req.method}] for appId: ${appId}`,
  173. );
  174. // GET request for simple connection test
  175. if (req.method === 'GET') {
  176. return res.status(200).json({
  177. status: 'ok',
  178. message: 'AuraK Feishu Webhook is active.',
  179. appId,
  180. timestamp: new Date().toISOString(),
  181. });
  182. }
  183. // Step 1: URL verification handshake
  184. const challenge = body?.challenge || body?.event?.challenge;
  185. if (body?.type === 'url_verification' || challenge) {
  186. this.logger.log(
  187. `URL verification active for appId: ${appId}, challenge: ${challenge}`,
  188. );
  189. return res.status(200).json({ challenge });
  190. }
  191. // Step 2: Return 200 immediately for all other events
  192. res.status(200).json({ success: true });
  193. // Step 3: Process the event asynchronously
  194. if (body?.type === 'event_callback' || body?.header?.event_type) {
  195. setImmediate(() =>
  196. this._processEvent(appId, body).catch((e) =>
  197. this.logger.error('Failed to process Feishu event async', e),
  198. ),
  199. );
  200. }
  201. }
  202. // ─── Private Event Processor ─────────────────────────────────────────────
  203. private async _processEvent(appId: string, body: any): Promise<void> {
  204. const { type, event, header } = body;
  205. if (type !== 'event_callback') return;
  206. const eventType = header?.event_type || body.event_type;
  207. const eventId = header?.event_id;
  208. this.logger.log(
  209. `Processing Feishu event [${eventId}]: ${eventType} for appId: ${appId}`,
  210. );
  211. const bot = await this.feishuService.getBotByAppId(appId);
  212. if (!bot || !bot.enabled) {
  213. this.logger.warn(`Bot not found or disabled for appId: ${appId}`);
  214. return;
  215. }
  216. switch (eventType) {
  217. case 'im.message.receive_v1':
  218. case 'im.message.p2p_msg_received':
  219. case 'im.message.group_at_msg_received':
  220. await this._handleMessage(bot, event);
  221. break;
  222. default:
  223. this.logger.log(`Unhandled event type: ${eventType}`);
  224. }
  225. }
  226. /**
  227. * Parse incoming IM message and route to chatService via FeishuService.
  228. * Implements Chunk 5 integration.
  229. */
  230. private async _handleMessage(bot: any, event: any): Promise<void> {
  231. const message = event?.message;
  232. if (!message) return;
  233. const messageId = message.message_id;
  234. const openId = event?.sender?.sender_id?.open_id;
  235. if (!openId) {
  236. this.logger.warn('No sender open_id found in Feishu event');
  237. return;
  238. }
  239. // Parse text content
  240. let userText = '';
  241. try {
  242. const content = JSON.parse(message.content || '{}');
  243. userText = content.text || '';
  244. } catch {
  245. this.logger.warn('Failed to parse Feishu message content');
  246. return;
  247. }
  248. if (!userText.trim()) return;
  249. try {
  250. // Centralized routing via FeishuService
  251. await this.feishuService.handleIncomingMessage(
  252. bot,
  253. openId,
  254. messageId,
  255. userText,
  256. );
  257. } catch (error) {
  258. this.logger.error('Message handling failed', error);
  259. }
  260. }
  261. }