Files
tamabox/internal/notify/notify.go
T
tamakyi b9060c982b feat(notify): 新增站级 Webhook 与用户级 Telegram 通知通道
在现有邮件通知之外补两条广播/点对点通道:

- 站级 Webhook([notify] webhook_url):新提问 / 新回答 / 新追问 / 新私聊 /
  新投票回复统一 POST JSON 到通用 Webhook(Discord / Slack / 企业微信机器人等)。
  消息体同时带 content(Discord 直接可用)与结构化 event/title/body/link 字段。
- 用户级 Telegram([notify] telegram_bot_token + users.telegram_chat_id):箱主
  自填 Chat ID,新提问 / 新投票回复点对点推送。

设计约束:通知是「尽力而为」旁路 —— 任一通道发送失败只记日志、不向上抛错,
绝不影响提问/回答的主流程;通道未配置时静默跳过。发送放在 shared.SafeGo 异步
执行,与现有邮件通知同一条路径。

6 处业务触发点接入 notify;私聊消息保留仅邮件(已有 5 分钟节流,避免广播刷屏)。
profile 页新增 Telegram Chat ID 输入框 + 四语言字典。app.sample.ini 加 [notify] 段。

测试覆盖 webhook 的 JSON 结构与未配置时的静默跳过。
2026-10-29 19:27:23 +08:00

148 lines
5.3 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.
// 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
}