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
537 lines
16 KiB
TypeScript
537 lines
16 KiB
TypeScript
/**
|
||
* 飞书消息模块
|
||
*
|
||
* 功能:
|
||
* 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;
|
||
/** 接收者 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<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 — 走事件通道
|
||
*
|
||
* 此函数处理方式 1(HTTP 回调),也兼容方式 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,
|
||
};
|
||
}
|