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 ? "***" : "", }, }; }