From 7dbaa2508f0192732a841b4aff62d4cd5a1768d1 Mon Sep 17 00:00:00 2001 From: Malash Date: Sun, 11 Oct 2026 03:35:16 +0800 Subject: [PATCH] feat: add per-device notification filtering --- .../server/automatic_task_notifications.go | 37 ++++++++---- internal/server/call_notifications.go | 17 +++--- internal/server/lark_notification.go | 7 ++- internal/server/meow_notification.go | 11 +++- .../server/notification_device_scope_test.go | 34 +++++++++++ internal/server/notification_routing_test.go | 60 +++++++++++++++++++ internal/server/settings_api.go | 17 +++++- internal/server/sms_notifications.go | 24 +++++++- internal/server/telegram_bot.go | 32 ++++++---- internal/server/wecom_notification.go | 7 ++- web/src/components/settings/controls.tsx | 45 +++++++++++++- web/src/components/settings/model.ts | 21 +++++-- web/src/lib/i18n-en.ts | 6 +- web/src/pages/SettingsPage.tsx | 22 +++++-- web/src/types.ts | 20 ++++--- web/test/notificationSettingsModel.test.mjs | 21 ++++++- 16 files changed, 320 insertions(+), 61 deletions(-) create mode 100644 internal/server/notification_device_scope_test.go create mode 100644 internal/server/notification_routing_test.go diff --git a/internal/server/automatic_task_notifications.go b/internal/server/automatic_task_notifications.go index ace0559c..2f907ba7 100644 --- a/internal/server/automatic_task_notifications.go +++ b/internal/server/automatic_task_notifications.go @@ -89,29 +89,32 @@ func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTa // sendAutomaticTaskNotification 按渠道发送任务结果,仅 MeoW 使用无标题正文。 func sendAutomaticTaskNotification(ctx context.Context, channel string, config map[string]any, message automaticTaskNotification) error { if channel == "meow" { - return meowNotificationSender(ctx, config, message.Title, message.DetailText()) + return sendMeowNotification(ctx, config, &message.Task.DeviceID, message.Title, message.DetailText()) } switch channel { case "telegram": - return sendTelegramTextNotification(ctx, config, message.Text) + return sendTelegramTextNotification(ctx, config, message.Task.DeviceID, message.Text) case "bark": - return sendBarkTextNotification(ctx, config, message.Title, message.Text) + return sendBarkTextNotification(ctx, config, message.Task.DeviceID, message.Title, message.Text) case "email": - return sendEmailTextNotification(ctx, config, message.Title, message.Text) + return sendEmailTextNotification(ctx, config, message.Task.DeviceID, message.Title, message.Text) case "pushplus": - return sendPushplusTextNotification(ctx, config, message.Title, message.Text) + return sendPushplusTextNotification(ctx, config, message.Task.DeviceID, message.Title, message.Text) case "webhook": return sendAutomaticTaskWebhook(ctx, config, message) case "wecom": - return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message)) + return sendWecomNotification(ctx, config, &message.Task.DeviceID, wecomAutomaticTaskValues(message)) case "lark": - return sendLarkNotification(ctx, config, larkAutomaticTaskValues(message)) + return sendLarkNotification(ctx, config, &message.Task.DeviceID, larkAutomaticTaskValues(message)) default: return fmt.Errorf("unsupported notification channel %q", channel) } } -func sendTelegramTextNotification(ctx context.Context, config map[string]any, text string) error { +func sendTelegramTextNotification(ctx context.Context, config map[string]any, deviceID, text string) error { + if !notificationMatchesDevice(configStrings(config, "device_ids"), deviceID) { + return nil + } token := configString(config, "bot_token") parsed, err := validateTelegramAPIURL(ctx, configString(config, "base_url"), token, "sendMessage") if err != nil { @@ -131,7 +134,10 @@ func sendTelegramTextNotification(ctx context.Context, config map[string]any, te return performNotificationRequest(client, request, true) } -func sendBarkTextNotification(ctx context.Context, config map[string]any, title, text string) error { +func sendBarkTextNotification(ctx context.Context, config map[string]any, deviceID, title, text string) error { + if !notificationMatchesDevice(configStrings(config, "device_ids"), deviceID) { + return nil + } client, err := restrictedHTTPClient(ctx, 8*time.Second, "") if err != nil { return err @@ -161,7 +167,10 @@ func sendBarkTextNotification(ctx context.Context, config map[string]any, title, return nil } -func sendPushplusTextNotification(ctx context.Context, config map[string]any, title, text string) error { +func sendPushplusTextNotification(ctx context.Context, config map[string]any, deviceID, title, text string) error { + if !notificationMatchesDevice(configStrings(config, "device_ids"), deviceID) { + return nil + } destination, err := validateOutboundURL(ctx, "https://www.pushplus.plus/send", true) if err != nil { return err @@ -201,6 +210,9 @@ func sendPushplusTextNotification(ctx context.Context, config map[string]any, ti } func sendAutomaticTaskWebhook(ctx context.Context, config map[string]any, message automaticTaskNotification) error { + if !notificationMatchesDevice(configStrings(config, "device_ids"), message.Task.DeviceID) { + return nil + } payload, _ := json.Marshal(map[string]any{ "event": "automatic_task.completed", "message": message.Text, "timestamp": message.Time.UTC().Format(time.RFC3339), "task_id": message.Task.ID, @@ -239,7 +251,10 @@ func sendAutomaticTaskWebhook(ctx context.Context, config map[string]any, messag return nil } -func sendEmailTextNotification(ctx context.Context, config map[string]any, subject, text string) error { +func sendEmailTextNotification(ctx context.Context, config map[string]any, deviceID, subject, text string) error { + if !notificationMatchesDevice(configStrings(config, "device_ids"), deviceID) { + return nil + } host := strings.TrimSpace(configString(config, "smtp_host")) port := configInt(config, "smtp_port") if port == 0 { diff --git a/internal/server/call_notifications.go b/internal/server/call_notifications.go index 19abd588..c39b9c4c 100644 --- a/internal/server/call_notifications.go +++ b/internal/server/call_notifications.go @@ -156,23 +156,23 @@ func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCa // sendCallNotification 按渠道发送来电信息,MeoW 与其他独立标题渠道复用 DetailText。 func sendCallNotification(ctx context.Context, channel string, config map[string]any, message IncomingCallNotification) error { if channel == "meow" { - return meowNotificationSender(ctx, config, message.Title(), message.DetailText()) + return sendMeowNotification(ctx, config, &message.DeviceID, message.Title(), message.DetailText()) } switch channel { case "telegram": - return sendTelegramTextNotification(ctx, config, message.Text()) + return sendTelegramTextNotification(ctx, config, message.DeviceID, message.Text()) case "bark": - return sendBarkTextNotification(ctx, config, message.Title(), message.DetailText()) + return sendBarkTextNotification(ctx, config, message.DeviceID, message.Title(), message.DetailText()) case "email": - return sendEmailTextNotification(ctx, config, message.Title()+" - "+message.DeviceLabel, message.Text()) + return sendEmailTextNotification(ctx, config, message.DeviceID, message.Title()+" - "+message.DeviceLabel, message.Text()) case "pushplus": - return sendPushplusTextNotification(ctx, config, message.Title(), message.DetailText()) + return sendPushplusTextNotification(ctx, config, message.DeviceID, message.Title(), message.DetailText()) case "webhook": return sendCallWebhookNotification(ctx, config, message) case "wecom": - return sendWecomNotification(ctx, config, wecomCallValues(message)) + return sendWecomNotification(ctx, config, &message.DeviceID, wecomCallValues(message)) case "lark": - return sendLarkNotification(ctx, config, larkCallValues(message)) + return sendLarkNotification(ctx, config, &message.DeviceID, larkCallValues(message)) default: return fmt.Errorf("unsupported notification channel %q", channel) } @@ -204,6 +204,9 @@ func renderCallWebhookTemplate(template string, message IncomingCallNotification } func sendCallWebhookNotification(ctx context.Context, config map[string]any, message IncomingCallNotification) error { + if !notificationMatchesDevice(configStrings(config, "device_ids"), message.DeviceID) { + return nil + } template := configString(config, "text_template") rendered := renderCallWebhookTemplate(template, message) payload, _ := json.Marshal(map[string]any{ diff --git a/internal/server/lark_notification.go b/internal/server/lark_notification.go index 7a41a8a1..11c1827a 100644 --- a/internal/server/lark_notification.go +++ b/internal/server/lark_notification.go @@ -232,7 +232,10 @@ func larkSigningSecret(config map[string]any) string { return configString(config, "secret") } -func sendLarkNotification(ctx context.Context, config map[string]any, values larkTemplateValues) error { +func sendLarkNotification(ctx context.Context, config map[string]any, deviceID *string, values larkTemplateValues) error { + if deviceID != nil && !notificationMatchesDevice(configStrings(config, "device_ids"), *deviceID) { + return nil + } payload, err := renderLarkPayload(configString(config, "payload_template"), values) if err != nil { return err @@ -288,5 +291,5 @@ func sanitizeLarkRequestError(err error) error { } func sendLarkNotificationTest(ctx context.Context, config map[string]any) error { - return sendLarkNotification(ctx, config, larkTestValues(time.Now())) + return sendLarkNotification(ctx, config, nil, larkTestValues(time.Now())) } diff --git a/internal/server/meow_notification.go b/internal/server/meow_notification.go index 3d0a794c..ddfe45a0 100644 --- a/internal/server/meow_notification.go +++ b/internal/server/meow_notification.go @@ -25,9 +25,16 @@ func validateMeowNotificationConfig(config map[string]any) error { } // 默认调用原有 MeoW 发送函数;测试中可替换为本地接收端,不访问真实收件人。 -var meowNotificationSender = sendMeowNotification +var meowNotificationSender = deliverMeowNotification -func sendMeowNotification(ctx context.Context, config map[string]any, title, text string) error { +func sendMeowNotification(ctx context.Context, config map[string]any, deviceID *string, title, text string) error { + if deviceID != nil && !notificationMatchesDevice(configStrings(config, "device_ids"), *deviceID) { + return nil + } + return meowNotificationSender(ctx, config, title, text) +} + +func deliverMeowNotification(ctx context.Context, config map[string]any, title, text string) error { if err := validateMeowNotificationConfig(config); err != nil { return err } diff --git a/internal/server/notification_device_scope_test.go b/internal/server/notification_device_scope_test.go new file mode 100644 index 00000000..c38b808b --- /dev/null +++ b/internal/server/notification_device_scope_test.go @@ -0,0 +1,34 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" +) + +func TestNotificationDeviceScopeAPI(t *testing.T) { + api := newSettingsAPITest(t) + for _, channel := range notificationChannels { + for _, item := range []struct { + scope string + status int + }{ + {`null`, http.StatusOK}, {`[]`, http.StatusOK}, {`["offline-device"]`, http.StatusOK}, + {`"all"`, http.StatusBadRequest}, {`[1]`, http.StatusBadRequest}, {`[""]`, http.StatusBadRequest}, + } { + body := fmt.Sprintf(`{%q:{"enabled":false,"device_ids":%s}}`, channel, item.scope) + response := api.request(t, http.MethodPut, "/api/settings/notifications", body) + if response.Code != item.status { + t.Fatalf("%s scope=%s: status=%d, body=%s", channel, item.scope, response.Code, response.Body) + } + if item.status == http.StatusOK { + config := decodeSettingsResponse(t, response)["data"].(map[string]any)[channel].(map[string]any) + got, _ := json.Marshal(config["device_ids"]) + if string(got) != item.scope { + t.Fatalf("%s scope=%s, want %s", channel, got, item.scope) + } + } + } + } +} diff --git a/internal/server/notification_routing_test.go b/internal/server/notification_routing_test.go new file mode 100644 index 00000000..9ab6f3bb --- /dev/null +++ b/internal/server/notification_routing_test.go @@ -0,0 +1,60 @@ +package server + +import ( + "context" + "testing" + + "vocat/internal/store" +) + +func TestNotificationProvidersFilterDevices(t *testing.T) { + // Matching notifications reach validation/transport and fail; filtered ones + // return nil. Cancellation prevents any real network request. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + bot := &telegramBot{server: newSettingsTestServer(t)} + for _, scope := range []struct { + ids []string + send bool + }{{nil, true}, {[]string{}, false}, {[]string{"a"}, true}, {[]string{"b"}, false}} { + config := map[string]any{ + "device_ids": scope.ids, "urls": []string{"://invalid"}, "url": "://invalid", + "nickname": "/", "payload_template": "{", "smtp_host": "example.invalid", + } + for _, channel := range notificationChannels { + for event, send := range map[string]func() error{ + "sms": func() error { + message := smsNotification{DeviceID: "a"} + if channel == "telegram" { + return bot.sendSMSNotification(ctx, telegramRuntimeConfig{DeviceIDs: scope.ids}, store.SMSMessage{DeviceID: message.DeviceID}) + } + return sendSMSNotification(ctx, channel, config, message) + }, + "call": func() error { + return sendCallNotification(ctx, channel, config, IncomingCallNotification{DeviceID: "a"}) + }, + "task": func() error { + return sendAutomaticTaskNotification(ctx, channel, config, automaticTaskNotification{Task: store.AutomaticTask{DeviceID: "a"}}) + }, + } { + if err := send(); (err != nil) != scope.send { + t.Fatalf("%s/%s scope=%v: err=%v, want attempted=%v", channel, event, scope.ids, err, scope.send) + } + } + } + } +} + +func TestManualNotificationIgnoresDeviceScope(t *testing.T) { + ctx := context.Background() + config := map[string]any{"device_ids": []string{}, "nickname": "/", "payload_template": "{"} + for name, send := range map[string]func() error{ + "meow": func() error { return sendMeowNotification(ctx, config, nil, "test", "test") }, + "wecom": func() error { return sendWecomNotificationTest(ctx, config) }, + "lark": func() error { return sendLarkNotificationTest(ctx, config) }, + } { + if err := send(); err == nil { + t.Fatalf("%s skipped validation because of the device scope", name) + } + } +} diff --git a/internal/server/settings_api.go b/internal/server/settings_api.go index 5bb1a492..a2c0e86d 100644 --- a/internal/server/settings_api.go +++ b/internal/server/settings_api.go @@ -217,7 +217,7 @@ func (s *Server) writeNotificationSettings(w http.ResponseWriter, r *http.Reques } response := make(map[string]any, len(notificationChannels)) for _, channel := range notificationChannels { - document := map[string]any{"enabled": false} + document := map[string]any{"enabled": false, "device_ids": nil} if setting, ok := stored[channel]; ok { redacted := setting.Redacted() if err := json.Unmarshal(redacted.Config, &document); err != nil { @@ -265,6 +265,9 @@ func decodeNotificationConfig( fields := notificationFields[channel] for name, value := range document { kind, known := fields[name] + if name == "device_ids" { + kind, known = "device_ids", true + } if !known { return false, nil, fmt.Errorf("%s.%s is not supported", channel, name) } @@ -301,6 +304,16 @@ func validateNotificationField( ) error { field := channel + "." + name switch kind { + case "device_ids": + var ids []string + if json.Unmarshal(raw, &ids) != nil { + return fmt.Errorf("%s must be null or an array of device IDs", field) + } + for _, id := range ids { + if strings.TrimSpace(id) == "" { + return fmt.Errorf("%s contains an empty device ID", field) + } + } case "boolean": var value bool if err := json.Unmarshal(raw, &value); err != nil { @@ -507,7 +520,7 @@ func (s *Server) handleNotificationTest( case "lark": err = sendLarkNotificationTest(notificationContext, resolved) case "meow": - err = sendMeowNotification(notificationContext, resolved, "VoCat 测试通知", "VoCat 消息推送测试") + err = sendMeowNotification(notificationContext, resolved, nil, "VoCat 测试通知", "VoCat 消息推送测试") } if err != nil { redacted := store.RedactText(err.Error(), provider) diff --git a/internal/server/sms_notifications.go b/internal/server/sms_notifications.go index 84741c21..b1d05ee6 100644 --- a/internal/server/sms_notifications.go +++ b/internal/server/sms_notifications.go @@ -15,6 +15,7 @@ import ( "net/http" "net/mail" "net/smtp" + "slices" "strconv" "strings" "time" @@ -201,6 +202,11 @@ func (s *Server) deliverSMSNotificationsOnce( return smsNotificationRetryDelay(state.consecutiveFailures) } +// A missing/null scope keeps the existing all-device behavior; [] sends nothing. +func notificationMatchesDevice(deviceIDs []string, deviceID string) bool { + return deviceIDs == nil || slices.Contains(deviceIDs, deviceID) +} + func smsMessageReadyToNotify(message store.SMSMessage) bool { return strings.TrimSpace(message.Body) != "" && store.ConcatSMSReadyToNotify(message.MessageID, message.Extra) @@ -281,7 +287,7 @@ func (s *Server) logSMSNotificationError(channel string, err error) { // sendSMSNotification 按渠道发送短信,MeoW 与其他独立标题渠道复用 DetailText。 func sendSMSNotification(ctx context.Context, channel string, config map[string]any, message smsNotification) error { if channel == "meow" { - return meowNotificationSender(ctx, config, "收到新短信", message.DetailText()) + return sendMeowNotification(ctx, config, &message.DeviceID, "收到新短信", message.DetailText()) } switch channel { case "bark": @@ -293,15 +299,18 @@ func sendSMSNotification(ctx context.Context, channel string, config map[string] case "webhook": return sendWebhookSMSNotification(ctx, config, message) case "wecom": - return sendWecomNotification(ctx, config, wecomSMSValues(message)) + return sendWecomNotification(ctx, config, &message.DeviceID, wecomSMSValues(message)) case "lark": - return sendLarkNotification(ctx, config, larkSMSValues(message)) + return sendLarkNotification(ctx, config, &message.DeviceID, larkSMSValues(message)) default: return fmt.Errorf("unsupported SMS notification channel %q", channel) } } func sendBarkSMSNotification(ctx context.Context, config map[string]any, message smsNotification) error { + if !notificationMatchesDevice(configStrings(config, "device_ids"), message.DeviceID) { + return nil + } client, err := restrictedHTTPClient(ctx, 6*time.Second, "") if err != nil { return err @@ -332,6 +341,9 @@ func sendBarkSMSNotification(ctx context.Context, config map[string]any, message } func sendWebhookSMSNotification(ctx context.Context, config map[string]any, message smsNotification) error { + if !notificationMatchesDevice(configStrings(config, "device_ids"), message.DeviceID) { + return nil + } rendered := message.Text() if template := configString(config, "text_template"); strings.TrimSpace(template) != "" { rendered = renderSMSWebhookTemplate(template, message) @@ -420,6 +432,9 @@ func buildPushplusPayload(token, title, content, topic, channel string) map[stri } func sendPushplusSMSNotification(ctx context.Context, config map[string]any, message smsNotification) error { + if !notificationMatchesDevice(configStrings(config, "device_ids"), message.DeviceID) { + return nil + } destination, err := validateOutboundURL(ctx, "https://www.pushplus.plus/send", true) if err != nil { return err @@ -462,6 +477,9 @@ func sendPushplusSMSNotification(ctx context.Context, config map[string]any, mes } func sendEmailSMSNotification(ctx context.Context, config map[string]any, message smsNotification) error { + if !notificationMatchesDevice(configStrings(config, "device_ids"), message.DeviceID) { + return nil + } host := strings.TrimSpace(configString(config, "smtp_host")) port := configInt(config, "smtp_port") if port == 0 { diff --git a/internal/server/telegram_bot.go b/internal/server/telegram_bot.go index 0248cc2a..1f109379 100644 --- a/internal/server/telegram_bot.go +++ b/internal/server/telegram_bot.go @@ -36,11 +36,12 @@ const ( var telegramTokenInURLPattern = regexp.MustCompile(`bot[0-9]{5,20}:[A-Za-z0-9_-]{20,128}`) type telegramRuntimeConfig struct { - Token string - ChatID string - AdminID int64 - BaseURL string - Proxy string + Token string + ChatID string + AdminID int64 + BaseURL string + Proxy string + DeviceIDs []string } type telegramBot struct { @@ -2260,6 +2261,15 @@ func (bot *telegramBot) finishAction(ctx context.Context, config telegramRuntime bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", action.AdminID), auditAction, "device", action.DeviceID, outcome, "telegram") } +func (bot *telegramBot) sendSMSNotification(ctx context.Context, config telegramRuntimeConfig, message store.SMSMessage) error { + notification := bot.server.newSMSNotification(ctx, message) + if !notificationMatchesDevice(config.DeviceIDs, notification.DeviceID) { + return nil + } + text := fmt.Sprintf("📩 新短信\n设备:%s\n来自:%s\n时间:%s\n\n%s", notification.DeviceID, message.Peer, message.Timestamp.Local().Format("2006-01-02 15:04:05"), message.Body) + return bot.sendText(ctx, config, 0, text, nil) +} + func (bot *telegramBot) notifyInboundSMS(ctx context.Context) { cursorInitialized := false var cursor int64 @@ -2295,8 +2305,7 @@ func (bot *telegramBot) notifyInboundSMS(ctx context.Context) { cursor = message.ID continue } - text := fmt.Sprintf("📩 新短信\n设备:%s\n来自:%s\n时间:%s\n\n%s", message.DeviceID, message.Peer, message.Timestamp.Local().Format("2006-01-02 15:04:05"), message.Body) - if sendErr := bot.sendText(ctx, config, 0, text, nil); sendErr != nil { + if sendErr := bot.sendSMSNotification(ctx, config, message); sendErr != nil { bot.warn("send Telegram SMS notification", sendErr) break } @@ -2346,10 +2355,11 @@ func (bot *telegramBot) loadConfig(ctx context.Context) (telegramRuntimeConfig, return telegramRuntimeConfig{}, false, fmt.Errorf("decode Telegram config: %w", err) } config := telegramRuntimeConfig{ - Token: configString(raw, "bot_token"), - ChatID: configString(raw, "chat_id"), - BaseURL: configString(raw, "base_url"), - Proxy: configString(raw, "proxy"), + Token: configString(raw, "bot_token"), + ChatID: configString(raw, "chat_id"), + BaseURL: configString(raw, "base_url"), + Proxy: configString(raw, "proxy"), + DeviceIDs: configStrings(raw, "device_ids"), } if config.BaseURL == "" { config.BaseURL = defaultTelegramBaseURL diff --git a/internal/server/wecom_notification.go b/internal/server/wecom_notification.go index ed7d7723..42c0a610 100644 --- a/internal/server/wecom_notification.go +++ b/internal/server/wecom_notification.go @@ -160,7 +160,10 @@ func validateWecomNotificationConfig(config map[string]any) error { return err } -func sendWecomNotification(ctx context.Context, config map[string]any, values wecomTemplateValues) error { +func sendWecomNotification(ctx context.Context, config map[string]any, deviceID *string, values wecomTemplateValues) error { + if deviceID != nil && !notificationMatchesDevice(configStrings(config, "device_ids"), *deviceID) { + return nil + } payload, err := renderWecomPayload(configString(config, "payload_template"), values) if err != nil { return err @@ -202,5 +205,5 @@ func sendWecomNotification(ctx context.Context, config map[string]any, values we } func sendWecomNotificationTest(ctx context.Context, config map[string]any) error { - return sendWecomNotification(ctx, config, wecomTestValues(time.Now())) + return sendWecomNotification(ctx, config, nil, wecomTestValues(time.Now())) } diff --git a/web/src/components/settings/controls.tsx b/web/src/components/settings/controls.tsx index 793102c7..c973fb74 100644 --- a/web/src/components/settings/controls.tsx +++ b/web/src/components/settings/controls.tsx @@ -1,11 +1,12 @@ import { useState, type ReactNode } from "react"; -import { AddRegular, DeleteRegular, EyeOffRegular, EyeRegular } from "@fluentui/react-icons"; +import { AddRegular, CheckmarkRegular, DeleteRegular, EyeOffRegular, EyeRegular } from "@fluentui/react-icons"; import { tl, useI18n } from "../../lib/i18n"; import { cx } from "../../lib/utils"; import { Button } from "../ui/Button"; import { Input } from "../ui/Input"; import { Switch } from "../ui/Switch"; import { message } from "../ui/message"; +import type { DeviceListItem } from "../../types"; const LABEL_CLASS = "text-xs font-bold text-gray-500 uppercase tracking-wider"; @@ -216,6 +217,48 @@ export function SegmentedTabs({ ); } +export function NotificationDeviceScope({ value, onChange, devices, failed }: { + value: string[] | null; + onChange: (ids: string[] | null) => void; + devices: Pick[] | null; + failed: boolean; +}) { + const { t } = useI18n(); + const available = devices ?? []; + // Unknown/offline IDs remain selectable; loading never rewrites the draft. + const choices = [...available, ...(value ?? []).filter((id) => !available.some((device) => device.id === id)) + .map((id) => ({ id, name: "" }))]; + return ( +
+
+
+
{t("仅推送指定设备")}
+

