feat: add feishu notification support

This commit is contained in:
zaunist committed 2025-08-16 16:46:16 +08:00
1 parent 58a7db9f4d
commit f7d0fe0c03
5 files changed
+287 -37

No files matched your search

+197 -13
View File
@@ -201,6 +201,18 @@ interface ResendConfig {
to: string;
}
// 飞书配置接口
interface FeishuConfig {
webhookUrl: string;
}
// 企业微信配置接口
interface WeComConfig {
webhookUrl: string;
}
/**
* 解析通知渠道配置
*/
@@ -258,9 +270,11 @@ function parseChannelConfig<T>(channel: models.NotificationChannel): T {
}
}
/**
* 通过Resend API发送邮件通知
*/
// =================================================================
// Section: 各渠道发送器实现 (Sender Implementations)
// =================================================================
async function sendResendNotification(
channel: models.NotificationChannel,
subject: string,
@@ -427,8 +441,51 @@ async function sendTelegramNotification(
};
}
}
// =================================================================
// Section: 新的通知发送器抽象层 (Refactored Sender Abstraction)
// =================================================================
/**
* 根据渠道类型发送通知
* 定义了通知发送器的统一接口。
* 每种通知渠道(如邮件、Telegram)都必须实现这个接口。
* "Good code is all about making the data structures, so the code is obvious."
* 这个接口就是我们新的数据结构。
*/
interface NotificationSender {
(
channel: models.NotificationChannel,
subject: string,
content: string
): Promise<{ success: boolean; error?: string }>;
}
/**
* 发送器注册表。
* 这是一个从渠道类型字符串到其发送器实现的映射。
* "Talk is cheap. Show me the code."
* 这段代码取代了原来愚蠢的 if-else 链。
*/
const senderRegistry: Record<string, NotificationSender> = {};
/**
* 注册一个新的通知发送器。
* @param type 渠道类型 (e.g., 'resend', 'telegram')
* @param sender 实现了 NotificationSender 接口的函数
*/
function registerSender(type: string, sender: NotificationSender) {
if (senderRegistry[type]) {
console.warn(`[通知注册] 覆盖已存在的发送器: ${type}`);
}
senderRegistry[type] = sender;
console.log(`[通知注册] 成功注册发送器: ${type}`);
}
/**
* 根据渠道类型发送通知 (重构后)
* 这个函数现在只负责查找和调用,不再关心具体实现。
* "The point of interfaces is that you don't have to care."
*/
async function sendNotificationByChannel(
channel: models.NotificationChannel,
@@ -444,23 +501,150 @@ async function sendNotificationByChannel(
return { success: false, error: "通知渠道已禁用" };
}
console.log(`[渠道分发] 渠道ID=${channel.id}的类型为${channel.type}`);
if (channel.type === "resend") {
console.log(`[渠道分发] 使用Resend邮件服务发送通知`);
return await sendResendNotification(channel, subject, content);
} else if (channel.type === "telegram") {
console.log(`[渠道分发] 使用Telegram发送通知`);
return await sendTelegramNotification(channel, subject, content);
const sender = senderRegistry[channel.type];
if (sender) {
console.log(`[渠道分发] 找到类型为 ${channel.type} 的发送器,开始执行`);
return await sender(channel, subject, content);
} else {
console.error(`[渠道分发] 不支持的通知渠道类型: ${channel.type}`);
return { success: false, error: `不支持的通知渠道类型: ${channel.type}` };
}
}
/**
* 发送通知
* 发送飞书通知
*/
async function sendFeishuNotification(
channel: models.NotificationChannel,
subject: string,
content: string
): Promise<{ success: boolean; error?: string }> {
try {
const config = parseChannelConfig<FeishuConfig>(channel);
const webhookUrl = config.webhookUrl;
if (!webhookUrl) {
console.error("[飞书通知] Webhook URL 不能为空");
return { success: false, error: "飞书 Webhook URL 不能为空" };
}
const message = {
msg_type: "interactive",
card: {
header: {
title: {
content: subject,
tag: "plain_text",
},
},
elements: [
{
tag: "div",
text: {
content: content,
tag: "lark_md",
},
},
],
},
};
console.log("[飞书通知] 准备发送通知到:", webhookUrl);
const response = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(message),
});
const responseData = await response.json();
if (responseData.StatusCode === 0 || responseData.code === 0) {
console.log("[飞书通知] 发送成功");
return { success: true };
} else {
console.error("[飞书通知] 发送失败:", responseData);
return {
success: false,
error: responseData.StatusMessage || responseData.msg || "发送失败",
};
}
} catch (error) {
console.error("发送飞书通知异常:", error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
// 注册已有的发送器
registerSender("resend", sendResendNotification);
registerSender("telegram", sendTelegramNotification);
registerSender("feishu", sendFeishuNotification);
/**
* 发送企业微信通知
*/
async function sendWeComNotification(
channel: models.NotificationChannel,
subject: string,
content: string
): Promise<{ success: boolean; error?: string }> {
try {
const config = parseChannelConfig<WeComConfig>(channel);
const webhookUrl = config.webhookUrl;
if (!webhookUrl) {
console.error("[企业微信通知] Webhook URL 不能为空");
return { success: false, error: "企业微信 Webhook URL 不能为空" };
}
// 企业微信的 Markdown 格式要求主题是加粗标题
const markdownContent = `**${subject}**\n\n${content}`;
const message = {
msgtype: "markdown",
markdown: {
content: markdownContent,
},
};
console.log("[企业微信通知] 准备发送通知到:", webhookUrl);
const response = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(message),
});
const responseData = await response.json();
if (responseData.errcode === 0) {
console.log("[企业微信通知] 发送成功");
return { success: true };
} else {
console.error("[企业微信通知] 发送失败:", responseData);
return {
success: false,
error: `错误码: ${responseData.errcode}, 错误信息: ${responseData.errmsg}`,
};
}
} catch (error) {
console.error("发送企业微信通知异常:", error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
registerSender("wecom", sendWeComNotification);
export async function sendNotification(
type: "monitor" | "agent" | "system",
targetId: number | null,
+2
View File
@@ -472,7 +472,9 @@ const enUS = {
"notifications.channels.errors.fromRequired": "Sender is required",
"notifications.channels.errors.toRequired": "Recipient is required",
"notifications.channels.errors.invalidFromEmail": "Invalid sender format",
"notifications.channels.errors.webhookUrlRequired": "Webhook URL is required",
"notifications.channels.apiKey": "Resend API Key",
"notifications.channels.webhookUrl": "Webhook URL",
"notifications.channels.getApiKey": "Get Resend API Key",
"notifications.channels.from": "Sender",
"notifications.channels.fromHint": 'Supported formats: "Your Name <email@example.com>" or "email@example.com"',
+2
View File
@@ -448,7 +448,9 @@ const zhCN = {
"notifications.channels.errors.fromRequired": "发件人为必填项",
"notifications.channels.errors.toRequired": "收件人为必填项",
"notifications.channels.errors.invalidFromEmail": "发件人格式无效",
"notifications.channels.errors.webhookUrlRequired": "Webhook URL为必填项",
"notifications.channels.apiKey": "Resend API密钥",
"notifications.channels.webhookUrl": "Webhook URL",
"notifications.channels.getApiKey": "获取Resend API密钥",
"notifications.channels.from": "发件人",
"notifications.channels.fromHint":
@@ -78,6 +78,8 @@ const NotificationsConfig = () => {
apiKey: "",
from: "",
to: "",
// Feishu & Wecom configuration
webhookUrl: "",
},
enabled: true,
});
@@ -88,6 +90,7 @@ const NotificationsConfig = () => {
apiKey: "",
from: "",
to: "",
webhookUrl: "",
});
const { t } = useTranslation();
@@ -267,6 +270,7 @@ const NotificationsConfig = () => {
apiKey: "",
from: "",
to: "",
webhookUrl: "",
},
enabled: true,
});
@@ -277,6 +281,7 @@ const NotificationsConfig = () => {
apiKey: "",
from: "",
to: "",
webhookUrl: "",
});
setIsAddChannelOpen(true);
};
@@ -322,6 +327,7 @@ const NotificationsConfig = () => {
apiKey: "",
from: "",
to: "",
webhookUrl: "",
},
enabled: channel.enabled,
});
@@ -349,6 +355,7 @@ const NotificationsConfig = () => {
apiKey: config.apiKey || "",
from: config.from || "",
to: config.to || "",
webhookUrl: "",
},
enabled: channel.enabled,
});
@@ -380,6 +387,7 @@ const NotificationsConfig = () => {
apiKey: config.apiKey || "",
from: config.from || "",
to: config.to || "",
webhookUrl: config.webhookUrl || "",
},
enabled: channel.enabled,
});
@@ -392,6 +400,7 @@ const NotificationsConfig = () => {
apiKey: "",
from: "",
to: "",
webhookUrl: "",
});
setIsEditChannelOpen(true);
};
@@ -418,6 +427,7 @@ const NotificationsConfig = () => {
apiKey: "",
from: "",
to: "",
webhookUrl: "",
};
let isValid = true;
@@ -465,6 +475,16 @@ const NotificationsConfig = () => {
}
}
// 验证 Feishu/WeCom 配置
if (channelForm.type === "feishu" || channelForm.type === "wecom") {
if (!channelForm.config.webhookUrl.trim()) {
errors.webhookUrl = t(
"notifications.channels.errors.webhookUrlRequired"
);
isValid = false;
}
}
setChannelFormErrors(errors);
return isValid;
};
@@ -499,6 +519,11 @@ const NotificationsConfig = () => {
to: channelForm.config.to,
};
channelData.config = JSON.stringify(configObj);
} else if (channelForm.type === "feishu" || channelForm.type === "wecom") {
const configObj = {
webhookUrl: channelForm.config.webhookUrl,
};
channelData.config = JSON.stringify(configObj);
} else {
// 将其他类型的配置也转换为字符串
channelData.config = JSON.stringify(channelForm.config);
@@ -1496,28 +1521,38 @@ const NotificationsConfig = () => {
apiKey: "",
from: "",
to: "",
webhookUrl: "",
});
// 根据选择的类型设置默认的配置值
const newConfig = {
botToken: "",
chatId: "",
apiKey: "",
from: "",
to: "",
webhookUrl: "",
};
switch (value) {
case "telegram":
newConfig.botToken = channelForm.config.botToken;
newConfig.chatId = channelForm.config.chatId;
break;
case "resend":
newConfig.apiKey = channelForm.config.apiKey;
newConfig.from = channelForm.config.from;
newConfig.to = channelForm.config.to;
break;
case "feishu":
case "wecom":
newConfig.webhookUrl = channelForm.config.webhookUrl;
break;
}
setChannelForm({
...channelForm,
type: value,
config:
value === "telegram"
? {
botToken: "",
chatId: "",
apiKey: "",
from: "",
to: "",
}
: {
botToken: "",
chatId: "",
apiKey: "",
from: "",
to: "",
},
config: newConfig,
});
console.log(`[通知渠道] 选择类型: ${value}`); // 调试信息
@@ -1533,6 +1568,12 @@ const NotificationsConfig = () => {
<SelectItem value="resend">
{t("notifications.channels.type.resend")}
</SelectItem>
<SelectItem value="feishu">
{t("notifications.channels.type.feishu")}
</SelectItem>
<SelectItem value="wecom">
{t("notifications.channels.type.wecom")}
</SelectItem>
<SelectItem value="webhook" disabled>
{t("notifications.channels.type.webhook")} (Coming Soon)
</SelectItem>
@@ -1542,12 +1583,6 @@ const NotificationsConfig = () => {
<SelectItem value="dingtalk" disabled>
{t("notifications.channels.type.dingtalk")} (Coming Soon)
</SelectItem>
<SelectItem value="wecom" disabled>
{t("notifications.channels.type.wecom")} (Coming Soon)
</SelectItem>
<SelectItem value="feishu" disabled>
{t("notifications.channels.type.feishu")} (Coming Soon)
</SelectItem>
</SelectContent>
</Select>
</Box>
@@ -1688,6 +1723,33 @@ const NotificationsConfig = () => {
</Box>
</>
)}
{(channelForm.type === "feishu" ||
channelForm.type === "wecom") && (
<Box>
<Text as="label" size="2" mb="1" weight="medium">
{t("notifications.channels.webhookUrl")}
</Text>
<TextField.Input
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
value={channelForm.config.webhookUrl}
onChange={(e) =>
setChannelForm({
...channelForm,
config: {
...channelForm.config,
webhookUrl: e.target.value,
},
})
}
/>
{channelFormErrors.webhookUrl && (
<Text size="1" color="red" mt="1">
{channelFormErrors.webhookUrl}
</Text>
)}
</Box>
)}
</Flex>
<Flex gap="3" mt="6" justify="end">
+1 -1
View File
@@ -2,7 +2,7 @@
export interface NotificationChannel {
id: number;
name: string;
type: string; // email, telegram
type: string; // telegram, resend, feishu, wecom
config: string; // JSON字符串
enabled: boolean;
created_by: number;