diff --git a/internal/service/notify_channels.go b/internal/service/notify_channels.go index 46e93e0..35bad4b 100644 --- a/internal/service/notify_channels.go +++ b/internal/service/notify_channels.go @@ -12,7 +12,6 @@ import ( "errors" "fmt" "net/http" - "net/url" "strings" "time" @@ -218,155 +217,6 @@ func (s *NotifyChannelService) BroadcastEvent(ctx context.Context, event NotifyE } } -// channelSubscribes returns true when the channel's Events list contains the -// event, or when the list is the legacy empty/"all events" value. -func channelSubscribes(n model.NotifyChannel, event string) bool { - if event == "" || n.Events == "" { - return true - } - var ev []string - if err := json.Unmarshal([]byte(n.Events), &ev); err != nil { - return true - } - if len(ev) == 0 { - return true - } - for _, e := range ev { - switch e { - case NotifyEventNone: - return false - case NotifyEventAll: - return true - } - if e == event { - return true - } - } - return false -} - -// dispatchOne is the inner dispatcher; the channel type drives which -// HTTP request gets built. -func (s *NotifyChannelService) dispatchOne(ctx context.Context, n model.NotifyChannel, title, body string) error { - return s.dispatchOneEvent(ctx, n, NotifyEvent{Title: title, Message: body}) -} - -func (s *NotifyChannelService) dispatchOneEvent(ctx context.Context, n model.NotifyChannel, event NotifyEvent) error { - cfg := map[string]any{} - _ = json.Unmarshal([]byte(n.Config), &cfg) - title := event.Title - body := event.Message - - switch n.Type { - case "telegram": - telegramCfg := telegramStringConfigFromAny(cfg) - token := telegramCfg["bot_token"] - chats := telegramTargetChatIDs(telegramCfg) - if token == "" || len(chats) == 0 { - return errors.New("telegram missing bot_token / group_chat_id / channel_chat_id") - } - text := formatTelegramNotification(event) - photoURL := telegramEventPhotoURL(event) - var firstErr error - for _, chat := range chats { - if photoURL != "" && len(text) <= 1024 { - form := url.Values{} - form.Set("chat_id", chat) - form.Set("photo", photoURL) - form.Set("caption", text) - form.Set("parse_mode", "HTML") - if err := telegramPostForm(ctx, telegramCfg, "sendPhoto", form, 15*time.Second); err == nil { - continue - } else if firstErr == nil { - firstErr = err - } - } - form := url.Values{} - form.Set("chat_id", chat) - form.Set("text", text) - form.Set("parse_mode", "HTML") - if err := telegramPostForm(ctx, telegramCfg, "sendMessage", form, 15*time.Second); err != nil && firstErr == nil { - firstErr = err - } - } - return firstErr - - case "bark": - key := str(cfg["device_key"]) - if key == "" { - return errors.New("bark missing device_key") - } - server := str(cfg["server"]) - if server == "" { - server = "https://api.day.app" - } - u := fmt.Sprintf("%s/%s/%s/%s", - strings.TrimRight(server, "/"), - url.PathEscape(key), - url.PathEscape(title), - url.PathEscape(body), - ) - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - return s.do(req) - - case "wechat": - key := str(cfg["sendkey"]) - if key == "" { - return errors.New("wechat missing sendkey") - } - u := fmt.Sprintf("https://sctapi.ftqq.com/%s.send", url.PathEscape(key)) - form := url.Values{} - form.Set("title", title) - form.Set("desp", body) - req, _ := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return s.do(req) - - case "webhook": - urlS := str(cfg["url"]) - if urlS == "" { - return errors.New("webhook missing url") - } - method := strings.ToUpper(str(cfg["method"])) - if method == "" { - method = "POST" - } - // Substitute {{title}} / {{message}} in the body template. - bodyTpl := str(cfg["body_template"]) - if bodyTpl == "" { - bodyTpl = `{"title":"{{title}}","message":"{{message}}"}` - } - bodyStr := strings.NewReplacer("{{title}}", title, "{{message}}", body).Replace(bodyTpl) - req, _ := http.NewRequestWithContext(ctx, method, urlS, strings.NewReader(bodyStr)) - // Apply custom headers (encoded as JSON in the config). - if hdrRaw := str(cfg["headers"]); hdrRaw != "" { - var hdr map[string]string - if err := json.Unmarshal([]byte(hdrRaw), &hdr); err == nil { - for k, v := range hdr { - req.Header.Set(k, v) - } - } - } - if req.Header.Get("Content-Type") == "" && method != http.MethodGet { - req.Header.Set("Content-Type", "application/json") - } - return s.do(req) - } - return fmt.Errorf("unknown channel type %q", n.Type) -} - -func (s *NotifyChannelService) do(req *http.Request) error { - resp, err := s.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return fmt.Errorf("upstream returned %d", resp.StatusCode) - } - return nil -} - // validateChannel rejects obviously-malformed inputs early so the API // returns a useful 400 rather than a database constraint error. func validateChannel(in ChannelInput) error { @@ -410,50 +260,6 @@ func normalizeChannelInput(in *ChannelInput) { } } -func telegramTargetChatIDs(cfg map[string]string) []string { - seen := map[string]bool{} - targets := []string{} - for _, key := range []string{"group_chat_id", "channel_chat_id"} { - chatID := strings.TrimSpace(cfg[key]) - if chatID == "" || seen[chatID] { - continue - } - seen[chatID] = true - targets = append(targets, chatID) - } - if len(targets) == 0 { - chatID := strings.TrimSpace(cfg["chat_id"]) - if strings.HasPrefix(chatID, "-") { - targets = append(targets, chatID) - } else if chatID != "" && strings.TrimSpace(cfg["admin_user_ids"]) == "" { - targets = append(targets, chatID) - } - } - if len(targets) == 0 { - for _, userID := range telegramConfiguredUserIDs(cfg["admin_user_ids"]) { - if seen[userID] { - continue - } - seen[userID] = true - targets = append(targets, userID) - } - } - return targets -} - -func telegramConfiguredUserIDs(raw string) []string { - out := []string{} - for _, value := range strings.FieldsFunc(raw, func(r rune) bool { - return r == ',' || r == ';' || r == ',' || r == ' ' || r == '\n' || r == '\t' - }) { - value = strings.TrimSpace(value) - if value != "" { - out = append(out, value) - } - } - return out -} - // str safely extracts a string from an interface{} loaded from JSON. func str(v any) string { if v == nil { diff --git a/internal/service/notify_dispatch.go b/internal/service/notify_dispatch.go new file mode 100644 index 0000000..38e8be8 --- /dev/null +++ b/internal/service/notify_dispatch.go @@ -0,0 +1,163 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +// channelSubscribes returns true when the channel's Events list contains the +// event, or when the list is the legacy empty/"all events" value. +func channelSubscribes(n model.NotifyChannel, event string) bool { + if event == "" || n.Events == "" { + return true + } + var ev []string + if err := json.Unmarshal([]byte(n.Events), &ev); err != nil { + return true + } + if len(ev) == 0 { + return true + } + for _, e := range ev { + switch e { + case NotifyEventNone: + return false + case NotifyEventAll: + return true + } + if e == event { + return true + } + } + return false +} + +// dispatchOne is the inner dispatcher; the channel type drives which +// HTTP request gets built. +func (s *NotifyChannelService) dispatchOne(ctx context.Context, n model.NotifyChannel, title, body string) error { + return s.dispatchOneEvent(ctx, n, NotifyEvent{Title: title, Message: body}) +} + +func (s *NotifyChannelService) dispatchOneEvent(ctx context.Context, n model.NotifyChannel, event NotifyEvent) error { + cfg := map[string]any{} + _ = json.Unmarshal([]byte(n.Config), &cfg) + title := event.Title + body := event.Message + + switch n.Type { + case "telegram": + telegramCfg := telegramStringConfigFromAny(cfg) + token := telegramCfg["bot_token"] + chats := telegramTargetChatIDs(telegramCfg) + if token == "" || len(chats) == 0 { + return errors.New("telegram missing bot_token / group_chat_id / channel_chat_id") + } + text := formatTelegramNotification(event) + photoURL := telegramEventPhotoURL(event) + var firstErr error + for _, chat := range chats { + if photoURL != "" && len(text) <= 1024 { + form := url.Values{} + form.Set("chat_id", chat) + form.Set("photo", photoURL) + form.Set("caption", text) + form.Set("parse_mode", "HTML") + if err := telegramPostForm(ctx, telegramCfg, "sendPhoto", form, 15*time.Second); err == nil { + continue + } else if firstErr == nil { + firstErr = err + } + } + form := url.Values{} + form.Set("chat_id", chat) + form.Set("text", text) + form.Set("parse_mode", "HTML") + if err := telegramPostForm(ctx, telegramCfg, "sendMessage", form, 15*time.Second); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr + + case "bark": + key := str(cfg["device_key"]) + if key == "" { + return errors.New("bark missing device_key") + } + server := str(cfg["server"]) + if server == "" { + server = "https://api.day.app" + } + u := fmt.Sprintf("%s/%s/%s/%s", + strings.TrimRight(server, "/"), + url.PathEscape(key), + url.PathEscape(title), + url.PathEscape(body), + ) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + return s.do(req) + + case "wechat": + key := str(cfg["sendkey"]) + if key == "" { + return errors.New("wechat missing sendkey") + } + u := fmt.Sprintf("https://sctapi.ftqq.com/%s.send", url.PathEscape(key)) + form := url.Values{} + form.Set("title", title) + form.Set("desp", body) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return s.do(req) + + case "webhook": + urlS := str(cfg["url"]) + if urlS == "" { + return errors.New("webhook missing url") + } + method := strings.ToUpper(str(cfg["method"])) + if method == "" { + method = "POST" + } + // Substitute {{title}} / {{message}} in the body template. + bodyTpl := str(cfg["body_template"]) + if bodyTpl == "" { + bodyTpl = `{"title":"{{title}}","message":"{{message}}"}` + } + bodyStr := strings.NewReplacer("{{title}}", title, "{{message}}", body).Replace(bodyTpl) + req, _ := http.NewRequestWithContext(ctx, method, urlS, strings.NewReader(bodyStr)) + // Apply custom headers (encoded as JSON in the config). + if hdrRaw := str(cfg["headers"]); hdrRaw != "" { + var hdr map[string]string + if err := json.Unmarshal([]byte(hdrRaw), &hdr); err == nil { + for k, v := range hdr { + req.Header.Set(k, v) + } + } + } + if req.Header.Get("Content-Type") == "" && method != http.MethodGet { + req.Header.Set("Content-Type", "application/json") + } + return s.do(req) + } + return fmt.Errorf("unknown channel type %q", n.Type) +} + +func (s *NotifyChannelService) do(req *http.Request) error { + resp, err := s.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("upstream returned %d", resp.StatusCode) + } + return nil +} diff --git a/internal/service/notify_telegram_config.go b/internal/service/notify_telegram_config.go new file mode 100644 index 0000000..65a4dcf --- /dev/null +++ b/internal/service/notify_telegram_config.go @@ -0,0 +1,47 @@ +package service + +import "strings" + +func telegramTargetChatIDs(cfg map[string]string) []string { + seen := map[string]bool{} + targets := []string{} + for _, key := range []string{"group_chat_id", "channel_chat_id"} { + chatID := strings.TrimSpace(cfg[key]) + if chatID == "" || seen[chatID] { + continue + } + seen[chatID] = true + targets = append(targets, chatID) + } + if len(targets) == 0 { + chatID := strings.TrimSpace(cfg["chat_id"]) + if strings.HasPrefix(chatID, "-") { + targets = append(targets, chatID) + } else if chatID != "" && strings.TrimSpace(cfg["admin_user_ids"]) == "" { + targets = append(targets, chatID) + } + } + if len(targets) == 0 { + for _, userID := range telegramConfiguredUserIDs(cfg["admin_user_ids"]) { + if seen[userID] { + continue + } + seen[userID] = true + targets = append(targets, userID) + } + } + return targets +} + +func telegramConfiguredUserIDs(raw string) []string { + out := []string{} + for _, value := range strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ';' || r == ',' || r == ' ' || r == '\n' || r == '\t' + }) { + value = strings.TrimSpace(value) + if value != "" { + out = append(out, value) + } + } + return out +}