feat: add dingtalk robot update notifications
This commit is contained in:
parent
94a22f0cc6
commit
38c10d616b
@ -87,6 +87,12 @@ import {
|
||||
normalizeCanvasImageTaskQueueConfig,
|
||||
type CanvasImageTaskQueueConfig,
|
||||
} from "@/lib/canvas/image-task-config";
|
||||
import {
|
||||
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
|
||||
defaultDingTalkRobotNotificationConfig,
|
||||
normalizeDingTalkRobotNotificationConfig,
|
||||
type DingTalkRobotNotificationConfig,
|
||||
} from "@/lib/notifications/dingtalk-robot-config";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
|
||||
@ -1513,6 +1519,13 @@ export function AdminSettingsClient() {
|
||||
`/api/admin/settings?key=${WORLDLABS_KEY_POOL_CONFIG_KEY}`,
|
||||
fetcher
|
||||
);
|
||||
const {
|
||||
data: dingTalkRobotNotificationConfigData,
|
||||
mutate: mutateDingTalkRobotNotificationConfig,
|
||||
} = useSWR<{ setting?: { value?: unknown } }>(
|
||||
`/api/admin/settings?key=${DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY}`,
|
||||
fetcher
|
||||
);
|
||||
const {
|
||||
data: threeDModelAliasConfigData,
|
||||
mutate: mutateThreeDModelAliasConfig,
|
||||
@ -2830,6 +2843,14 @@ export function AdminSettingsClient() {
|
||||
title: "",
|
||||
content: "",
|
||||
});
|
||||
const [dingTalkRobotNotificationConfig, setDingTalkRobotNotificationConfig] =
|
||||
useState<DingTalkRobotNotificationConfig>(
|
||||
defaultDingTalkRobotNotificationConfig,
|
||||
);
|
||||
const [isSavingDingTalkRobotNotificationConfig, setIsSavingDingTalkRobotNotificationConfig] =
|
||||
useState(false);
|
||||
const [isSendingDingTalkNotification, setIsSendingDingTalkNotification] =
|
||||
useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/settings?key=login_notification")
|
||||
@ -2841,6 +2862,13 @@ export function AdminSettingsClient() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const value = dingTalkRobotNotificationConfigData?.setting?.value;
|
||||
setDingTalkRobotNotificationConfig(
|
||||
normalizeDingTalkRobotNotificationConfig(value),
|
||||
);
|
||||
}, [dingTalkRobotNotificationConfigData]);
|
||||
|
||||
const handleSaveNotification = async () => {
|
||||
try {
|
||||
await fetch("/api/admin/settings", {
|
||||
@ -2857,6 +2885,68 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDingTalkRobotNotificationConfig = async () => {
|
||||
setIsSavingDingTalkRobotNotificationConfig(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
key: DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
|
||||
value: dingTalkRobotNotificationConfig,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("保存失败");
|
||||
}
|
||||
|
||||
toast.success("钉钉群机器人配置已保存");
|
||||
mutateDingTalkRobotNotificationConfig();
|
||||
} catch (error) {
|
||||
toast.error("保存钉钉群机器人配置失败");
|
||||
} finally {
|
||||
setIsSavingDingTalkRobotNotificationConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendNotificationToDingTalk = async () => {
|
||||
const title = String(notification.title || "").trim();
|
||||
const content = String(notification.content || "").trim();
|
||||
|
||||
if (!title) {
|
||||
toast.error("请先填写系统通知标题");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
toast.error("请先填写系统通知内容");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSendingDingTalkNotification(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/dingtalk-robot/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, content }),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok || payload?.success === false) {
|
||||
throw new Error(payload?.error || "发送失败");
|
||||
}
|
||||
|
||||
toast.success("当前系统通知已发送到钉钉群");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "发送钉钉群通知失败",
|
||||
);
|
||||
} finally {
|
||||
setIsSendingDingTalkNotification(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (questionsData) {
|
||||
setQuestions(questionsData);
|
||||
@ -13421,7 +13511,7 @@ export function AdminSettingsClient() {
|
||||
</TabsContent>
|
||||
|
||||
{/* 系统通知设置 */}
|
||||
<TabsContent value="notification" className="mt-6">
|
||||
<TabsContent value="notification" className="mt-6 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">系统通知</CardTitle>
|
||||
@ -13471,6 +13561,91 @@ export function AdminSettingsClient() {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">钉钉群机器人通知</CardTitle>
|
||||
<CardDescription>
|
||||
配置钉钉自定义机器人后,可将当前系统通知一键发送到钉钉群
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="dingtalk-robot-enabled"
|
||||
checked={dingTalkRobotNotificationConfig.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setDingTalkRobotNotificationConfig((current) => ({
|
||||
...current,
|
||||
enabled: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="dingtalk-robot-enabled">
|
||||
启用钉钉群机器人通知
|
||||
</Label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dingtalk-robot-webhook">Webhook 地址</Label>
|
||||
<Input
|
||||
id="dingtalk-robot-webhook"
|
||||
value={dingTalkRobotNotificationConfig.webhookUrl}
|
||||
onChange={(e) =>
|
||||
setDingTalkRobotNotificationConfig((current) => ({
|
||||
...current,
|
||||
webhookUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://oapi.dingtalk.com/robot/send?access_token=..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dingtalk-robot-secret">
|
||||
加签密钥(可选)
|
||||
</Label>
|
||||
<Input
|
||||
id="dingtalk-robot-secret"
|
||||
type="password"
|
||||
value={dingTalkRobotNotificationConfig.secret}
|
||||
onChange={(e) =>
|
||||
setDingTalkRobotNotificationConfig((current) => ({
|
||||
...current,
|
||||
secret: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="如机器人启用了安全加签,请填写"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
建议在钉钉群中创建“自定义机器人”,将 Webhook 与加签密钥配置到这里。发送时会复用当前系统通知的标题与正文。
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
onClick={handleSaveDingTalkRobotNotificationConfig}
|
||||
disabled={isSavingDingTalkRobotNotificationConfig}
|
||||
>
|
||||
{isSavingDingTalkRobotNotificationConfig ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
保存机器人配置
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSendNotificationToDingTalk}
|
||||
disabled={isSendingDingTalkNotification}
|
||||
>
|
||||
{isSendingDingTalkNotification ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
发送当前系统通知到钉钉群
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* AI 模型配置 */}
|
||||
|
||||
67
app/api/admin/dingtalk-robot/send/route.ts
Normal file
67
app/api/admin/dingtalk-robot/send/route.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { z } from "zod";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
import {
|
||||
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
|
||||
defaultDingTalkRobotNotificationConfig,
|
||||
normalizeDingTalkRobotNotificationConfig,
|
||||
} from "@/lib/notifications/dingtalk-robot-config";
|
||||
import { sendDingTalkRobotNotification } from "@/lib/notifications/dingtalk-robot";
|
||||
|
||||
const bodySchema = z.object({
|
||||
title: z.string().trim().min(1, "通知标题不能为空").max(100, "通知标题过长"),
|
||||
content: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "通知内容不能为空")
|
||||
.max(5000, "通知内容过长"),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const body = bodySchema.parse(await request.json());
|
||||
const setting = await getSystemSetting({
|
||||
key: DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
|
||||
}).catch(() => null);
|
||||
|
||||
const config = setting?.value
|
||||
? normalizeDingTalkRobotNotificationConfig(setting.value)
|
||||
: defaultDingTalkRobotNotificationConfig;
|
||||
|
||||
const result = await sendDingTalkRobotNotification({
|
||||
config,
|
||||
title: body.title,
|
||||
content: body.content,
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ChatSDKError) {
|
||||
return error.toResponse();
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.issues[0]?.message || "参数校验失败",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "发送钉钉通知失败",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -51,6 +51,10 @@ import {
|
||||
CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
|
||||
normalizeCanvasImageTaskQueueConfig,
|
||||
} from "@/lib/canvas/image-task-config";
|
||||
import {
|
||||
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
|
||||
normalizeDingTalkRobotNotificationConfig,
|
||||
} from "@/lib/notifications/dingtalk-robot-config";
|
||||
|
||||
const settingSchema = z.object({
|
||||
key: z.string(),
|
||||
@ -98,6 +102,12 @@ export async function GET(request: Request) {
|
||||
setting: setting ? { ...setting, value } : { key, value },
|
||||
});
|
||||
}
|
||||
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
|
||||
const value = normalizeDingTalkRobotNotificationConfig(setting?.value);
|
||||
return Response.json({
|
||||
setting: setting ? { ...setting, value } : { key, value },
|
||||
});
|
||||
}
|
||||
return Response.json({ setting });
|
||||
}
|
||||
|
||||
@ -232,6 +242,10 @@ export async function POST(request: Request) {
|
||||
nextValue = normalizeCanvasImageTaskQueueConfig(value);
|
||||
}
|
||||
|
||||
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
|
||||
nextValue = normalizeDingTalkRobotNotificationConfig(value);
|
||||
}
|
||||
|
||||
if (key === THIRD_PARTY_SQLSERVER_CONFIG_KEY) {
|
||||
const existingSetting = await getSystemSetting({
|
||||
key: THIRD_PARTY_SQLSERVER_CONFIG_KEY,
|
||||
|
||||
37
lib/notifications/dingtalk-robot-config.ts
Normal file
37
lib/notifications/dingtalk-robot-config.ts
Normal file
@ -0,0 +1,37 @@
|
||||
export const DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY =
|
||||
"dingtalk_robot_notification_config";
|
||||
|
||||
export type DingTalkRobotNotificationConfig = {
|
||||
enabled: boolean;
|
||||
webhookUrl: string;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
export const defaultDingTalkRobotNotificationConfig: DingTalkRobotNotificationConfig =
|
||||
{
|
||||
enabled: false,
|
||||
webhookUrl: "",
|
||||
secret: "",
|
||||
};
|
||||
|
||||
function normalizeRobotString(value: unknown) {
|
||||
return String(value || "")
|
||||
.replace(/[\u2028\u2029]/g, "")
|
||||
.replace(/[\u0000-\u001F\u007F]/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function normalizeDingTalkRobotNotificationConfig(
|
||||
value: unknown,
|
||||
): DingTalkRobotNotificationConfig {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return defaultDingTalkRobotNotificationConfig;
|
||||
}
|
||||
|
||||
const raw = value as Partial<DingTalkRobotNotificationConfig>;
|
||||
return {
|
||||
enabled: raw.enabled === true,
|
||||
webhookUrl: normalizeRobotString(raw.webhookUrl),
|
||||
secret: normalizeRobotString(raw.secret),
|
||||
};
|
||||
}
|
||||
134
lib/notifications/dingtalk-robot.ts
Normal file
134
lib/notifications/dingtalk-robot.ts
Normal file
@ -0,0 +1,134 @@
|
||||
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 || "").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());
|
||||
|
||||
const body = normalizedContent
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.join("\n\n");
|
||||
|
||||
return [`### ${normalizedTitle}`, "", body, "", `> 发送时间:${sentAt}`].join(
|
||||
"\n",
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendDingTalkRobotNotification(params: {
|
||||
config: DingTalkRobotNotificationConfig;
|
||||
title: string;
|
||||
content: string;
|
||||
}) {
|
||||
const config = normalizeDingTalkRobotNotificationConfig(params.config);
|
||||
|
||||
if (!config.enabled) {
|
||||
throw new Error("钉钉群机器人通知未启用");
|
||||
}
|
||||
|
||||
if (!config.webhookUrl) {
|
||||
throw new Error("钉钉群机器人 Webhook 未配置");
|
||||
}
|
||||
|
||||
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 ? "***" : "",
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user