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:
@@ -274,6 +274,19 @@ password = ""
|
|||||||
port = 465
|
port = 465
|
||||||
smtp = ""
|
smtp = ""
|
||||||
|
|
||||||
|
[notify]
|
||||||
|
; ── 站级广播式通知通道(可选,均留空则只走 [mail] 点对点邮件)──────────────
|
||||||
|
; webhook_url:通用 Webhook(Discord / Slack / 企业微信群机器人等),站级事件
|
||||||
|
; (新提问 / 新回答 / 新追问 / 新私聊 / 新投票回复)统一 POST JSON 推送。
|
||||||
|
; 留空 = 不推送站级 Webhook。
|
||||||
|
; 注意:不同平台的 webhook 格式不同,这里发送统一 JSON(含 content/event/title/
|
||||||
|
; body/link/time 字段),Discord 可直接用 content 字段展示;其他平台建议在
|
||||||
|
; webhook 前端加一层转发做格式适配。
|
||||||
|
; webhook_url = ""
|
||||||
|
; telegram_bot_token:Telegram Bot token,配合每个箱主自填的 Chat ID 实现
|
||||||
|
; 「新提问」点对点通知。留空 = 禁用 Telegram 通知。
|
||||||
|
; telegram_bot_token = ""
|
||||||
|
|
||||||
[limits]
|
[limits]
|
||||||
; ── 全站内容 / 数量上限(运行时配置,修改后重启生效)──────────────────────
|
; ── 全站内容 / 数量上限(运行时配置,修改后重启生效)──────────────────────
|
||||||
; 缺失本段或个别键时按内置默认值运行。长度统一按字符(rune)计数。
|
; 缺失本段或个别键时按内置默认值运行。长度统一按字符(rune)计数。
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ func Init() error {
|
|||||||
return errors.Wrap(err, "map 'mail'")
|
return errors.Wrap(err, "map 'mail'")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := File.Section("notify").MapTo(&Notify); err != nil {
|
||||||
|
return errors.Wrap(err, "map 'notify'")
|
||||||
|
}
|
||||||
|
|
||||||
serviceSections := File.Section("service").ChildSections()
|
serviceSections := File.Section("service").ChildSections()
|
||||||
for _, serviceSection := range serviceSections {
|
for _, serviceSection := range serviceSections {
|
||||||
serviceSection := serviceSection
|
serviceSection := serviceSection
|
||||||
|
|||||||
@@ -215,6 +215,18 @@ var (
|
|||||||
SkipTLSVerify bool `ini:"skip_tls_verify"` // 仅自签名 / 内网 SMTP 需要
|
SkipTLSVerify bool `ini:"skip_tls_verify"` // 仅自签名 / 内网 SMTP 需要
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify 站级通知通道配置([notify] 段)。与 [mail] 的「邮件给具体收件人」不同,
|
||||||
|
// 这两个是「广播式」通道:
|
||||||
|
// - WebhookURL:通用 Webhook(Discord / Slack / 企业微信群机器人等),站级事件
|
||||||
|
// (新提问 / 新回答 / 新追问 / 新私聊 / 新投票 / 安全告警 / 举报)统一推送。
|
||||||
|
// - TelegramBotToken:Telegram Bot token,配合每个箱主自填的 chat_id 实现
|
||||||
|
// 「新提问」点对点通知。
|
||||||
|
// 两者都可留空:留空的通道对应发送静默跳过(不影响邮件等其他通道)。
|
||||||
|
Notify struct {
|
||||||
|
WebhookURL string `ini:"webhook_url"`
|
||||||
|
TelegramBotToken string `ini:"telegram_bot_token"`
|
||||||
|
}
|
||||||
|
|
||||||
Service struct {
|
Service struct {
|
||||||
Backends []struct {
|
Backends []struct {
|
||||||
Prefix string `ini:"prefix"`
|
Prefix string `ini:"prefix"`
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ type User struct {
|
|||||||
// 取值合法性由 route/user 在写入前校验,读取侧由 internal/theme 回退。
|
// 取值合法性由 route/user 在写入前校验,读取侧由 internal/theme 回退。
|
||||||
Theme string `json:"theme" gorm:"column:theme;type:varchar(32);default:''"`
|
Theme string `json:"theme" gorm:"column:theme;type:varchar(32);default:''"`
|
||||||
Notify NotifyType `json:"notify"`
|
Notify NotifyType `json:"notify"`
|
||||||
|
// TelegramChatID 箱主自填的 Telegram chat_id,配合站级 [notify] telegram_bot_token
|
||||||
|
// 实现「新提问」点对点通知。空串 = 未开启 Telegram 通知。
|
||||||
|
TelegramChatID string `json:"-" gorm:"column:telegram_chat_id;type:varchar(64);default:''"`
|
||||||
HarassmentSetting HarassmentSettingType `json:"harassment_setting" gorm:"default:'none'"`
|
HarassmentSetting HarassmentSettingType `json:"harassment_setting" gorm:"default:'none'"`
|
||||||
BlockWords string `json:"-"`
|
BlockWords string `json:"-"`
|
||||||
// 防骚扰设置取值 none / register_only(见下方常量)。历史账号此列为空串(建表时无默认值),
|
// 防骚扰设置取值 none / register_only(见下方常量)。历史账号此列为空串(建表时无默认值),
|
||||||
@@ -343,6 +346,7 @@ type UpdateUserOptions struct {
|
|||||||
Bloglink string
|
Bloglink string
|
||||||
UseCustomQRColor bool
|
UseCustomQRColor bool
|
||||||
Notify NotifyType
|
Notify NotifyType
|
||||||
|
TelegramChatID string
|
||||||
Language string
|
Language string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,6 +382,7 @@ func (db *users) Update(ctx context.Context, id uint, opts UpdateUserOptions) er
|
|||||||
"Bloglink": opts.Bloglink,
|
"Bloglink": opts.Bloglink,
|
||||||
"UseCustomQRColor": opts.UseCustomQRColor,
|
"UseCustomQRColor": opts.UseCustomQRColor,
|
||||||
"Notify": opts.Notify,
|
"Notify": opts.Notify,
|
||||||
|
"TelegramChatID": opts.TelegramChatID,
|
||||||
"Language": opts.Language,
|
"Language": opts.Language,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type UpdateProfile struct {
|
|||||||
Backgroundimagealpha string `valid:"required" label:"二维码背景透明度" msg:"form.profile.backgroundimagealpha"`
|
Backgroundimagealpha string `valid:"required" label:"二维码背景透明度" msg:"form.profile.backgroundimagealpha"`
|
||||||
Qrcodepdpcolor string `valid:"required;minlen:7;maxlen:7" label:"二维码探测图形颜色" msg:"form.profile.colorHex"`
|
Qrcodepdpcolor string `valid:"required;minlen:7;maxlen:7" label:"二维码探测图形颜色" msg:"form.profile.colorHex"`
|
||||||
NotifyEmail string `label:"开启邮箱通知"`
|
NotifyEmail string `label:"开启邮箱通知"`
|
||||||
|
TelegramChatID string `form:"telegram_chat_id" valid:"maxlen:64" label:"Telegram Chat ID" msg:"form.profile.telegramChatId"`
|
||||||
Acfunlink string `valid:"maxlen:30" label:"AcFun主页" msg:"form.profile.linkMax"`
|
Acfunlink string `valid:"maxlen:30" label:"AcFun主页" msg:"form.profile.linkMax"`
|
||||||
Bililink string `valid:"maxlen:30" label:"Bili主页" msg:"form.profile.linkMax"`
|
Bililink string `valid:"maxlen:30" label:"Bili主页" msg:"form.profile.linkMax"`
|
||||||
Weibolink string `valid:"maxlen:30" label:"微博主页" msg:"form.profile.linkMax"`
|
Weibolink string `valid:"maxlen:30" label:"微博主页" msg:"form.profile.linkMax"`
|
||||||
|
|||||||
@@ -203,6 +203,9 @@ var enDict = map[string]string{
|
|||||||
"admin.oldPasswordHint": "Only needed when changing password; leave empty to keep it unchanged",
|
"admin.oldPasswordHint": "Only needed when changing password; leave empty to keep it unchanged",
|
||||||
"admin.newPasswordHint": "6-64 characters; leave empty to keep it unchanged",
|
"admin.newPasswordHint": "6-64 characters; leave empty to keep it unchanged",
|
||||||
"admin.notifyEmail": "Email",
|
"admin.notifyEmail": "Email",
|
||||||
|
"admin.notifyTelegram": "Telegram",
|
||||||
|
"admin.notifyTelegramPlaceholder": "Telegram Chat ID (empty = disabled)",
|
||||||
|
"admin.notifyTelegramHint": "Send any message to @userinfobot to get your Chat ID. Requires a site-configured Telegram Bot.",
|
||||||
"admin.useCustomFg": "Use custom foreground color (otherwise auto-matched)",
|
"admin.useCustomFg": "Use custom foreground color (otherwise auto-matched)",
|
||||||
"admin.customFgHint": "Only takes effect when enabled; otherwise the color is auto-generated",
|
"admin.customFgHint": "Only takes effect when enabled; otherwise the color is auto-generated",
|
||||||
"admin.avatarPlaceholder": "Upload avatar (non-1:1 images will be squished)",
|
"admin.avatarPlaceholder": "Upload avatar (non-1:1 images will be squished)",
|
||||||
|
|||||||
@@ -203,6 +203,9 @@ var jaDict = map[string]string{
|
|||||||
"admin.oldPasswordHint": "パスワード変更時のみ使用。空欄のままなら変更されません",
|
"admin.oldPasswordHint": "パスワード変更時のみ使用。空欄のままなら変更されません",
|
||||||
"admin.newPasswordHint": "6〜64文字。空欄のままなら変更されません",
|
"admin.newPasswordHint": "6〜64文字。空欄のままなら変更されません",
|
||||||
"admin.notifyEmail": "メール",
|
"admin.notifyEmail": "メール",
|
||||||
|
"admin.notifyTelegram": "Telegram 通知",
|
||||||
|
"admin.notifyTelegramPlaceholder": "Telegram Chat ID(空欄なら送信しない)",
|
||||||
|
"admin.notifyTelegramHint": "@userinfobot に何か送ると Chat ID を取得できます。サイト側で Telegram Bot の設定が必要です。",
|
||||||
"admin.useCustomFg": "カスタム前景色を使用(それ以外は自動マッチ)",
|
"admin.useCustomFg": "カスタム前景色を使用(それ以外は自動マッチ)",
|
||||||
"admin.customFgHint": "有効時のみ適用されます。それ以外は自動生成されます",
|
"admin.customFgHint": "有効時のみ適用されます。それ以外は自動生成されます",
|
||||||
"admin.avatarPlaceholder": "アバターをアップロード(1:1 以外は縦横比が崩れます)",
|
"admin.avatarPlaceholder": "アバターをアップロード(1:1 以外は縦横比が崩れます)",
|
||||||
|
|||||||
@@ -203,6 +203,9 @@ var zhCNDict = map[string]string{
|
|||||||
"admin.oldPasswordHint": "修改密码使用,留空则不修改",
|
"admin.oldPasswordHint": "修改密码使用,留空则不修改",
|
||||||
"admin.newPasswordHint": "6-64 位,留空则不修改",
|
"admin.newPasswordHint": "6-64 位,留空则不修改",
|
||||||
"admin.notifyEmail": "邮件",
|
"admin.notifyEmail": "邮件",
|
||||||
|
"admin.notifyTelegram": "Telegram 通知",
|
||||||
|
"admin.notifyTelegramPlaceholder": "Telegram Chat ID(留空则不发送)",
|
||||||
|
"admin.notifyTelegramHint": "向 @userinfobot 发送任意消息即可获得你的 Chat ID。需站方配置了 Telegram Bot。",
|
||||||
"admin.useCustomFg": "启用自定义前景色(否则自动匹配)",
|
"admin.useCustomFg": "启用自定义前景色(否则自动匹配)",
|
||||||
"admin.customFgHint": "仅在启用时生效,否则颜色由系统自动生成",
|
"admin.customFgHint": "仅在启用时生效,否则颜色由系统自动生成",
|
||||||
"admin.avatarPlaceholder": "上传个人头像(头像比例不为1:1就扁了你哦)",
|
"admin.avatarPlaceholder": "上传个人头像(头像比例不为1:1就扁了你哦)",
|
||||||
|
|||||||
@@ -203,6 +203,9 @@ var zhTWDict = map[string]string{
|
|||||||
"admin.oldPasswordHint": "修改密碼使用,留空則不修改",
|
"admin.oldPasswordHint": "修改密碼使用,留空則不修改",
|
||||||
"admin.newPasswordHint": "6-64 位,留空則不修改",
|
"admin.newPasswordHint": "6-64 位,留空則不修改",
|
||||||
"admin.notifyEmail": "郵件",
|
"admin.notifyEmail": "郵件",
|
||||||
|
"admin.notifyTelegram": "Telegram 通知",
|
||||||
|
"admin.notifyTelegramPlaceholder": "Telegram Chat ID(留空則不發送)",
|
||||||
|
"admin.notifyTelegramHint": "向 @userinfobot 發送任意訊息即可取得你的 Chat ID。需站方配置了 Telegram Bot。",
|
||||||
"admin.useCustomFg": "啟用自訂前景色(否則自動比對)",
|
"admin.useCustomFg": "啟用自訂前景色(否則自動比對)",
|
||||||
"admin.customFgHint": "僅在啟用時生效,否則顏色由系統自動產生",
|
"admin.customFgHint": "僅在啟用時生效,否則顏色由系統自動產生",
|
||||||
"admin.avatarPlaceholder": "上傳個人頭像(頭像比例不為1:1就扁了你哦)",
|
"admin.avatarPlaceholder": "上傳個人頭像(頭像比例不為1:1就扁了你哦)",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/tamakyi/TamaBox/internal/form"
|
"github.com/tamakyi/TamaBox/internal/form"
|
||||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||||
"github.com/tamakyi/TamaBox/internal/mail"
|
"github.com/tamakyi/TamaBox/internal/mail"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/notify"
|
||||||
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
||||||
"github.com/tamakyi/TamaBox/internal/shared"
|
"github.com/tamakyi/TamaBox/internal/shared"
|
||||||
)
|
)
|
||||||
@@ -146,6 +147,9 @@ func PublishFollowUp(ctx context.Context, pageUser *db.User, question *db.Questi
|
|||||||
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new follow up mail to user")
|
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new follow up mail to user")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
notify.Send(stdctx.Background(), "new_follow_up",
|
||||||
|
"新的追问", f.Content,
|
||||||
|
fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, pageUser.Domain, question.ID))
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "followup.flash.sent"))
|
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "followup.flash.sent"))
|
||||||
@@ -222,6 +226,11 @@ func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Questio
|
|||||||
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send follow up answer mail to questioner")
|
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send follow up answer mail to questioner")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if wasFirst {
|
||||||
|
notify.Send(stdctx.Background(), "follow_up_answer",
|
||||||
|
"追问的回答", f.Answer,
|
||||||
|
fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, pageUser.Domain, question.ID))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "followup.flash.answered"))
|
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "followup.flash.answered"))
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import (
|
|||||||
"github.com/tamakyi/TamaBox/internal/imagecheck"
|
"github.com/tamakyi/TamaBox/internal/imagecheck"
|
||||||
"github.com/tamakyi/TamaBox/internal/insights"
|
"github.com/tamakyi/TamaBox/internal/insights"
|
||||||
"github.com/tamakyi/TamaBox/internal/mail"
|
"github.com/tamakyi/TamaBox/internal/mail"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/notify"
|
||||||
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
||||||
"github.com/tamakyi/TamaBox/internal/shared"
|
"github.com/tamakyi/TamaBox/internal/shared"
|
||||||
"github.com/tamakyi/TamaBox/internal/storage"
|
"github.com/tamakyi/TamaBox/internal/storage"
|
||||||
@@ -575,6 +576,9 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
|
|||||||
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new conversation mail to asker")
|
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new conversation mail to asker")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
notify.Send(stdctx.Background(), "new_conversation",
|
||||||
|
"新的私聊", f.Content,
|
||||||
|
fmt.Sprintf("%s/_/%s/chat/%s", conf.App.ExternalURL, pageUser.Domain, conv.Token))
|
||||||
})
|
})
|
||||||
|
|
||||||
chatURL := fmt.Sprintf("/_/%s/chat/%s", pageUser.Domain, conv.Token)
|
chatURL := fmt.Sprintf("/_/%s/chat/%s", pageUser.Domain, conv.Token)
|
||||||
@@ -733,6 +737,16 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
|
|||||||
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new question mail to questioner")
|
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new question mail to questioner")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 站级 Webhook:新提问是运营最关心的事件之一,推给配置的广播通道。
|
||||||
|
notify.Send(stdctx.Background(), "new_question",
|
||||||
|
"新的提问", question.Content,
|
||||||
|
fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, pageUser.Domain, question.ID))
|
||||||
|
// 用户级 Telegram:箱主自填 chat_id 时点对点推送。
|
||||||
|
if pageUser.TelegramChatID != "" {
|
||||||
|
notify.SendToTelegram(stdctx.Background(), pageUser.TelegramChatID,
|
||||||
|
fmt.Sprintf("📬 新的提问\n\n%s\n\n%s/_/%s/%d",
|
||||||
|
question.Content, conf.App.ExternalURL, pageUser.Domain, question.ID))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
questionPrivateURL := fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token)
|
questionPrivateURL := fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||||
"github.com/tamakyi/TamaBox/internal/imagecheck"
|
"github.com/tamakyi/TamaBox/internal/imagecheck"
|
||||||
"github.com/tamakyi/TamaBox/internal/mail"
|
"github.com/tamakyi/TamaBox/internal/mail"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/notify"
|
||||||
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
||||||
"github.com/tamakyi/TamaBox/internal/shared"
|
"github.com/tamakyi/TamaBox/internal/shared"
|
||||||
)
|
)
|
||||||
@@ -265,6 +266,12 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question
|
|||||||
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send receive reply mail to questioner")
|
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send receive reply mail to questioner")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 站级 Webhook:新回答(回答发布时推送)。
|
||||||
|
if !forcedPrivate {
|
||||||
|
notify.Send(stdctx.Background(), "new_answer",
|
||||||
|
"新的回答", f.Answer,
|
||||||
|
fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, pageUser.Domain, question.ID))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// L2 下必须明确告知「已转为私密」:单题页对箱主本人仍是 200、内容照常显示,
|
// L2 下必须明确告知「已转为私密」:单题页对箱主本人仍是 200、内容照常显示,
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ func UpdateProfile(ctx context.Context, f form.UpdateProfile) {
|
|||||||
Bloglink: f.Bloglink,
|
Bloglink: f.Bloglink,
|
||||||
UseCustomQRColor: useCustomQRColor,
|
UseCustomQRColor: useCustomQRColor,
|
||||||
Notify: notify,
|
Notify: notify,
|
||||||
|
TelegramChatID: f.TelegramChatID,
|
||||||
Language: i18n.Resolve(f.Language),
|
Language: i18n.Resolve(f.Language),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update user profile")
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update user profile")
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
"github.com/tamakyi/TamaBox/internal/db"
|
"github.com/tamakyi/TamaBox/internal/db"
|
||||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||||
"github.com/tamakyi/TamaBox/internal/mail"
|
"github.com/tamakyi/TamaBox/internal/mail"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/notify"
|
||||||
"github.com/tamakyi/TamaBox/internal/security/censor"
|
"github.com/tamakyi/TamaBox/internal/security/censor"
|
||||||
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
||||||
"github.com/tamakyi/TamaBox/internal/shared"
|
"github.com/tamakyi/TamaBox/internal/shared"
|
||||||
@@ -761,6 +762,20 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if vote.Type == db.VoteTypeForm {
|
||||||
|
title := vote.Title
|
||||||
|
voteID := vote.ID
|
||||||
|
shared.SafeGo(func() {
|
||||||
|
notify.Send(stdctx.Background(), "new_vote_submission",
|
||||||
|
"新的投票回复", title,
|
||||||
|
fmt.Sprintf("%s/user/votes/%d/results", conf.App.ExternalURL, voteID))
|
||||||
|
if owner.TelegramChatID != "" {
|
||||||
|
notify.SendToTelegram(stdctx.Background(), owner.TelegramChatID,
|
||||||
|
fmt.Sprintf("🗳️ 投票「%s」收到新回复\n%s/user/votes/%d/results",
|
||||||
|
title, conf.App.ExternalURL, voteID))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
ctx.SetSuccessFlash(i18n.T(lang, "vote.flash.success"))
|
ctx.SetSuccessFlash(i18n.T(lang, "vote.flash.success"))
|
||||||
ctx.Redirect("/vote/" + vote.LinkToken)
|
ctx.Redirect("/vote/" + vote.LinkToken)
|
||||||
|
|||||||
@@ -595,6 +595,13 @@
|
|||||||
<span class="uk-text-small"> {{t .LangCode "admin.notifyEmail"}}</span>
|
<span class="uk-text-small"> {{t .LangCode "admin.notifyEmail"}}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="uk-margin">
|
||||||
|
<label class="uk-form-label">{{t .LangCode "admin.notifyTelegram"}}</label>
|
||||||
|
<input name="telegram_chat_id" class="uk-input uk-form-width-medium" type="text"
|
||||||
|
placeholder="{{t .LangCode "admin.notifyTelegramPlaceholder"}}"
|
||||||
|
value="{{.LoggedUser.TelegramChatID}}">
|
||||||
|
<div class="uk-text-small uk-text-muted">{{t .LangCode "admin.notifyTelegramHint"}}</div>
|
||||||
|
</div>
|
||||||
<div class="tb-card-save">
|
<div class="tb-card-save">
|
||||||
<button type="submit" class="uk-button uk-button-primary">{{t .LangCode "profile.saveBasic"}}</button>
|
<button type="submit" class="uk-button uk-button-primary">{{t .LangCode "profile.saveBasic"}}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user