Files
oci-portal/internal/service/notify.go
T

210 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 Bot API 推送通知;未启用或未配置时静默跳过。
// 云外 HTTP 调用,不属于 internal/oci 的职责范围。
type Notifier struct {
settings *SettingService
client *http.Client
base string // 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 {
cfg, err := n.settings.TelegramConfig(ctx)
if err != nil {
return err
}
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
return nil
}
return n.post(ctx, cfg, text)
}
// 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("telegram 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("telegram 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)
}
cfg, err := n.settings.TelegramConfig(ctx)
if err != nil {
return err
}
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
return fmt.Errorf("Telegram 通知未启用或未配置,请先在「通知方式」保存配置")
}
vars := map[string]string{}
for k, v := range def.Sample {
vars[k] = v
}
return n.sendTemplate(ctx, kind, tplOverride, vars)
}
// sendTemplate 渲染并发送:取生效模板 → 变量替换 → 截断 → Markdown 转 Telegram HTML。
func (n *Notifier) sendTemplate(ctx context.Context, kind, tplOverride string, vars map[string]string) error {
cfg, err := n.settings.TelegramConfig(ctx)
if err != nil {
return err
}
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
return nil
}
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.postHTML(ctx, cfg, mdToTelegramHTML(truncateText(text, notifyTextLimit)))
}
// 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]) + "…"
}