feat: add feishu notification support
This commit is contained in:
1 parent
58a7db9f4d
commit
f7d0fe0c03
5 files changed
+287
-37
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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"',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user