feat: add Lark group bot notifications (#47)

This commit is contained in:
Nayacco
2026-08-17 13:47:10 +08:00
committed by GitHub
parent 6ec950bfd2
commit 1a4032d013
17 changed files with 1013 additions and 22 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ export function PushplusTab({ value, onChange }: ChannelProps<PushplusForm>) {
<ChannelHeader title={t("启用 Pushplus 推送")} enabled={value.enabled} onToggle={(enabled) => onChange({ enabled })} />
<div className="space-y-4">
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
{t("该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。")}
{t("该渠道仅用于单向通知,不提供设备控制功能。新短信会逐条推送;启用通知的自动任务也会推送执行结果。")}
</div>
<Field label="Token">
<Input value={value.token} onChange={(e) => onChange({ token: e.target.value })} disabled={off} placeholder={t("Pushplus 用户的 Token")} />
+82 -8
View File
@@ -6,7 +6,7 @@ import { Select } from "../ui/Select";
import { Switch } from "../ui/Switch";
import { ChannelHeader, EmptyLine, Field, UrlListEditor } from "./controls";
import { HEADER_NAME_SUGGESTIONS, nextHeaderRowId } from "./model";
import type { BarkForm, EmailForm, HeaderRow, WebhookForm, WecomForm } from "./model";
import type { BarkForm, EmailForm, HeaderRow, LarkForm, WebhookForm, WecomForm } from "./model";
const HEADER_LIST_ID = "vocat-webhook-header-names";
@@ -21,11 +21,11 @@ function hasAnyUrl(urls: string[]): boolean {
return Array.isArray(urls) && urls.some((url) => String(url || "").trim().length > 0);
}
function SMSOnlyHint() {
function OneWayNotificationHint() {
const { t } = useI18n();
return (
<div className="mb-4 rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
{t("该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。")}
{t("该渠道仅用于单向通知,不提供设备控制功能。新短信会逐条推送;启用通知的自动任务也会推送执行结果。")}
</div>
);
}
@@ -51,7 +51,7 @@ export function BarkTab({ value, onChange, testing, onTest }: PushChannelProps<B
</Button>
}
/>
<SMSOnlyHint />
<OneWayNotificationHint />
<div className="space-y-4">
<UrlListEditor
urls={value.urls}
@@ -97,7 +97,7 @@ export function EmailTab({ value, onChange, testing, onTest }: PushChannelProps<
</Button>
}
/>
<SMSOnlyHint />
<OneWayNotificationHint />
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-10">
<Field label={t("SMTP 主机")} className="sm:col-span-5">
@@ -163,7 +163,7 @@ export function WebhookTab({ value, onChange, testing, onTest }: PushChannelProp
</Button>
}
/>
<SMSOnlyHint />
<OneWayNotificationHint />
<div className="space-y-4">
<UrlListEditor
urls={value.urls}
@@ -284,7 +284,7 @@ export function WecomTab({ value, onChange, testing, onTest }: PushChannelProps<
</Button>
}
/>
<SMSOnlyHint />
<OneWayNotificationHint />
<div className="space-y-4">
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
{t("每个企业微信消息推送 Webhook URL 单独占一行,点击添加 URL 新增一行;不使用逗号、空格或换行分隔多个 URL。")}
@@ -300,7 +300,81 @@ export function WecomTab({ value, onChange, testing, onTest }: PushChannelProps<
label={t("JSON 请求体模板")}
hint={
<>
{t("支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如")} <code>{"{{message}}"}</code>{lang === "zh" ? "。" : "."}
{t("支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如")} <code>{"{{message}}"}</code>{lang === "zh" ? "。" : ". "}
{t("可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。")}
</>
}
>
<Textarea
value={value.payloadTemplate}
onChange={(event) => onChange({ payloadTemplate: event.target.value })}
disabled={off}
rows={12}
className="font-mono text-xs"
/>
</Field>
</div>
</div>
);
}
export function LarkTab({ value, onChange, testing, onTest }: PushChannelProps<LarkForm>) {
const { t, lang } = useI18n();
const off = !value.enabled;
const complete = !!value.url.trim() && !!value.payloadTemplate.trim() && (!value.signingEnabled || !!value.secret.trim());
return (
<div className="pt-2">
<ChannelHeader
title={t("启用飞书 / Lark 群自定义机器人通知")}
enabled={value.enabled}
onToggle={(enabled) => onChange({ enabled })}
actions={
<Button size="small" variant="primary" plain loading={testing} disabled={off || !complete} onClick={onTest}>
{t("测试通知")}
</Button>
}
/>
<OneWayNotificationHint />
<div className="space-y-4">
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
{t("支持飞书 open.feishu.cn 与国际版 Lark open.larksuite.com 的群自定义机器人 Webhook,无需创建应用。")}
</div>
<Field label={t("群机器人 Webhook URL")}>
<Input
value={value.url}
onChange={(event) => {
const url = event.target.value;
onChange(value.url === "********" && url !== value.url ? { url, secret: "" } : { url });
}}
disabled={off}
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
/>
</Field>
<div className="space-y-1">
<label className="block text-xs font-bold uppercase tracking-wider text-gray-500">{t("启用签名校验")}</label>
<div className="flex h-10 items-center">
<Switch checked={value.signingEnabled} onChange={(signingEnabled) => onChange({ signingEnabled })} disabled={off} />
</div>
</div>
{value.signingEnabled ? (
<Field
label={t("签名密钥 (Secret)")}
hint={t("填写群机器人安全设置生成的签名密钥;Webhook URL 与密钥都会作为敏感配置并在页面中脱敏。")}
>
<Input
value={value.secret}
onChange={(event) => onChange({ secret: event.target.value })}
disabled={off}
type="password"
placeholder={t("群机器人签名密钥")}
/>
</Field>
) : null}
<Field
label={t("JSON 请求体模板")}
hint={
<>
{t("支持完整飞书 / Lark 群自定义机器人 JSON。变量必须作为 JSON 值使用,例如")} <code>{"{{message}}"}</code>{lang === "zh" ? "。" : ". "}
{t("可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。")}
</>
}
+43
View File
@@ -59,6 +59,14 @@ export interface WecomForm {
payloadTemplate: string;
}
export interface LarkForm {
enabled: boolean;
url: string;
signingEnabled: boolean;
secret: string;
payloadTemplate: string;
}
export const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
"msgtype": "text",
"text": {
@@ -66,6 +74,13 @@ export const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
}
}`;
export const DEFAULT_LARK_PAYLOAD_TEMPLATE = `{
"msg_type": "text",
"content": {
"text": {{message}}
}
}`;
export interface NotifyForms {
telegram: TelegramForm;
webhook: WebhookForm;
@@ -73,6 +88,7 @@ export interface NotifyForms {
email: EmailForm;
pushplus: PushplusForm;
wecom: WecomForm;
lark: LarkForm;
}
// 系统保留头,自定义同名头会被忽略(品牌 vocat)
@@ -148,6 +164,7 @@ export function formsFromNotifications(data: Partial<NotificationSettings>): Not
const email = asRecord(data.email);
const pushplus = asRecord(data.pushplus);
const wecom = asRecord(data.wecom);
const lark = asRecord(data.lark);
return {
telegram: {
enabled: !!telegram.enabled,
@@ -197,6 +214,13 @@ export function formsFromNotifications(data: Partial<NotificationSettings>): Not
urls: strList(wecom.urls),
payloadTemplate: str(wecom.payloadTemplate ?? wecom.payload_template) || DEFAULT_WECOM_PAYLOAD_TEMPLATE,
},
lark: {
enabled: !!lark.enabled,
url: str(lark.url),
signingEnabled: !!lark.signingEnabled,
secret: lark.signingEnabled ? str(lark.secret) : "",
payloadTemplate: str(lark.payloadTemplate ?? lark.payload_template) || DEFAULT_LARK_PAYLOAD_TEMPLATE,
},
};
}
@@ -255,6 +279,24 @@ export function buildWecomPayload(form: WecomForm, forTest = false) {
};
}
export function buildLarkPayload(form: LarkForm, forTest = false) {
const payload: {
enabled: boolean;
url?: string;
signing_enabled: boolean;
payload_template: string;
secret?: string;
} = {
enabled: !!form.enabled,
signing_enabled: !!form.signingEnabled,
payload_template: String(form.payloadTemplate || ""),
};
const url = forTest ? String(form.url || "").trim() : String(form.url || "");
if (url) payload.url = url;
if (form.signingEnabled) payload.secret = String(form.secret || "");
return payload;
}
export function buildNotificationsPayload(forms: NotifyForms) {
return {
telegram: {
@@ -276,5 +318,6 @@ export function buildNotificationsPayload(forms: NotifyForms) {
webhook: buildWebhookPayload(forms.webhook),
bark: buildBarkPayload(forms.bark),
wecom: buildWecomPayload(forms.wecom),
lark: buildLarkPayload(forms.lark),
};
}
+16 -3
View File
@@ -325,6 +325,7 @@ export const EN_DICT: Record<string, string> = {
"Bark 测试失败": "Bark test failed",
"Email 测试失败": "Email test failed",
"企业微信消息推送测试失败": "WeCom message push test failed",
"飞书 / Lark 群机器人通知测试失败": "Feishu / Lark group bot notification test failed",
// ---- 设置页:安全卡 ----
: "Security",
@@ -394,8 +395,8 @@ export const EN_DICT: Record<string, string> = {
"启用后会推送新短信,并允许指定管理员通过 Bot 查看状态、切卡、管理 WiFi Calling、发送短信和限时拨号。拨号只执行呼叫并自动挂断,不处理音频。":
"When enabled, new SMS messages are pushed and the designated administrator can check status, switch profiles, manage WiFi Calling, send SMS, and place timed calls. Calls only dial and hang up automatically; audio is not processed.",
"启用 Pushplus 推送": "Enable Pushplus",
"该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。":
"This channel only pushes newly received SMS messages and provides no device controls. Every SMS is pushed separately and is not merged by content.",
"该渠道仅用于单向通知,不提供设备控制功能。新短信会逐条推送;启用通知的自动任务也会推送执行结果。":
"This channel is for one-way notifications only and provides no device controls. New SMS messages are delivered individually, and notification-enabled automatic tasks also send their results.",
"例如 123456": "e.g. 123456",
"接收短信通知和命令回复的私聊或群组 ID。群组 ID 可以是负数。":
"Private chat or group ID that receives SMS notifications and command replies. Group IDs may be negative.",
@@ -424,7 +425,9 @@ export const EN_DICT: Record<string, string> = {
"启用 Webhook 推送": "Enable Webhook",
"企业微信消息推送": "WeCom Message Push",
"启用企业微信消息推送": "Enable WeCom Message Push",
"Telegram / Bark / Email / Pushplus / Webhook / 企业微信消息推送": "Telegram / Bark / Email / Pushplus / Webhook / WeCom Message Push",
"飞书 / Lark 群机器人": "Feishu / Lark Group Bot",
"启用飞书 / Lark 群自定义机器人通知": "Enable Feishu / Lark Custom Group Bot Notifications",
"Telegram / Bark / Email / Pushplus / Webhook / 企业微信 / 飞书 / Lark 群机器人": "Telegram / Bark / Email / Pushplus / Webhook / WeCom / Feishu / Lark Group Bot",
"目标 URLs": "Target URLs",
"添加 URL": "Add URL",
"尚未配置任何 Bark URL,点击右侧添加按钮。": "No Bark URLs yet. Click the add button on the right.",
@@ -436,6 +439,16 @@ export const EN_DICT: Record<string, string> = {
"支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如": "Supports a complete WeCom message push JSON payload. Use variables as JSON values, for example",
"可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。":
"Available variables: {{event}}, {{title}}, {{message}}, {{timestamp}}, {{content}}, {{number}}, {{device_id}}, {{device_name}}, {{device_label}}, {{time}}.",
"支持飞书 open.feishu.cn 与国际版 Lark open.larksuite.com 的群自定义机器人 Webhook,无需创建应用。":
"Supports custom group bot Webhooks for Feishu at open.feishu.cn and international Lark at open.larksuite.com; no app is required.",
"群机器人 Webhook URL": "Group Bot Webhook URL",
: "Enable Signature Verification",
"签名密钥 (Secret)": "Signing Secret",
"填写群机器人安全设置生成的签名密钥;Webhook URL 与密钥都会作为敏感配置并在页面中脱敏。":
"Enter the signing secret generated in the group bot security settings. The Webhook URL and secret are treated as sensitive and masked in the UI.",
: "Group bot signing secret",
"支持完整飞书 / Lark 群自定义机器人 JSON。变量必须作为 JSON 值使用,例如":
"Supports a complete Feishu / Lark custom group bot JSON payload. Use variables as JSON values, for example",
"分组 (Group)": "Group",
"例如 vocat": "e.g. vocat",
"iOS 设备上的通知分组。": "Notification group on iOS devices.",
+25 -3
View File
@@ -13,6 +13,7 @@ import { useAuth } from "../store/auth";
import {
buildBarkPayload,
buildEmailPayload,
buildLarkPayload,
buildNotificationsPayload,
buildWecomPayload,
buildWebhookPayload,
@@ -21,7 +22,7 @@ import {
type NotifyForms,
} from "../components/settings/model";
import { PushplusTab, TelegramTab } from "../components/settings/BotTabs";
import { BarkTab, EmailTab, WebhookTab, WecomTab } from "../components/settings/PushTabs";
import { BarkTab, EmailTab, LarkTab, WebhookTab, WecomTab } from "../components/settings/PushTabs";
import { PluginsCard } from "../components/settings/PluginsCard";
import { HTTPSCard } from "../components/settings/HTTPSCard";
import { DeviceQuotaCard } from "../components/settings/DeviceQuotaCard";
@@ -36,6 +37,7 @@ const NOTIFY_TABS = [
{ key: "pushplus", label: "Pushplus" },
{ key: "webhook", label: "Webhook" },
{ key: "wecom", label: "企业微信消息推送" },
{ key: "lark", label: "飞书 / Lark 群机器人" },
];
const EMPTY_SYSTEM_INFO: SystemInfo = { version: "", buildTime: "", config: "" };
@@ -54,6 +56,7 @@ export default function SettingsPage() {
const [testingBark, setTestingBark] = useState(false);
const [testingEmail, setTestingEmail] = useState(false);
const [testingWecom, setTestingWecom] = useState(false);
const [testingLark, setTestingLark] = useState(false);
const [changingPassword, setChangingPassword] = useState(false);
const [checkingUpdate, setCheckingUpdate] = useState(false);
const [applyingUpdate, setApplyingUpdate] = useState(false);
@@ -265,10 +268,11 @@ export default function SettingsPage() {
setSavingNotif(true);
try {
// vocat 后端 PUT 成功即返回完整配置文档(参考实现返回 {applied, warning}
await api("/settings/notifications", {
const data = await api<NotificationSettings>("/settings/notifications", {
method: "PUT",
body: buildNotificationsPayload(forms),
});
setForms(formsFromNotifications(data));
message.success(t("通知配置已保存"));
} catch (error) {
message.error(apiMessage(error) || t("通知配置保存失败"));
@@ -338,6 +342,21 @@ export default function SettingsPage() {
}
}, [forms.wecom]);
const onTestLark = useCallback(async () => {
setTestingLark(true);
try {
await api("/settings/notifications/lark/test", {
method: "POST",
body: buildLarkPayload(forms.lark, true),
});
message.success(t("测试通知已发送"));
} catch (error) {
message.error(apiMessage(error) || t("飞书 / Lark 群机器人通知测试失败"));
} finally {
setTestingLark(false);
}
}, [forms.lark]);
const onCheckUpdate = useCallback(async () => {
setCheckingUpdate(true);
try {
@@ -466,7 +485,7 @@ export default function SettingsPage() {
<CardIcon>
<AlertRegular className="text-[24px]" />
</CardIcon>
<CardTitle title={t("通知")} subtitle={t("Telegram / Bark / Email / Pushplus / Webhook / 企业微信消息推送")} />
<CardTitle title={t("通知")} subtitle={t("Telegram / Bark / Email / Pushplus / Webhook / 企业微信 / 飞书 / Lark 群机器人")} />
</div>
<Button variant="primary" loading={savingNotif} disabled={loadingNotif} onClick={onSaveNotifications} className="!border-0" icon={<CheckmarkRegular />}>
{t("保存通知配置")}
@@ -500,6 +519,9 @@ export default function SettingsPage() {
{activeTab === "wecom" ? (
<WecomTab value={forms.wecom} onChange={(p) => updateChannel("wecom", p)} testing={testingWecom} onTest={onTestWecom} />
) : null}
{activeTab === "lark" ? (
<LarkTab value={forms.lark} onChange={(p) => updateChannel("lark", p)} testing={testingLark} onTest={onTestLark} />
) : null}
</div>
)}
</div>
+1
View File
@@ -420,6 +420,7 @@ export interface NotificationSettings {
email: Record<string, unknown>;
pushplus: Record<string, unknown>;
wecom: Record<string, unknown>;
lark: Record<string, unknown>;
}
// 网络访问控制策略:默认仅放行内网网段,可切换到对公网开放。