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
This commit is contained in:
@@ -1,18 +1,29 @@
|
||||
/**
|
||||
* 飞书消息发送模块
|
||||
* 支持两种方式:
|
||||
* 1. 应用身份(推荐):使用 App ID/App Secret 获取 tenant_token 调用开放 API
|
||||
* 2. Webhook 方式:直接调用自定义机器人 Webhook(向后兼容)
|
||||
* 飞书消息模块
|
||||
*
|
||||
* 功能:
|
||||
* 1. 消息发送(应用身份 + Webhook)
|
||||
* 2. 事件回调处理(URL验证 + 签名校验 + 消息接收)
|
||||
* 3. 卡片交互回调(按钮点击)
|
||||
*
|
||||
* 飞书事件订阅配置:
|
||||
* - Request URL: https://your-domain/api/feishu/event
|
||||
* - 加密策略:Verification Token + Encrypt Key(可选)
|
||||
*/
|
||||
|
||||
import { createHmac } from 'crypto';
|
||||
import { createHmac, createHash } from 'crypto';
|
||||
|
||||
// 配置:从环境变量或 TOOLS.md 读取
|
||||
// 配置:从环境变量读取
|
||||
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
|
||||
*/
|
||||
@@ -185,12 +196,20 @@ export function buildDecisionCard(card: DecisionCardOptions): Record<string, unk
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送决策卡片
|
||||
* 发送决策卡片(带 decisionId 用于回调追踪)
|
||||
*/
|
||||
export async function sendDecisionCard(card: DecisionCardOptions, receiveId: string, receiveIdType: 'open_id' | 'user_id' = 'open_id'): Promise<{ ok: boolean; code: number; msg: string }> {
|
||||
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 },
|
||||
@@ -204,7 +223,7 @@ export async function sendDecisionCard(card: DecisionCardOptions, receiveId: str
|
||||
tag: 'button',
|
||||
text: { tag: 'plain_text', content: opt.label },
|
||||
type: opt.style === 'danger' ? 'danger' : opt.style === 'primary' ? 'primary' : 'default',
|
||||
value: { action: opt.key },
|
||||
value: { action: opt.key, decisionId: id },
|
||||
})),
|
||||
},
|
||||
],
|
||||
@@ -229,3 +248,289 @@ export async function sendDecisionCard(card: DecisionCardOptions, receiveId: str
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user