Files
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

239 lines
9.9 KiB
Go
Raw Permalink 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.
package question
import (
stdctx "context"
"crypto/subtle"
"fmt"
"unicode/utf8"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"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"
)
// isQuestioner 判断当前请求者是否为该提问的提问者。
// 匿名提问者通过 token(URL 或表单隐藏域)验证;登录提问者通过账号 ID 验证。
func isQuestioner(ctx context.Context, question *db.Question, token string) bool {
// 常数时间比较:与 Pager 中间件里的校验(route/question/question.go)同一口径。
// 用 == 会按字节短路,攻击者能靠响应时间逐字节试探出 token ——
// 而 token 就是「私密提问 / 尚未回答的提问」唯一的钥匙。
if question.Token != "" && token != "" &&
subtle.ConstantTimeCompare([]byte(question.Token), []byte(token)) == 1 {
return true
}
if ctx.IsLogged && question.AskerUserID != 0 && ctx.User.ID == question.AskerUserID {
return true
}
return false
}
// PublishFollowUp 提问者在问题被回答后,用 token 继续追问。
func PublishFollowUp(ctx context.Context, pageUser *db.User, question *db.Question, f form.PublishFollowUpQuestion) {
// govalid 校验失败(追问内容为空)时回显表单,与 AnswerFollowUp 同路径。
// (旧实现漏掉该早退:required 失败后仍会继续 censor/入库,追问空内容也会被创建。)
if ctx.HasError() {
ctx.Success("question/item")
return
}
// 追问内容长度上限(conf.Limits.FollowUpContentLen,[limits] 配置段),
// 与前端追问框 maxlength 同源,rune 计数。
if utf8.RuneCountInString(f.Content) > conf.Limits.FollowUpContentLen {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "form.followup.contentTooLong", conf.Limits.FollowUpContentLen)), f)
ctx.Success("question/item")
return
}
token := ctx.Query("t")
if token == "" {
token = f.Token
}
if !isQuestioner(ctx, question, token) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errNoPermission"))
ctx.Redirect("/")
return
}
// 只能对已回答的问题追问
if question.Answer == "" {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errNotAnswered"))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
// 追问次数限制:先快速预检查(友好提示),CreateIfBelowLimit 内再原子兜底
// (并发场景下 count-then-create 存在竞态,必须以数据库原子检查为准)。
count, err := db.FollowUps.CountByQuestionID(ctx.Request().Context(), question.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to count follow ups")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
if count >= db.MaxFollowUpCount {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errLimit", db.MaxFollowUpCount))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
// 服务端校验"等主人回复":模板仅在前一条被回复后才展示追问表单,
// 但 POST 可被直接构造绕过,因此必须检查最新一条追问是否已回复。
//
// 这一段只是「快速失败」省掉一次事务开销;并发/双击下的正确性由
// db.FollowUps.CreateIfBelowLimit 在事务内重新判定(见 ErrFollowUpAwaitingReply)。
latest, err := db.FollowUps.GetLatestByQuestionID(ctx.Request().Context(), question.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get latest follow up")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
if latest != nil && latest.Answer == "" {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errWaitReply"))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
if ctx.HasError() {
ctx.Success("question/item")
return
}
// 内容安全检测:统一守卫,按箱主「审核严格度」裁决是否放行
// (提问 / 投票 / 私聊共用同一份判定,见 internal/security/moderation)。
if _, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictness(ctx.Request().Context(), pageUser.ID, pageUser.ModerationStrictness), ctx.Lang(), f.Content); err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
created, err := db.FollowUps.CreateIfBelowLimit(ctx.Request().Context(), db.CreateFollowUpOptions{
QuestionID: question.ID,
Content: f.Content,
})
if err != nil {
if errors.Is(err, db.ErrFollowUpLimitReached) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errLimit", db.MaxFollowUpCount))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
if errors.Is(err, db.ErrFollowUpAwaitingReply) {
// 事务层的兜底(并发双击时会走到这里)
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errWaitReply"))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create follow up")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
_ = created
// 邮件通知提问箱主人(开启邮件提醒时)
shared.SafeGo(func() {
if pageUser.Notify == db.NotifyTypeEmail {
if err := mail.SendNewFollowUpMail(pageUser.Email, pageUser.Domain, question.ID, question.Content, f.Content, question.Token); err != nil {
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.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
}
// AnswerFollowUp 提问箱主人回答某条追问。
func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Question, f form.AnswerFollowUpQuestion) {
if ctx.User.ID != pageUser.ID {
ctx.Redirect("/")
return
}
if ctx.HasError() {
ctx.Success("question/item")
return
}
// 追问的回答内容长度上限:与主回答共用 conf.Limits.AnswerContentLen
// ([limits] 配置段),与前端回复框 maxlength 同源,rune 计数。
if utf8.RuneCountInString(f.Answer) > conf.Limits.AnswerContentLen {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "form.followup.answerTooLong", conf.Limits.AnswerContentLen)), f)
ctx.Success("question/item")
return
}
// 站方处置 L2 起拒绝回答追问:追问回答同样是公开内容,且没有「私密」这一档可以降级。
restrictionLevel, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), pageUser.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
if db.RestrictionAtLeast(restrictionLevel, db.RestrictionL2) {
// 同请求渲染,用 SetError(SetErrorFlash 要等下一次请求才显示)。
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errRestrictedPublish")), f)
ctx.Success("question/item")
return
}
followUpID := uint(ctx.ParamInt("followUpID"))
// 内容安全检测:统一守卫,箱主本人回复同样按自己的审核严格度裁决。
if _, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictnessFor(restrictionLevel, pageUser.ModerationStrictness), ctx.Lang(), f.Answer); err != nil {
// 同请求渲染,用 SetError;原先的 SetErrorFlash 在这里不会显示出来(既有缺陷)。
ctx.SetError(err, f)
ctx.Success("question/item")
return
}
wasFirst, err := db.FollowUps.UpdateAnswerByID(ctx.Request().Context(), question.ID, followUpID, f.Answer)
if err != nil {
if errors.Is(err, db.ErrFollowUpNotFound) {
// 同请求渲染需用 SetError(SetErrorFlash 要等下一次请求才显示)。
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "followup.errNotFound")), f)
ctx.Success("question/item")
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to answer follow up")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
// 邮件通知提问者(留下邮箱时):复用"您的提问有了回复"模板,带 token 链接。
//
// 只在**首次**回答时发:箱主改一次措辞、补一句、修错别字都会重新保存,
// 每次都发的话提问者会连着收到好几封一模一样的「您的提问有回复」。
// 主回答那边早就有同样的判定(question.Answer == ""),这里补齐。
shared.SafeGo(func() {
if wasFirst && question.ReceiveReplyEmail != "" {
if err := mail.SendNewAnswerMail(question.ReceiveReplyEmail, pageUser.Domain, question.ID, question.Content, f.Answer, question.Token); err != nil {
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.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID))
}