Files
pmp-tool/src/server/feishu.ts
xiaohei 28340b23c1
Some checks failed
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled
CI / lint-and-typecheck (push) Has been cancelled
fix: 飞书卡片消息格式修正 - msg_type=interactive需用content传card JSON
2026-04-12 02:02:20 +08:00

232 lines
7.1 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. 应用身份(推荐):使用 App ID/App Secret 获取 tenant_token 调用开放 API
* 2. Webhook 方式:直接调用自定义机器人 Webhook向后兼容
*/
import { createHmac } 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';
/**
* 使用应用身份获取 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,
},
};
}
/**
* 发送决策卡片
*/
export async function sendDecisionCard(card: DecisionCardOptions, receiveId: string, receiveIdType: 'open_id' | 'user_id' = 'open_id'): 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}`;
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 },
})),
},
],
};
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,
};
}