mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
467 lines
13 KiB
Go
467 lines
13 KiB
Go
// Package service — notification channel CRUD + multi-channel dispatch.
|
||
//
|
||
// The original NotifierService reads a single set of keys from the
|
||
// settings table. NotifyChannelService persists *named* channels in
|
||
// their own table so the operator can add multiple Telegram bots, Bark
|
||
// servers, etc. and pick which events flow to which channel.
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"go.uber.org/zap"
|
||
|
||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||
)
|
||
|
||
// NotifyChannelService manages notify channels and dispatches messages.
|
||
type NotifyChannelService struct {
|
||
log *zap.Logger
|
||
repo *repository.Container
|
||
client *http.Client
|
||
}
|
||
|
||
// NewNotifyChannelService is the constructor.
|
||
func NewNotifyChannelService(log *zap.Logger, repo *repository.Container) *NotifyChannelService {
|
||
return &NotifyChannelService{
|
||
log: log,
|
||
repo: repo,
|
||
client: NewExternalHTTPClient(10 * time.Second),
|
||
}
|
||
}
|
||
|
||
// ChannelInput is the shape accepted by Create / Update. Config is a
|
||
// generic map; it gets serialised to JSON before being persisted.
|
||
type ChannelInput struct {
|
||
Name string `json:"name" binding:"required"`
|
||
Type string `json:"type" binding:"required"`
|
||
Config map[string]any `json:"config"`
|
||
Events []string `json:"events"`
|
||
Enabled *bool `json:"enabled,omitempty"`
|
||
}
|
||
|
||
// channelView is the public shape — Config is decoded back to a map so
|
||
// the React form can edit it directly without unwrapping JSON twice.
|
||
type channelView struct {
|
||
model.NotifyChannel
|
||
Config map[string]any `json:"config"`
|
||
Events []string `json:"events"`
|
||
}
|
||
|
||
// toView decodes Config + Events from their persisted JSON strings.
|
||
func toView(n model.NotifyChannel) channelView {
|
||
v := channelView{NotifyChannel: n}
|
||
if n.Config != "" {
|
||
_ = json.Unmarshal([]byte(n.Config), &v.Config)
|
||
}
|
||
if v.Config == nil {
|
||
v.Config = map[string]any{}
|
||
}
|
||
if n.Events != "" {
|
||
_ = json.Unmarshal([]byte(n.Events), &v.Events)
|
||
}
|
||
if v.Events == nil {
|
||
v.Events = []string{}
|
||
}
|
||
return v
|
||
}
|
||
|
||
// List returns every channel as a decoded view.
|
||
func (s *NotifyChannelService) List(ctx context.Context) ([]channelView, error) {
|
||
rows, err := s.repo.NotifyChannel.List(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]channelView, 0, len(rows))
|
||
for _, r := range rows {
|
||
out = append(out, toView(r))
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// Create persists a new channel.
|
||
func (s *NotifyChannelService) Create(ctx context.Context, in ChannelInput) (*channelView, error) {
|
||
normalizeChannelInput(&in)
|
||
if err := validateChannel(in); err != nil {
|
||
return nil, err
|
||
}
|
||
cfgBlob, _ := json.Marshal(in.Config)
|
||
evBlob, _ := json.Marshal(in.Events)
|
||
n := &model.NotifyChannel{
|
||
Name: strings.TrimSpace(in.Name),
|
||
Type: in.Type,
|
||
Config: string(cfgBlob),
|
||
Events: string(evBlob),
|
||
Enabled: true,
|
||
}
|
||
if in.Enabled != nil {
|
||
n.Enabled = *in.Enabled
|
||
}
|
||
if err := s.repo.NotifyChannel.Create(ctx, n); err != nil {
|
||
return nil, err
|
||
}
|
||
if n.Type == "telegram" && n.Enabled {
|
||
if err := registerTelegramBotCommands(ctx, telegramStringConfigFromAny(in.Config)); err != nil && s.log != nil {
|
||
s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err)))
|
||
}
|
||
}
|
||
v := toView(*n)
|
||
return &v, nil
|
||
}
|
||
|
||
// Update applies a partial patch to an existing channel.
|
||
func (s *NotifyChannelService) Update(ctx context.Context, id string, in ChannelInput) (*channelView, error) {
|
||
normalizeChannelInput(&in)
|
||
if err := validateChannel(in); err != nil {
|
||
return nil, err
|
||
}
|
||
cfgBlob, _ := json.Marshal(in.Config)
|
||
evBlob, _ := json.Marshal(in.Events)
|
||
patch := map[string]any{
|
||
"name": strings.TrimSpace(in.Name),
|
||
"type": in.Type,
|
||
"config": string(cfgBlob),
|
||
"events": string(evBlob),
|
||
}
|
||
if in.Enabled != nil {
|
||
patch["enabled"] = *in.Enabled
|
||
}
|
||
// Fetch existing row, apply patch via repo Update
|
||
existing, err := s.repo.NotifyChannel.FindByID(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if existing == nil {
|
||
return nil, errors.New("channel not found")
|
||
}
|
||
existing.Name = patch["name"].(string)
|
||
existing.Type = patch["type"].(string)
|
||
existing.Config = patch["config"].(string)
|
||
existing.Events = patch["events"].(string)
|
||
if en, ok := patch["enabled"]; ok {
|
||
existing.Enabled = en.(bool)
|
||
}
|
||
if err := s.repo.NotifyChannel.Update(ctx, existing); err != nil {
|
||
return nil, err
|
||
}
|
||
if existing.Type == "telegram" && existing.Enabled {
|
||
if err := registerTelegramBotCommands(ctx, telegramStringConfigFromAny(in.Config)); err != nil && s.log != nil {
|
||
s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err)))
|
||
}
|
||
}
|
||
row, err := s.repo.NotifyChannel.FindByID(ctx, id)
|
||
if err != nil || row == nil {
|
||
return nil, err
|
||
}
|
||
v := toView(*row)
|
||
return &v, nil
|
||
}
|
||
|
||
// Delete removes the channel.
|
||
func (s *NotifyChannelService) Delete(ctx context.Context, id string) error {
|
||
return s.repo.NotifyChannel.Delete(ctx, id)
|
||
}
|
||
|
||
// Test sends a "测试通知" through a single channel.
|
||
func (s *NotifyChannelService) Test(ctx context.Context, id string) error {
|
||
row, err := s.repo.NotifyChannel.FindByID(ctx, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if row == nil {
|
||
return errors.New("channel not found")
|
||
}
|
||
return s.dispatchOne(ctx, *row, "MediaStationGo 测试通知", "如果你看到这条消息,说明该通道工作正常。")
|
||
}
|
||
|
||
const (
|
||
NotifyEventAll = "__all__"
|
||
NotifyEventNone = "__none__"
|
||
)
|
||
|
||
// Broadcast sends a message to every enabled channel that subscribes to
|
||
// `event`. Legacy empty Events values mean "all events"; the explicit
|
||
// NotifyEventNone sentinel means the channel stays enabled but receives no
|
||
// event push.
|
||
func (s *NotifyChannelService) Broadcast(ctx context.Context, title, body, event string) {
|
||
s.BroadcastEvent(ctx, NotifyEvent{
|
||
Type: event,
|
||
Title: title,
|
||
Message: body,
|
||
})
|
||
}
|
||
|
||
// BroadcastEvent sends one structured event to every subscribed enabled
|
||
// channel. Rich channels such as Telegram can use Data fields for artwork and
|
||
// cleaner formatting while simpler channels keep receiving title/body text.
|
||
func (s *NotifyChannelService) BroadcastEvent(ctx context.Context, event NotifyEvent) {
|
||
rows, err := s.repo.NotifyChannel.ListEnabled(ctx)
|
||
if err != nil {
|
||
s.log.Warn("notify list failed", zap.Error(err))
|
||
return
|
||
}
|
||
for _, r := range rows {
|
||
if !channelSubscribes(r, event.Type) {
|
||
continue
|
||
}
|
||
if err := s.dispatchOneEvent(ctx, r, event); err != nil {
|
||
s.log.Warn("notify dispatch failed", zap.String("channel", r.Name), zap.Error(err))
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
if strings.TrimSpace(in.Name) == "" {
|
||
return errors.New("name required")
|
||
}
|
||
switch in.Type {
|
||
case "telegram", "wechat", "bark", "webhook", "email":
|
||
default:
|
||
return fmt.Errorf("unsupported channel type %q", in.Type)
|
||
}
|
||
if in.Type == "telegram" {
|
||
cfg := in.Config
|
||
if str(cfg["bot_token"]) == "" {
|
||
return errors.New("telegram bot_token required")
|
||
}
|
||
if str(cfg["admin_user_ids"]) == "" {
|
||
return errors.New("telegram admin_user_ids required")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizeChannelInput(in *ChannelInput) {
|
||
if in == nil || in.Type != "telegram" {
|
||
return
|
||
}
|
||
if in.Config == nil {
|
||
in.Config = map[string]any{}
|
||
}
|
||
chatID := str(in.Config["chat_id"])
|
||
if chatID == "" {
|
||
return
|
||
}
|
||
if strings.HasPrefix(chatID, "-") && str(in.Config["group_chat_id"]) == "" && str(in.Config["channel_chat_id"]) == "" && str(in.Config["command_chat_id"]) == "" {
|
||
in.Config["group_chat_id"] = chatID
|
||
return
|
||
}
|
||
if !strings.HasPrefix(chatID, "-") && str(in.Config["admin_user_ids"]) == "" {
|
||
in.Config["admin_user_ids"] = chatID
|
||
}
|
||
}
|
||
|
||
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 {
|
||
return ""
|
||
}
|
||
if s, ok := v.(string); ok {
|
||
return strings.TrimSpace(s)
|
||
}
|
||
return strings.TrimSpace(fmt.Sprint(v))
|
||
}
|