232 lines
7.1 KiB
TypeScript
232 lines
7.1 KiB
TypeScript
/**
|
||
* 飞书消息发送模块
|
||
* 支持两种方式:
|
||
* 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;
|
||
/** 接收者 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,
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 发送决策卡片
|
||
*/
|
||
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,
|
||
};
|
||
}
|