package service import ( "bytes" "context" "encoding/json" "errors" "fmt" "html" "log" "net/http" "net/url" "strings" "sync" "time" ) // notifySendTimeout 是单条通知发送的超时时间。 const notifySendTimeout = 10 * time.Second // notifyTextLimit 是通知正文长度上限(Telegram 上限 4096 字符,预留转义余量)。 const notifyTextLimit = 3800 // telegramAPIBase 是 Telegram Bot API 官方根地址。 const telegramAPIBase = "https://api.telegram.org" // Notifier 向 Telegram 与全部启用的通知渠道(webhook/ntfy/bark/smtp)推送; // 未启用或未配置的渠道静默跳过。云外 HTTP 调用,不属于 internal/oci 的职责范围。 type Notifier struct { settings *SettingService client *http.Client base string // Telegram API 根地址,测试时注入 httptest 服务 wg sync.WaitGroup } // NewNotifier 组装依赖,指向官方 API 地址。 func NewNotifier(settings *SettingService) *Notifier { return &Notifier{ settings: settings, client: &http.Client{Timeout: notifySendTimeout}, base: telegramAPIBase, } } // Send 向全部启用渠道发送一条纯文本消息;无渠道可用时返回 nil。 func (n *Notifier) Send(ctx context.Context, text string) error { return n.dispatch(ctx, notifyMsg{Title: "oci-portal 通知", Text: text}) } // dispatch 把消息投递到 Telegram 与全部启用渠道;渠道间互不影响,错误合并返回供日志。 func (n *Notifier) dispatch(ctx context.Context, msg notifyMsg) error { msg.Text = truncateText(msg.Text, notifyTextLimit) var errs []error if err := n.sendTelegram(ctx, msg); err != nil { errs = append(errs, fmt.Errorf("telegram: %w", err)) } for _, chType := range NotifyChannelTypes { if err := n.sendChannel(ctx, chType, msg); err != nil { errs = append(errs, fmt.Errorf("%s: %w", chType, err)) } } return errors.Join(errs...) } // sendTelegram 走既有 Telegram 路径;未启用或未配置时静默跳过。 func (n *Notifier) sendTelegram(ctx context.Context, msg notifyMsg) error { cfg, err := n.settings.TelegramConfig(ctx) if err != nil { return err } if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" { return nil } return n.postHTML(ctx, cfg, mdToTelegramHTML(msg.Text)) } // sendChannel 向单个新增渠道投递;未启用或关键字段缺失时静默跳过。 func (n *Notifier) sendChannel(ctx context.Context, chType string, msg notifyMsg) error { switch chType { case ChannelWebhook: cfg, err := n.settings.webhookChannel(ctx) if err != nil || !cfg.Enabled || cfg.URL == "" { return err } return sendWebhook(ctx, n.client, cfg, msg) case ChannelNtfy: cfg, err := n.settings.ntfyChannel(ctx) if err != nil || !cfg.Enabled || cfg.Topic == "" { return err } return sendNtfy(ctx, n.client, cfg, msg) case ChannelBark: cfg, err := n.settings.barkChannel(ctx) if err != nil || !cfg.Enabled || cfg.DeviceKey == "" { return err } return sendBark(ctx, n.client, cfg, msg) case ChannelSMTP: cfg, err := n.settings.smtpChannel(ctx) if err != nil || !cfg.Enabled || cfg.Host == "" { return err } return sendSMTP(ctx, cfg, msg) } return nil } // SendAsync 异步发送并自带超时,失败只记日志,绝不影响调用链路。 func (n *Notifier) SendAsync(text string) { n.wg.Add(1) go func() { defer n.wg.Done() ctx, cancel := context.WithTimeout(context.Background(), notifySendTimeout) defer cancel() if err := n.Send(ctx, text); err != nil { log.Printf("notify: %v", err) } }() } // SendTemplateAsync 按 kind 的生效模板(自定义或默认)渲染变量后异步发送; // 模板支持轻量 Markdown,Telegram 按 HTML 语义投递,其余渠道按纯文本投递。 func (n *Notifier) SendTemplateAsync(kind string, vars map[string]string) { n.wg.Add(1) go func() { defer n.wg.Done() ctx, cancel := context.WithTimeout(context.Background(), notifySendTimeout) defer cancel() if err := n.sendTemplate(ctx, kind, "", vars); err != nil { log.Printf("notify %s: %v", kind, err) } }() } // SendTemplateTest 用 kind 的示例变量同步发送一条测试消息; // tplOverride 非空时按给定模板渲染(供编辑表单预览未保存内容)。 func (n *Notifier) SendTemplateTest(ctx context.Context, kind, tplOverride string) error { def, ok := notifyTplDefs[kind] if !ok { return fmt.Errorf("未知通知类型 %q", kind) } enabled, err := n.anyChannelEnabled(ctx) if err != nil { return err } if !enabled { return fmt.Errorf("没有已启用的通知渠道,请先在「通知方式」配置并启用") } vars := map[string]string{} for k, v := range def.Sample { vars[k] = v } return n.sendTemplate(ctx, kind, tplOverride, vars) } // anyChannelEnabled 报告是否存在至少一个已启用且配置齐全的渠道(含 Telegram)。 func (n *Notifier) anyChannelEnabled(ctx context.Context) (bool, error) { tg, err := n.settings.TelegramConfig(ctx) if err != nil { return false, err } if tg.Enabled && tg.BotToken != "" && tg.ChatID != "" { return true, nil } views, err := n.settings.NotifyChannels(ctx) if err != nil { return false, err } for _, v := range views { if v.Enabled { return true, nil } } return false, nil } // sendTemplate 渲染并分发:取生效模板 → 变量替换 → 投递到全部启用渠道。 func (n *Notifier) sendTemplate(ctx context.Context, kind, tplOverride string, vars map[string]string) error { tpl := tplOverride if strings.TrimSpace(tpl) == "" { custom, err := n.settings.NotifyTemplate(ctx, kind) if err != nil { log.Printf("notify template %s: %v", kind, err) } tpl = notifyTplText(custom, kind) } text := renderNotifyTemplate(tpl, vars) return n.dispatch(ctx, notifyMsg{Title: notifyTplLabel(kind), Text: text}) } // SendChannelTest 向指定渠道同步发送测试消息;不看启用开关,便于保存后即时验证。 func (n *Notifier) SendChannelTest(ctx context.Context, chType string) error { msg := notifyMsg{Title: "oci-portal 测试消息", Text: "✅ 通知渠道配置成功:" + chType} switch chType { case ChannelWebhook: cfg, err := n.settings.webhookChannel(ctx) if err != nil { return err } if cfg.URL == "" { return fmt.Errorf("请先保存 Webhook URL") } return sendWebhook(ctx, n.client, cfg, msg) case ChannelNtfy: cfg, err := n.settings.ntfyChannel(ctx) if err != nil { return err } if cfg.Topic == "" { return fmt.Errorf("请先保存 ntfy topic") } return sendNtfy(ctx, n.client, cfg, msg) } return n.sendChannelTestRest(ctx, chType, msg) } // sendChannelTestRest 承接 SendChannelTest 的 bark/smtp 分支(拆分控制函数长度)。 func (n *Notifier) sendChannelTestRest(ctx context.Context, chType string, msg notifyMsg) error { switch chType { case ChannelBark: cfg, err := n.settings.barkChannel(ctx) if err != nil { return err } if cfg.DeviceKey == "" { return fmt.Errorf("请先保存 Bark device key") } return sendBark(ctx, n.client, cfg, msg) case ChannelSMTP: cfg, err := n.settings.smtpChannel(ctx) if err != nil { return err } if cfg.Host == "" || cfg.From == "" || cfg.To == "" { return fmt.Errorf("请先保存 SMTP 主机、发件人与收件人") } return sendSMTP(ctx, cfg, msg) } return ErrUnknownNotifyChannel } // Wait 等待全部在途异步通知发完,供退出前收尾与测试同步。 func (n *Notifier) Wait() { n.wg.Wait() } // Test 同步发送一条测试消息,配置无效时透出可读错误。 func (n *Notifier) Test(ctx context.Context) error { cfg, err := n.settings.TelegramConfig(ctx) if err != nil { return err } if cfg.BotToken == "" || cfg.ChatID == "" { return fmt.Errorf("telegram test: bot token and chat id are required") } return n.post(ctx, cfg, "✅ oci-portal 测试消息:Telegram 通知配置成功") } // post 调用 sendMessage;先截断再做 HTML 转义,避免把转义实体截成两半。 func (n *Notifier) post(ctx context.Context, cfg TelegramConfig, text string) error { return n.postHTML(ctx, cfg, html.EscapeString(truncateText(text, notifyTextLimit))) } // postHTML 调用 sendMessage 发送已按 Telegram HTML 组装的文本(调用方保证转义)。 func (n *Notifier) postHTML(ctx context.Context, cfg TelegramConfig, htmlText string) error { body, err := json.Marshal(map[string]any{ "chat_id": cfg.ChatID, "text": htmlText, "parse_mode": "HTML", "disable_web_page_preview": true, }) if err != nil { return fmt.Errorf("telegram send: %w", err) } apiURL := fmt.Sprintf("%s/bot%s/sendMessage", n.base, cfg.BotToken) req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body)) if err != nil { return fmt.Errorf("telegram send: %w", sanitizeURLError(err)) } req.Header.Set("Content-Type", "application/json") resp, err := n.client.Do(req) if err != nil { return fmt.Errorf("telegram send: %w", sanitizeURLError(err)) } defer resp.Body.Close() return parseTelegramResponse(resp) } // telegramResponse 是 Telegram Bot API 的通用响应包。 type telegramResponse struct { OK bool `json:"ok"` Description string `json:"description"` } // parseTelegramResponse 解析响应;ok=false 时透出 Telegram 的错误描述。 func parseTelegramResponse(resp *http.Response) error { var r telegramResponse if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { return fmt.Errorf("telegram response (%d): %w", resp.StatusCode, err) } if !r.OK { return fmt.Errorf("telegram: %s", r.Description) } return nil } // sanitizeURLError 剥掉 *url.Error 外壳只保留底层原因: // 其 Error() 会拼出完整请求 URL,而路径中含 bot token, // 原样向上返回会把 token 泄漏进日志与接口响应。 func sanitizeURLError(err error) error { var uerr *url.Error if errors.As(err, &uerr) { return uerr.Err } return err } // truncateText 按 rune 截断超长文本,追加省略号提示。 func truncateText(text string, limit int) string { runes := []rune(text) if len(runes) <= limit { return text } return string(runes[:limit]) + "…" }