{t("关闭时推送全部设备的通知。")}

+
+ onChange(enabled ? [] : null)} ariaLabel={t("仅推送指定设备")} /> +
+ {value !== null ? <> + {failed ?

{t("设备列表加载失败,已保留适用设备范围。")}

: devices === null ? ( +

{t("正在加载设备…")}

+ ) : null} +
+ {choices.map((device) => { + const selected = value.includes(device.id); + return ( + + ); + })} +
+ : null} +
+ ); +} + // URL 列表编辑器(Webhook / Bark 目标 URLs)。 export function UrlListEditor({ urls, diff --git a/web/src/components/settings/model.ts b/web/src/components/settings/model.ts index a20431ac..d882cab9 100644 --- a/web/src/components/settings/model.ts +++ b/web/src/components/settings/model.ts @@ -81,7 +81,7 @@ export const DEFAULT_LARK_PAYLOAD_TEMPLATE = `{ } }`; -export interface NotifyForms { +interface ChannelForms { meow: NotificationSettings["meow"]; telegram: TelegramForm; webhook: WebhookForm; @@ -92,6 +92,10 @@ export interface NotifyForms { lark: LarkForm; } +export type NotifyForms = { + [K in keyof ChannelForms]: ChannelForms[K] & { deviceIds: string[] | null }; +}; + // 系统保留头,自定义同名头会被忽略(品牌 vocat) export const RESERVED_HEADERS = new Set(["content-type", "x-vocat-signature"]); @@ -167,7 +171,7 @@ export function formsFromNotifications(data: Partial): Not const pushplus = asRecord(data.pushplus); const wecom = asRecord(data.wecom); const lark = asRecord(data.lark); - return { + const forms = { meow: { enabled: !!meow.enabled, nickname: str(meow.nickname), url: str(meow.url), imgUrl: str(meow.imgUrl) }, telegram: { enabled: !!telegram.enabled, @@ -224,7 +228,12 @@ export function formsFromNotifications(data: Partial): Not secret: lark.signingEnabled ? str(lark.secret) : "", payloadTemplate: str(lark.payloadTemplate ?? lark.payload_template) || DEFAULT_LARK_PAYLOAD_TEMPLATE, }, - }; + } as NotifyForms; + for (const channel of Object.keys(forms) as (keyof NotifyForms)[]) { + const ids = data[channel]?.deviceIds; + forms[channel].deviceIds = Array.isArray(ids) ? ids.map(String) : null; + } + return forms; } function splitList(value: string): string[] { @@ -312,7 +321,7 @@ export function buildMeowPayload(form: NotifyForms["meow"]) { export type ClearableNotificationChannel = "telegram" | "email"; export function buildNotificationsPayload(forms: NotifyForms, clearedChannels: readonly ClearableNotificationChannel[] = []) { - return { + const payload: NotificationSettings = { meow: buildMeowPayload(forms.meow), telegram: { enabled: !!forms.telegram.enabled, @@ -339,4 +348,8 @@ export function buildNotificationsPayload(forms: NotifyForms, clearedChannels: r wecom: buildWecomPayload(forms.wecom), lark: buildLarkPayload(forms.lark), }; + for (const channel of Object.keys(forms) as (keyof NotifyForms)[]) { + payload[channel].deviceIds = forms[channel].deviceIds; + } + return payload; } diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index 8a5156b6..241b1e58 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -6,7 +6,11 @@ */ export const EN_DICT: Record = { "清空配置": "Clear configuration", - "清空后将移除该渠道的账号、地址和凭据,其他选项恢复默认值。启用状态保持不变,点击“保存通知配置”后生效。": "This removes accounts, addresses and credentials and restores other options to defaults. The enabled state stays unchanged. Click Save notification settings to apply.", + "清空后将移除该渠道的账号、地址和凭据,其他选项恢复默认值。启用状态和适用设备保持不变,点击“保存通知配置”后生效。": "This removes accounts, addresses and credentials and restores other options to defaults. The enabled state and device scope stay unchanged. Click Save notification settings to apply.", + "仅推送指定设备": "Only notify for selected devices", + "关闭时推送全部设备的通知。": "When off, notifications are sent for all devices.", + "正在加载设备…": "Loading devices…", + "设备列表加载失败,已保留适用设备范围。": "Could not load devices. The device scope is unchanged.", "启用 MeoW 推送": "Enable MeoW notifications", "MeoW 昵称": "MeoW nickname", "MeoW 测试失败": "MeoW test failed", diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index fb89b091..455f48df 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -1,13 +1,13 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { AlertRegular, CheckmarkRegular } from "@fluentui/react-icons"; import { api, apiMessage, getSecuritySettings, updateSecuritySettings } from "../api"; -import type { DeveloperSettings, HTTPSSettings, NotificationSettings, SecuritySettings, SMSSettings, SystemInfo } from "../types"; +import type { DeveloperSettings, DeviceListItem, DevicesResponse, HTTPSSettings, NotificationSettings, SecuritySettings, SMSSettings, SystemInfo } from "../types"; import { Button, PageHeader, confirmDialog, message } from "../components/ui"; import { CardDecor, CardIcon, CardTitle, SecurityCard, SystemInfoCard } from "../components/settings/Cards"; import type { PasswordForm, UpdateInfo } from "../components/settings/Cards"; import { NetworkAccessCard } from "../components/settings/NetworkAccessCard"; import type { NetworkAccessForm } from "../components/settings/NetworkAccessCard"; -import { SegmentedTabs } from "../components/settings/controls"; +import { NotificationDeviceScope, SegmentedTabs } from "../components/settings/controls"; import { useI18n } from "../lib/i18n"; import { useAuth } from "../store/auth"; import { @@ -55,15 +55,18 @@ export default function SettingsPage() { const [systemInfo, setSystemInfo] = useState(EMPTY_SYSTEM_INFO); const [password, setPassword] = useState(EMPTY_PASSWORD); const [forms, setForms] = useState(defaultNotifyForms); + const [devices, setDevices] = useState(null); + const [devicesFailed, setDevicesFailed] = useState(false); + const devicesRequested = useRef(false); const [clearedChannels, setClearedChannels] = useState([]); const clearChannel = async (channel: ClearableNotificationChannel) => { const name = t(NOTIFY_TABS.find(tab => tab.key === channel)!.label); if (!await confirmDialog( - t("清空后将移除该渠道的账号、地址和凭据,其他选项恢复默认值。启用状态保持不变,点击“保存通知配置”后生效。"), + t("清空后将移除该渠道的账号、地址和凭据,其他选项恢复默认值。启用状态和适用设备保持不变,点击“保存通知配置”后生效。"), t("清空配置") + " · " + name, { type: "warning", confirmVariant: "danger", confirmText: t("清空配置"), cancelText: t("取消") }, )) return; - setForms(prev => ({ ...prev, [channel]: { ...defaultNotifyForms()[channel], enabled: prev[channel].enabled } })); + setForms(prev => ({ ...prev, [channel]: { ...defaultNotifyForms()[channel], enabled: prev[channel].enabled, deviceIds: prev[channel].deviceIds } })); setClearedChannels(prev => prev.includes(channel) ? prev : [...prev, channel]); }; const [testingMeow, setTestingMeow] = useState(false); @@ -101,6 +104,13 @@ export default function SettingsPage() { setForms((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } })); }, []); + useEffect(() => { + if (devicesRequested.current) return; + devicesRequested.current = true; + void api("/devices").then(data => setDevices(data.devices ?? [])) + .catch(() => setDevicesFailed(true)); + }, []); + const fetchSystemInfo = useCallback(async () => { try { const data = await api("/system/info"); @@ -591,6 +601,8 @@ export default function SettingsPage() { {activeTab === "lark" ? ( updateChannel("lark", p)} testing={testingLark} onTest={onTestLark} /> ) : null} + updateChannel(activeTab as keyof NotifyForms, { deviceIds })} /> )} diff --git a/web/src/types.ts b/web/src/types.ts index 18950368..ccffd826 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -468,15 +468,19 @@ export interface EsimOverview { profiles: EsimEuiccProfiles[]; } +export type NotificationConfig> = T & { + deviceIds?: string[] | null; // Missing in older settings; null means all devices. +}; + export interface NotificationSettings { - meow: { enabled: boolean; nickname: string; url: string; imgUrl: string }; - telegram: Record; - webhook: Record; - bark: Record; - email: Record; - pushplus: Record; - wecom: Record; - lark: Record; + meow: NotificationConfig<{ enabled: boolean; nickname: string; url: string; imgUrl: string }>; + telegram: NotificationConfig; + webhook: NotificationConfig; + bark: NotificationConfig; + email: NotificationConfig; + pushplus: NotificationConfig; + wecom: NotificationConfig; + lark: NotificationConfig; } // 网络访问控制策略:默认仅放行内网网段,可切换到对公网开放。 diff --git a/web/test/notificationSettingsModel.test.mjs b/web/test/notificationSettingsModel.test.mjs index 0a18abfa..bd262066 100644 --- a/web/test/notificationSettingsModel.test.mjs +++ b/web/test/notificationSettingsModel.test.mjs @@ -15,6 +15,7 @@ const { DEFAULT_LARK_PAYLOAD_TEMPLATE, buildLarkPayload, buildNotificationsPayload, + defaultNotifyForms, formsFromNotifications, } = await import(moduleURL); @@ -30,6 +31,7 @@ test("loads a masked signed Lark group bot config", () => { }); assert.deepEqual(forms.lark, { + deviceIds: null, enabled: true, url: "********", signingEnabled: true, @@ -84,6 +86,7 @@ test("includes Lark in the complete notification settings payload", () => { const payload = buildNotificationsPayload(forms); assert.deepEqual(payload.lark, { + deviceIds: null, enabled: false, signing_enabled: false, payload_template: DEFAULT_LARK_PAYLOAD_TEMPLATE, @@ -92,12 +95,12 @@ test("includes Lark in the complete notification settings payload", () => { test("round-trips MeoW alongside the seven existing channels", () => { const forms = formsFromNotifications({ meow: { enabled: true, nickname: " 昵称 ", url: " https://example.com ", imgUrl: " https://example.com/icon.png " } }); - assert.deepEqual(buildNotificationsPayload(forms).meow, { enabled: true, nickname: "昵称", url: "https://example.com", imgUrl: "https://example.com/icon.png" }); + assert.deepEqual(buildNotificationsPayload(forms).meow, { enabled: true, nickname: "昵称", url: "https://example.com", imgUrl: "https://example.com/icon.png", deviceIds: null }); assert.deepEqual(Object.keys(buildNotificationsPayload(forms)).sort(), ["bark", "email", "lark", "meow", "pushplus", "telegram", "webhook", "wecom"].sort()); }); test("clear markers apply only to explicitly cleared Telegram and Email drafts", () => { - const forms = formsFromNotifications({ telegram: { enabled: true, botToken: "********" }, email: { enabled: false, password: "********" } }); + const forms = formsFromNotifications({ telegram: { enabled: true, botToken: "********", deviceIds: [] }, email: { enabled: false, password: "********", deviceIds: ["offline"] } }); const normal = buildNotificationsPayload(forms); assert.equal(Object.hasOwn(normal.telegram, "clearSecrets"), false); assert.equal(Object.hasOwn(normal.email, "clearSecrets"), false); @@ -106,10 +109,24 @@ test("clear markers apply only to explicitly cleared Telegram and Email drafts", assert.equal(cleared.telegram.enabled, true); assert.equal(cleared.telegram.botToken, ""); assert.equal(cleared.telegram.clearSecrets, true); + assert.deepEqual(cleared.telegram.deviceIds, []); assert.equal(Object.hasOwn(cleared.email, "clearSecrets"), false); forms.email.password = "new-password"; const rewritten = buildNotificationsPayload(forms, ["email"]); assert.equal(rewritten.email.enabled, false); assert.equal(rewritten.email.password, "new-password"); assert.equal(rewritten.email.clearSecrets, true); + assert.deepEqual(rewritten.email.deviceIds, ["offline"]); +}); + +test("device bindings round-trip missing, null, empty and selected scopes", () => { + const channels = Object.keys(defaultNotifyForms()); + const scopes = [undefined, null, [], ["offline-device"]]; + const data = Object.fromEntries(channels.map((channel, index) => [channel, { deviceIds: scopes[index % scopes.length] }])); + const forms = formsFromNotifications(data); + const payload = buildNotificationsPayload(forms); + for (const channel of channels) { + assert.deepEqual(forms[channel].deviceIds, data[channel].deviceIds ?? null); + assert.deepEqual(payload[channel].deviceIds, data[channel].deviceIds ?? null); + } });