Files
pmp-tool/src/server/feishu.ts
xiaohei 2532cf4f4e
Some checks failed
CI / lint-and-typecheck (push) Failing after 42s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
feat: P1 full implementation - 8 modules + frontend pages + feishu WS
P1 Backend (8 modules, 50+ new API endpoints):
- P1-1: Stakeholder management (power-interest matrix)
- P1-2: WBS task decomposition (tree structure + AI split)
- P1-3: Risk management (register + matrix + AI identify)
- P1-4: Requirement pool (MoSCoW + coverage + conflict detect)
- P1-5: Change management (submit → evaluate → approve → implement)
- P1-6: Health report (6 dimensions + red/yellow/green)
- P1-7: Retrospective & knowledge base
- P1-8: Multi-model support (6 models + strategy selection)

Frontend (9 pages with sidebar navigation):
- Project wizard, Kanban, Stakeholders, WBS, Risks,
  Requirements, Changes, Health, Retrospective

Feishu integration:
- WebSocket long-connection for receiving messages
- Card button callback with decision tracking
- 11 chat commands for P0+P1 features

Tech: TypeScript, React 18, Arco Design, Hono, @larksuiteoapi/node-sdk
2026-04-12 18:51:41 +08:00

537 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 飞书消息模块
*
* 功能:
* 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<string> {
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;
/** 接收者 IDopen_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<string, unknown> = {
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<void> {
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<void> {
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<void> {
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<string, unknown> {
const elements: Record<string, unknown>[] = [
{
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<string, any>;
/** 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<string, string>;
tag: string;
};
token?: string;
}
/**
* 消息处理回调类型
*/
export type MessageHandler = (
event: FeishuMessageEvent,
) => void | Promise<void>;
/**
* 卡片操作处理回调类型
*/
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<string, string>,
): Promise<{ status: number; body: Record<string, any> }> {
// --- 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 — 走事件通道
*
* 此函数处理方式 1HTTP 回调),也兼容方式 2
*/
export async function handleCardCallback(
reqBody: FeishuCardActionRequest | { event?: Record<string, any>; header?: Record<string, any> },
): Promise<{ status: number; body: Record<string, any> }> {
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<string, any>,
): 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,
};
}