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 结构与未配置时的静默跳过。
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
)
|
||||
|
||||
// TestSendWebhookPostsJSON 验证 webhook 发送的是带 content/event/title 的 JSON,
|
||||
// 且能正确 POST 到目标 URL。
|
||||
func TestSendWebhookPostsJSON(t *testing.T) {
|
||||
var got map[string]interface{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodPost, r.Method)
|
||||
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&got))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sendWebhook(t.Context(), srv.URL, event{
|
||||
Event: "new_question",
|
||||
Title: "新的提问",
|
||||
Body: "你好",
|
||||
Link: "https://example.com/_/box/1",
|
||||
Time: "2026-10-29 19:00:00",
|
||||
})
|
||||
|
||||
require.Equal(t, "new_question", got["event"])
|
||||
require.Equal(t, "新的提问", got["title"])
|
||||
require.Equal(t, "你好", got["body"])
|
||||
require.NotEmpty(t, got["content"], "Discord 兼容的 content 字段必须有")
|
||||
}
|
||||
|
||||
// TestSendWebhookSkipsWhenUnconfigured 未配置 webhook_url 时 Send 静默跳过(不 panic)。
|
||||
func TestSendWebhookSkipsWhenUnconfigured(t *testing.T) {
|
||||
conf.Notify.WebhookURL = ""
|
||||
defer func() { conf.Notify.WebhookURL = "" }()
|
||||
// 不应 panic,也不应发起任何请求。
|
||||
Send(t.Context(), "new_question", "t", "b", "l")
|
||||
}
|
||||
|
||||
// TestSendToTelegramSkipsWhenUnconfigured chatID 或 bot token 为空时静默跳过。
|
||||
func TestSendToTelegramSkipsWhenUnconfigured(t *testing.T) {
|
||||
conf.Notify.TelegramBotToken = ""
|
||||
defer func() { conf.Notify.TelegramBotToken = "" }()
|
||||
SendToTelegram(t.Context(), "", "hello") // 空 chatID
|
||||
SendToTelegram(t.Context(), "123", "hello") // 空 token
|
||||
}
|
||||
Reference in New Issue
Block a user