ai-chatbot/lib/notifications/dingtalk-robot.ts
Your Name 2561c66cb8
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: update workflow categories and admin notifications
2026-05-07 19:31:14 +08:00

141 lines
3.4 KiB
TypeScript

import "server-only";
import { createHmac } from "node:crypto";
import {
defaultDingTalkRobotNotificationConfig,
normalizeDingTalkRobotNotificationConfig,
type DingTalkRobotNotificationConfig,
} from "./dingtalk-robot-config";
function buildSignedWebhookUrl(webhookUrl: string, secret: string) {
const normalizedUrl = String(webhookUrl || "").trim();
const normalizedSecret = String(secret || "").trim();
if (!normalizedSecret) {
return normalizedUrl;
}
const timestamp = Date.now().toString();
const stringToSign = `${timestamp}\n${normalizedSecret}`;
const sign = createHmac("sha256", normalizedSecret)
.update(stringToSign, "utf8")
.digest("base64");
const url = new URL(normalizedUrl);
url.searchParams.set("timestamp", timestamp);
url.searchParams.set("sign", sign);
return url.toString();
}
function buildMarkdownText(params: { title: string; content: string }) {
const normalizedTitle = String(params.title || "").trim();
const normalizedContent = String(params.content || "")
.replace(/\r\n?/g, "\n")
.trim();
const sentAt = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(new Date());
return [
`### ${normalizedTitle}`,
"",
normalizedContent,
"",
`> 发送时间:${sentAt}`,
].join("\n");
}
export function assertDingTalkRobotNotificationConfigReady(
config: DingTalkRobotNotificationConfig,
) {
if (!config.enabled) {
throw new Error("钉钉群机器人通知未启用");
}
if (!config.webhookUrl) {
throw new Error("钉钉群机器人 Webhook 未配置");
}
}
export async function sendDingTalkRobotNotification(params: {
config: DingTalkRobotNotificationConfig;
title: string;
content: string;
}) {
const config = normalizeDingTalkRobotNotificationConfig(params.config);
assertDingTalkRobotNotificationConfigReady(config);
const title = String(params.title || "").trim();
const content = String(params.content || "").trim();
if (!title) {
throw new Error("通知标题不能为空");
}
if (!content) {
throw new Error("通知内容不能为空");
}
const requestUrl = buildSignedWebhookUrl(config.webhookUrl, config.secret);
const response = await fetch(requestUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
msgtype: "markdown",
markdown: {
title,
text: buildMarkdownText({ title, content }),
},
at: {
isAtAll: false,
},
}),
signal: AbortSignal.timeout(15_000),
});
const responseText = await response.text();
let responseData: any = null;
try {
responseData = responseText ? JSON.parse(responseText) : null;
} catch {
responseData = responseText;
}
if (!response.ok) {
throw new Error(
`钉钉群机器人请求失败:${response.status} ${response.statusText}`,
);
}
if (
responseData &&
typeof responseData === "object" &&
"errcode" in responseData &&
responseData.errcode !== 0
) {
throw new Error(
String(responseData.errmsg || "钉钉群机器人返回失败"),
);
}
return {
ok: true,
response: responseData,
configUsed: {
...defaultDingTalkRobotNotificationConfig,
...config,
secret: config.secret ? "***" : "",
},
};
}