Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 26 additions & 11 deletions internal/server/automatic_task_notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 10 additions & 7 deletions internal/server/call_notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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{
Expand Down
7 changes: 5 additions & 2 deletions internal/server/lark_notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()))
}
11 changes: 9 additions & 2 deletions internal/server/meow_notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
34 changes: 34 additions & 0 deletions internal/server/notification_device_scope_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
}
60 changes: 60 additions & 0 deletions internal/server/notification_routing_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
17 changes: 15 additions & 2 deletions internal/server/settings_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 21 additions & 3 deletions internal/server/sms_notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"net/http"
"net/mail"
"net/smtp"
"slices"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading