// Copyright 2026 tamakyi. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. // Package notify 封装站级通知通道:通用 Webhook 与 Telegram Bot。 // // 与 internal/mail(点对点邮件)的分工:mail 负责「给某个具体邮箱发」,notify 负责 // 「广播式」通知 —— Webhook 推给一个 URL(Discord/Slack/企业微信机器人等), // Telegram 推给某个 chat_id(配合箱主自填的 chat_id 实现点对点)。 // // 设计约束:任一通道发送失败都只记日志、不向上抛错,也绝不影响业务主流程 —— // 通知是「尽力而为」的旁路,一条通知发不出去不该让提问/回答写不进去。 // 通道未配置(URL/token 为空)时对应发送静默跳过。 package notify import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time" "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/tamakyi/TamaBox/internal/conf" ) const httpTimeout = 5 * time.Second // event 一条站级通知事件的载荷。Webhook 与 Telegram 都用它(字段名通用)。 type event struct { Event string `json:"event"` // 事件名,如 new_question / new_answer / ... Title string `json:"title"` Body string `json:"body"` Link string `json:"link,omitempty"` Time string `json:"time"` } // Send 推送一条站级事件到所有已配置的广播通道(Webhook + Telegram)。 // 各通道独立发送、互不影响;失败仅记日志。 func Send(ctx context.Context, eventName, title, body, link string) { ev := event{ Event: eventName, Title: title, Body: body, Link: link, Time: time.Now().Format("2006-01-02 15:04:05"), } if conf.Notify.WebhookURL != "" { sendWebhook(ctx, conf.Notify.WebhookURL, ev) } // Telegram 站级广播需要一个全局 chat_id;当前设计里 Telegram 是「用户级点对点」 // (见 SendToTelegram),站级不额外广播,避免重复打扰。这里留空即不推。 } // SendToTelegram 推送一条通知到指定 chat_id(箱主自填,配合站级 bot token)。 // chatID 为空或 bot token 未配置时静默跳过。 func SendToTelegram(ctx context.Context, chatID, text string) { if chatID == "" || conf.Notify.TelegramBotToken == "" { return } if err := sendTelegram(ctx, conf.Notify.TelegramBotToken, chatID, text); err != nil { logrus.WithContext(ctx).WithError(err).Warn("Telegram 通知发送失败") } } // sendWebhook POST 一条 JSON 到通用 Webhook URL。 // // 适配 Discord / Slack 的格式差异:这里发送的是统一的 JSON 对象。Discord 的 webhook // 期望 {"content": "..."},Slack 期望 {"text": "..."},企业微信期望 {"msgtype":"text", // "text":{"content":"..."}} —— 无法用一种格式同时适配所有平台。因此这里发送「通用 JSON」, // 运营方在 webhook 前端(如自己的一层转发)做格式适配;对只接受特定格式的机器人, // 请用能转发的中间服务(如 Discord 支持自定义 message,Slack incoming webhook 也接受 text 字段)。 // // 实际上为最大化开箱可用,这里优先兼容 Discord 的 content 字段 + 通用 event 字段并存: // 既保留结构化信息,又让 Discord 机器人能直接读出人话。 func sendWebhook(ctx context.Context, url string, ev event) { payload := map[string]interface{}{ "content": fmt.Sprintf("【%s】%s\n%s", ev.Title, ev.Body, ev.Link), "event": ev.Event, "title": ev.Title, "body": ev.Body, "link": ev.Link, "time": ev.Time, } data, err := json.Marshal(payload) if err != nil { logrus.WithContext(ctx).WithError(err).Warn("序列化 webhook 通知失败") return } req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) if err != nil { logrus.WithContext(ctx).WithError(err).Warn("构造 webhook 请求失败") return } req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: httpTimeout} resp, err := client.Do(req) if err != nil { logrus.WithContext(ctx).WithError(err).Warn("webhook 通知发送失败") return } defer resp.Body.Close() if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) logrus.WithContext(ctx).WithField("status", resp.StatusCode). WithField("resp", string(body)).Warn("webhook 通知返回非 2xx") } } // sendTelegram 通过 Telegram Bot API 发送一条消息到指定 chat_id。 func sendTelegram(ctx context.Context, botToken, chatID, text string) error { url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken) payload, err := json.Marshal(map[string]string{ "chat_id": chatID, "text": text, "parse_mode": "HTML", }) if err != nil { return errors.Wrap(err, "marshal telegram payload") } req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { return errors.Wrap(err, "build telegram request") } req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: httpTimeout} resp, err := client.Do(req) if err != nil { return errors.Wrap(err, "telegram request") } defer resp.Body.Close() if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return errors.Errorf("telegram api status %d: %s", resp.StatusCode, string(body)) } return nil }