141 lines
3.4 KiB
TypeScript
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 ? "***" : "",
|
|
},
|
|
};
|
|
}
|