feat: P1 full implementation - 8 modules + frontend pages + feishu WS
Some checks failed
CI / lint-and-typecheck (push) Failing after 42s
CI / test (push) Has been skipped
CI / build (push) Has been skipped

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:
xiaohei
2026-04-12 18:51:41 +08:00
parent ab0154bcf9
commit 2532cf4f4e
24 changed files with 4157 additions and 68 deletions

View File

@@ -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 — 走事件通道
*
* 此函数处理方式 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,
};
}