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:
tamakyi
2026-10-29 19:27:23 +08:00
parent 1e9ffc0718
commit b9060c982b
17 changed files with 519 additions and 218 deletions
+9
View File
@@ -15,6 +15,7 @@ import (
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/notify"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"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")
}
}
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"))
@@ -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")
}
}
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"))
+14
View File
@@ -37,6 +37,7 @@ import (
"github.com/tamakyi/TamaBox/internal/imagecheck"
"github.com/tamakyi/TamaBox/internal/insights"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/notify"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/shared"
"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")
}
}
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)
@@ -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")
}
}
// 站级 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)
+7
View File
@@ -21,6 +21,7 @@ import (
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/imagecheck"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/notify"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"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")
}
}
// 站级 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、内容照常显示,
+1
View File
@@ -254,6 +254,7 @@ func UpdateProfile(ctx context.Context, f form.UpdateProfile) {
Bloglink: f.Bloglink,
UseCustomQRColor: useCustomQRColor,
Notify: notify,
TelegramChatID: f.TelegramChatID,
Language: i18n.Resolve(f.Language),
}); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update user profile")
+15
View File
@@ -30,6 +30,7 @@ import (
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
"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/moderation"
"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.Redirect("/vote/" + vote.LinkToken)