/** * 飞书消息模块 * * 功能: * 1. 消息发送(应用身份 + Webhook) * 2. 事件回调处理(URL验证 + 签名校验 + 消息接收) * 3. 卡片交互回调(按钮点击) * * 飞书事件订阅配置: * - Request URL: https://your-domain/api/feishu/event * - 加密策略:Verification Token + Encrypt Key(可选) */ import { createHmac, createHash } from 'crypto'; // 配置:从环境变量读取 const FEISHU_APP_ID = process.env.FEISHU_APP_ID || 'cli_a95093447cb85cdd'; const FEISHU_APP_SECRET = process.env.FEISHU_APP_SECRET || 'd17CeffVfOnTkQo8LIP7hbhOQwSPv7Jv'; const FEISHU_WEBHOOK = process.env.FEISHU_WEBHOOK || 'https://open.feishu.cn/open-apis/bot/v2/hook/58321c74-5881-4f41-bcd4-85f4d7c5b3c1'; const FEISHU_WEBHOOK_SECRET = process.env.FEISHU_WEBHOOK_SECRET || 'UgCdzrcci4s9YS1GSAHt4e'; /** 飞书事件订阅 Verification Token(在飞书开放平台 > 事件订阅中获取) */ const FEISHU_VERIFICATION_TOKEN = process.env.FEISHU_VERIFICATION_TOKEN || ''; /** 飞书事件订阅 Encrypt Key(如果启用了加密) */ const FEISHU_ENCRYPT_KEY = process.env.FEISHU_ENCRYPT_KEY || ''; /** * 使用应用身份获取 tenant_access_token */ async function getTenantToken(): Promise { const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ app_id: FEISHU_APP_ID, app_secret: FEISHU_APP_SECRET }), }); const data = await res.json(); if (data.code !== 0) { throw new Error(`Failed to get tenant token: ${data.msg}`); } return data.tenant_access_token; } /** * 生成 Webhook 签名 */ function generateWebhookSign(timestamp: number, secret: string): string { const stringToSign = `${timestamp}\n${secret}`; const hmac = createHmac('sha256', stringToSign); return hmac.digest('base64'); } /** * 发送飞书文本消息(支持两种方式) */ export interface FeishuMessageOptions { /** 消息内容 */ text: string; /** 接收者 ID(open_id、user_id、chat_id 等) */ receiveId: string; /** 接收者 ID 类型:open_id、user_id、chat_id、email */ receiveIdType?: 'open_id' | 'user_id' | 'chat_id' | 'email'; /** 是否使用应用身份(默认 true),若为 false 则使用 webhook */ useApp?: boolean; } /** * 发送文本消息 */ export async function sendFeishuMessage(options: FeishuMessageOptions): Promise<{ ok: boolean; code: number; msg: string }> { const { text, receiveId, receiveIdType = 'open_id', useApp = true } = options; if (useApp) { // 使用应用身份 const token = await getTenantToken(); const url = `https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ receive_id: receiveId, msg_type: 'text', content: JSON.stringify({ text }), }), }); const data = await res.json(); return { ok: data.code === 0, code: data.code, msg: data.msg, }; } else { // 使用 webhook const timestamp = Math.floor(Date.now() / 1000); const body: Record = { msg_type: 'text', content: { text }, }; if (FEISHU_WEBHOOK_SECRET) { body.timestamp = String(timestamp); body.sign = generateWebhookSign(timestamp, FEISHU_WEBHOOK_SECRET); } const res = await fetch(FEISHU_WEBHOOK, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await res.json(); return { ok: data.code === 0 || data.StatusCode === 0, code: data.code ?? data.StatusCode ?? -1, msg: data.msg ?? data.StatusMessage ?? '', }; } } /** * 发送项目创建通知 */ export async function notifyProjectCreated(projectName: string, goal: string, receiveId: string, receiveIdType: 'open_id' | 'user_id' = 'open_id'): Promise { await sendFeishuMessage({ text: `🚀 新项目已创建\n\n项目:${projectName}\n目标:${goal}\n\n请及时查看并确认项目章程。`, receiveId, receiveIdType, useApp: true, }); } /** * 发送里程碑提醒 */ export async function notifyMilestoneReminder(milestoneName: string, targetDate: string, receiveId: string, receiveIdType: 'open_id' | 'user_id' = 'open_id'): Promise { await sendFeishuMessage({ text: `⏰ 里程碑提醒\n\n里程碑「${milestoneName}」即将到期\n目标日期:${targetDate}\n\n请确认进度是否正常。`, receiveId, receiveIdType, useApp: true, }); } /** * 发送风险预警 */ export async function notifyRiskAlert(riskDesc: string, priority: number, receiveId: string, receiveIdType: 'open_id' | 'user_id' = 'open_id'): Promise { const level = priority >= 15 ? '🔴 高' : priority >= 10 ? '🟡 中' : '🟢 低'; await sendFeishuMessage({ text: `⚠️ 风险预警\n\n风险:${riskDesc}\n优先级:${level}(${priority}分)\n\n请评估并制定应对措施。`, receiveId, receiveIdType, useApp: true, }); } /** * 发送决策请求(生成卡片消息) */ export interface DecisionCardOptions { title: string; description: string; options: Array<{ key: string; label: string; style?: 'primary' | 'danger' | 'default' }>; } /** * 生成飞书卡片消息 */ export function buildDecisionCard(card: DecisionCardOptions): Record { const elements: Record[] = [ { tag: 'div', text: { tag: 'plain_text', content: card.description }, }, ]; const actions = card.options.map((opt) => ({ tag: 'button', text: { tag: 'plain_text', content: opt.label }, type: opt.style === 'danger' ? 'danger' : opt.style === 'primary' ? 'primary' : 'default', value: { action: opt.key }, })); elements.push({ tag: 'action', actions }); return { msg_type: 'interactive', card: { header: { title: { tag: 'plain_text', content: card.title }, template: 'blue', }, elements, }, }; } /** * 发送决策卡片(带 decisionId 用于回调追踪) */ export async function sendDecisionCard( card: DecisionCardOptions, receiveId: string, receiveIdType: 'open_id' | 'user_id' = 'open_id', decisionId?: string, ): Promise<{ ok: boolean; code: number; msg: string }> { const token = await getTenantToken(); const url = `https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`; // 决策 ID 嵌入按钮 value,回调时可定位 const id = decisionId || `decision-${Date.now()}`; const cardContent = { header: { title: { tag: 'plain_text', content: card.title }, template: 'blue', }, elements: [ { tag: 'div', text: { tag: 'plain_text', content: card.description } }, { tag: 'action', actions: card.options.map((opt) => ({ tag: 'button', text: { tag: 'plain_text', content: opt.label }, type: opt.style === 'danger' ? 'danger' : opt.style === 'primary' ? 'primary' : 'default', value: { action: opt.key, decisionId: id }, })), }, ], }; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ receive_id: receiveId, msg_type: 'interactive', content: JSON.stringify(cardContent), }), }); const data = await res.json(); return { ok: data.code === 0, code: data.code, msg: data.msg, }; } // ============================================================ // 事件回调 & 卡片回调 // ============================================================ /** * 飞书事件回调请求体 */ export interface FeishuEventRequest { /** schema 版本 */ schema?: string; /** 事件订阅头部 */ header?: { event_id: string; event_type: string; create_time: string; token: string; app_id: string; tenant_key: string; }; /** 事件内容 */ event?: Record; /** URL 验证的 challenge */ challenge?: string; token?: string; type?: string; } /** * 消息接收事件体(im.message.receive_v1) */ export interface FeishuMessageEvent { sender: { sender_id: { union_id: string; user_id: string; open_id: string }; sender_type: string; tenant_key: string; }; message: { message_id: string; root_id: string; parent_id: string; create_time: string; chat_id: string; chat_type: 'p2p' | 'group'; message_type: string; content: string; mentions?: Array<{ key: string; id: { union_id: string; user_id: string; open_id: string }; name: string; tenant_key: string; }>; }; } /** * 卡片交互回调请求体 */ export interface FeishuCardActionRequest { open_id: string; open_message_id: string; open_chat_id: string; operator?: { open_id: string; user_id: string; }; action?: { value: Record; tag: string; }; token?: string; } /** * 消息处理回调类型 */ export type MessageHandler = ( event: FeishuMessageEvent, ) => void | Promise; /** * 卡片操作处理回调类型 */ export type CardActionHandler = ( action: FeishuCardActionRequest, ) => { toast?: { type: 'success' | 'error' | 'info'; content: string } } | Promise<{ toast?: { type: 'success' | 'error' | 'info'; content: string } } | undefined> | undefined; // 注册的处理器 const messageHandlers: MessageHandler[] = []; const cardActionHandlers: CardActionHandler[] = []; /** * 注册消息处理器 */ export function onMessage(handler: MessageHandler): void { messageHandlers.push(handler); } /** * 注册卡片操作处理器 */ export function onCardAction(handler: CardActionHandler): void { cardActionHandlers.push(handler); } /** * 处理飞书事件回调(完整实现) * * 包含: * 1. URL 验证(challenge) * 2. Token 校验 * 3. 签名校验(使用 X-Lark-Signature / X-Lark-Request-Timestamp) * 4. 消息接收事件分发 */ export async function handleFeishuEvent( body: FeishuEventRequest, headers: Record, ): Promise<{ status: number; body: Record }> { // --- 1. URL 验证(配置事件订阅时飞书发来的 challenge 请求) --- if (body.type === 'url_verification' || body.challenge) { if (FEISHU_VERIFICATION_TOKEN && body.token !== FEISHU_VERIFICATION_TOKEN) { return { status: 403, body: { error: 'Token mismatch' } }; } return { status: 200, body: { challenge: body.challenge }, }; } // --- 2. 签名校验 --- const timestamp = headers['x-lark-request-timestamp'] || headers['x-lark-request-fetch'] || ''; const nonce = headers['x-lark-request-nonce'] || ''; const signature = headers['x-lark-signature'] || ''; if (FEISHU_VERIFICATION_TOKEN && timestamp && signature) { const expectedSig = genEventSignature(timestamp, nonce, FEISHU_VERIFICATION_TOKEN, JSON.stringify(body)); if (expectedSig !== signature) { console.warn('[Feishu] Signature verification failed'); return { status: 403, body: { error: 'Invalid signature' } }; } } // --- 3. Token 校验 --- if (FEISHU_VERIFICATION_TOKEN && body.header?.token && body.header.token !== FEISHU_VERIFICATION_TOKEN) { console.warn('[Feishu] Token verification failed'); return { status: 403, body: { error: 'Invalid token' } }; } // --- 4. 分发事件 --- const eventType = body.header?.event_type || body.type; if (eventType === 'im.message.receive_v1' && body.event) { const msgEvent = body.event as FeishuMessageEvent; console.log(`[Feishu] Message received: type=${msgEvent.message?.message_type}, chat=${msgEvent.message?.chat_type}, from=${msgEvent.sender?.sender_id?.open_id}`); for (const handler of messageHandlers) { try { await handler(msgEvent); } catch (err) { console.error('[Feishu] Message handler error:', err); } } } // 始终返回 200,避免飞书重试 return { status: 200, body: {} }; } /** * 生成事件签名(飞书 v2 校验方式) * 算法:sha256(timestamp + nonce + token + body) */ function genEventSignature(timestamp: string, nonce: string, token: string, bodyStr: string): string { const content = `${timestamp}${nonce}${token}${bodyStr}`; return createHash('sha256').update(content).digest('hex'); } /** * 处理卡片交互回调 * * 飞书卡片回调有两种方式: * 1. HTTP 回调(配置 Request URL)— 推送到服务端 * 2. 通过事件订阅 card.action.trigger — 走事件通道 * * 此函数处理方式 1(HTTP 回调),也兼容方式 2 */ export async function handleCardCallback( reqBody: FeishuCardActionRequest | { event?: Record; header?: Record }, ): Promise<{ status: number; body: Record }> { let action: FeishuCardActionRequest; // 事件订阅方式(card.action.trigger) if ('event' in reqBody && reqBody.event && 'header' in reqBody) { const eventData = reqBody.event as any; action = { open_id: eventData.operator?.open_id || eventData.user?.open_id || '', open_message_id: eventData.context?.open_message_id || eventData.message_id || '', open_chat_id: eventData.context?.open_chat_id || eventData.chat_id || '', operator: eventData.operator || eventData.user, action: eventData.action, token: reqBody.header?.token, }; } else { action = reqBody as FeishuCardActionRequest; } if (!action.action?.value) { return { status: 200, body: {} }; } console.log(`[Feishu] Card action: user=${action.open_id}, value=${JSON.stringify(action.action.value)}`); // 执行所有处理器,收集最后一个有返回值的 toast let toastResult: { toast?: { type: 'success' | 'error' | 'info'; content: string } } | undefined; for (const handler of cardActionHandlers) { try { const result = await handler(action); if (result?.toast) toastResult = result; } catch (err) { console.error('[Feishu] Card action handler error:', err); } } // 飞书卡片回调需要返回特定格式(含 toast 表示前端提示) return { status: 200, body: toastResult || { toast: { type: 'success' as const, content: '已收到' } }, }; } /** * 回复消息(通过 message_id 回复) */ export async function replyMessage( messageId: string, text: string, ): Promise<{ ok: boolean; code: number; msg: string }> { const token = await getTenantToken(); const url = `https://open.feishu.cn/open-apis/im/v1/messages/${messageId}/reply`; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ msg_type: 'text', content: JSON.stringify({ text }), }), }); const data = await res.json(); return { ok: data.code === 0, code: data.code, msg: data.msg, }; } /** * 更新卡片消息(根据 message_id 更新卡片内容) */ export async function updateCard( messageId: string, cardContent: Record, ): Promise<{ ok: boolean; code: number; msg: string }> { const token = await getTenantToken(); const url = `https://open.feishu.cn/open-apis/im/v1/messages/${messageId}`; const res = await fetch(url, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ content: JSON.stringify(cardContent), }), }); const data = await res.json(); return { ok: data.code === 0, code: data.code, msg: data.msg, }; }