diff --git a/conf/app.sample.ini b/conf/app.sample.ini index 4000be0..90f297e 100644 --- a/conf/app.sample.ini +++ b/conf/app.sample.ini @@ -274,6 +274,19 @@ password = "" port = 465 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] ; ── 全站内容 / 数量上限(运行时配置,修改后重启生效)────────────────────── ; 缺失本段或个别键时按内置默认值运行。长度统一按字符(rune)计数。 diff --git a/internal/conf/conf.go b/internal/conf/conf.go index 0c643a2..f1f2a2c 100644 --- a/internal/conf/conf.go +++ b/internal/conf/conf.go @@ -111,6 +111,10 @@ func Init() error { 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() for _, serviceSection := range serviceSections { serviceSection := serviceSection diff --git a/internal/conf/static.go b/internal/conf/static.go index e6d65b2..17f2956 100644 --- a/internal/conf/static.go +++ b/internal/conf/static.go @@ -215,6 +215,18 @@ var ( 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 { Backends []struct { Prefix string `ini:"prefix"` diff --git a/internal/db/users.go b/internal/db/users.go index 2edbc33..762e689 100644 --- a/internal/db/users.go +++ b/internal/db/users.go @@ -109,8 +109,11 @@ type User struct { Backgroundimagealpha string `json:"Backgroundimagealpha"` // Theme 箱主选择的主题 slug(空串=跟随站点默认,default=基础外观)。 // 取值合法性由 route/user 在写入前校验,读取侧由 internal/theme 回退。 - Theme string `json:"theme" gorm:"column:theme;type:varchar(32);default:''"` - Notify NotifyType `json:"notify"` + Theme string `json:"theme" gorm:"column:theme;type:varchar(32);default:''"` + 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'"` BlockWords string `json:"-"` // 防骚扰设置取值 none / register_only(见下方常量)。历史账号此列为空串(建表时无默认值), @@ -343,6 +346,7 @@ type UpdateUserOptions struct { Bloglink string UseCustomQRColor bool Notify NotifyType + TelegramChatID string Language string } @@ -378,6 +382,7 @@ func (db *users) Update(ctx context.Context, id uint, opts UpdateUserOptions) er "Bloglink": opts.Bloglink, "UseCustomQRColor": opts.UseCustomQRColor, "Notify": opts.Notify, + "TelegramChatID": opts.TelegramChatID, "Language": opts.Language, } diff --git a/internal/form/user.go b/internal/form/user.go index df9de1e..5508756 100644 --- a/internal/form/user.go +++ b/internal/form/user.go @@ -17,6 +17,7 @@ type UpdateProfile struct { Backgroundimagealpha string `valid:"required" label:"二维码背景透明度" msg:"form.profile.backgroundimagealpha"` Qrcodepdpcolor string `valid:"required;minlen:7;maxlen:7" label:"二维码探测图形颜色" msg:"form.profile.colorHex"` 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"` Bililink string `valid:"maxlen:30" label:"Bili主页" msg:"form.profile.linkMax"` Weibolink string `valid:"maxlen:30" label:"微博主页" msg:"form.profile.linkMax"` diff --git a/internal/i18n/dict_en.go b/internal/i18n/dict_en.go index f990d8a..2f131cc 100644 --- a/internal/i18n/dict_en.go +++ b/internal/i18n/dict_en.go @@ -200,34 +200,37 @@ var enDict = map[string]string{ "admin.export": "Export data", // Admin settings (additional) - "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.notifyEmail": "Email", - "admin.useCustomFg": "Use custom foreground color (otherwise auto-matched)", - "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.backgroundPlaceholder": "Upload box background", - "admin.qrBackgroundPlaceholder": "Upload QR background (a background that is too dark may make the QR code unreadable)", - "admin.qrPreviewHint": "The preview updates instantly after choosing an image", - "admin.preview": "Live preview", - "admin.themePreset": "Preset themes", - "admin.themeDefault": "Default", - "admin.themeDark": "Dark", - "admin.themeOcean": "Ocean", - "admin.themeSunset": "Sunset", - "admin.resetDefaults": "🔄 Reset to defaults", - "admin.previewHint": "Adjustments are for preview only; click \"Save changes\" to apply", - "admin.socialHint": "Check the box and fill in the profile URL (no https:// needed). Unchecked items won't be shown on your page.", - "admin.enableAcfun": "Enable AcFun", - "admin.enableBili": "Enable Bilibili", - "admin.enableWeibo": "Enable Weibo", - "admin.enableXiaohongshu": "Enable Xiaohongshu", - "admin.enableBlog": "Enable Personal Blog", - "admin.registerOnly": "Only allow registered users to ask me questions", - "admin.updateHarassment": "Update anti-harassment settings", - "admin.globalBlockWords": "Global blocked words", - "admin.globalBlockWordsHint": "Blocked words apply to questions, chat messages sent to you, and vote submissions. This single setting is shared across the question / chat / vote panels and takes effect immediately from any of them.", - "admin.updateBlockWords": "Save blocked words", + "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.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.customFgHint": "Only takes effect when enabled; otherwise the color is auto-generated", + "admin.avatarPlaceholder": "Upload avatar (non-1:1 images will be squished)", + "admin.backgroundPlaceholder": "Upload box background", + "admin.qrBackgroundPlaceholder": "Upload QR background (a background that is too dark may make the QR code unreadable)", + "admin.qrPreviewHint": "The preview updates instantly after choosing an image", + "admin.preview": "Live preview", + "admin.themePreset": "Preset themes", + "admin.themeDefault": "Default", + "admin.themeDark": "Dark", + "admin.themeOcean": "Ocean", + "admin.themeSunset": "Sunset", + "admin.resetDefaults": "🔄 Reset to defaults", + "admin.previewHint": "Adjustments are for preview only; click \"Save changes\" to apply", + "admin.socialHint": "Check the box and fill in the profile URL (no https:// needed). Unchecked items won't be shown on your page.", + "admin.enableAcfun": "Enable AcFun", + "admin.enableBili": "Enable Bilibili", + "admin.enableWeibo": "Enable Weibo", + "admin.enableXiaohongshu": "Enable Xiaohongshu", + "admin.enableBlog": "Enable Personal Blog", + "admin.registerOnly": "Only allow registered users to ask me questions", + "admin.updateHarassment": "Update anti-harassment settings", + "admin.globalBlockWords": "Global blocked words", + "admin.globalBlockWordsHint": "Blocked words apply to questions, chat messages sent to you, and vote submissions. This single setting is shared across the question / chat / vote panels and takes effect immediately from any of them.", + "admin.updateBlockWords": "Save blocked words", // Moderation strictness: shared by questions / polls / chat. Decision rules live in internal/security/censor/policy.go. "admin.moderationStrictness": "Moderation strictness", @@ -1055,36 +1058,36 @@ var enDict = map[string]string{ "stats.weekday.7": "Sunday", // Owner insights panel (/user/insights) - "insights.title": "Insights", - "insights.heading": "Your insights", - "insights.lead": "These numbers are yours alone: how many people opened your box, how many questions arrived, and how many are still waiting. Everything is aggregated — no visitor or asker is ever identified.", - "insights.back": "Back to settings", - "insights.entryTitle": "Insights", - "insights.entryDesc": "See how many people visit your box and how your questions are doing", - "insights.summary": "At a glance", - "insights.noViews": "No visits recorded yet. Numbers will appear once someone opens your box.", - "insights.today": "Today", - "insights.week": "Last 7 days", - "insights.month": "Last 30 days", - "insights.newQuestions": "new questions", - "insights.pv": "views", - "insights.uv": "visitors", - "insights.trendTitle": "Views over the last 30 days", - "insights.trendNote": "One bar per day: the light part is page views, the dark part is unique visitors that day. Hover a bar for its views / visitors / questions.", - "insights.legendPV": "Page views", - "insights.legendUV": "Unique visitors (per day)", - "insights.legendHover": "Hover for daily details", - "insights.hourTitle": "When people show up", - "insights.hourNote": "When questions arrived during the last 30 days. The brightest bar is your busiest hour.", - "insights.funnelTitle": "Question handling", - "insights.funnelNote": "All questions split into answered / read but unanswered / untouched.", + "insights.title": "Insights", + "insights.heading": "Your insights", + "insights.lead": "These numbers are yours alone: how many people opened your box, how many questions arrived, and how many are still waiting. Everything is aggregated — no visitor or asker is ever identified.", + "insights.back": "Back to settings", + "insights.entryTitle": "Insights", + "insights.entryDesc": "See how many people visit your box and how your questions are doing", + "insights.summary": "At a glance", + "insights.noViews": "No visits recorded yet. Numbers will appear once someone opens your box.", + "insights.today": "Today", + "insights.week": "Last 7 days", + "insights.month": "Last 30 days", + "insights.newQuestions": "new questions", + "insights.pv": "views", + "insights.uv": "visitors", + "insights.trendTitle": "Views over the last 30 days", + "insights.trendNote": "One bar per day: the light part is page views, the dark part is unique visitors that day. Hover a bar for its views / visitors / questions.", + "insights.legendPV": "Page views", + "insights.legendUV": "Unique visitors (per day)", + "insights.legendHover": "Hover for daily details", + "insights.hourTitle": "When people show up", + "insights.hourNote": "When questions arrived during the last 30 days. The brightest bar is your busiest hour.", + "insights.funnelTitle": "Question handling", + "insights.funnelNote": "All questions split into answered / read but unanswered / untouched.", "insights.funnelTotal": "All questions", "insights.funnelAnswered": "Answered", "insights.funnelReadOnly": "Read, not answered", "insights.funnelUnread": "Not read", - "insights.answerRate": "Answer rate: answered ÷ all questions", - "insights.privacyTitle": "About these numbers", - "insights.privacyNote": "Visit stats only record \"someone visited N times on a given day\". A visitor is an IP hash with a per-day salt — no IP, no user agent, no page path is stored, and the same person cannot be linked across days. The 7-day / 30-day visitor counts are therefore the sum of daily uniques: someone visiting on several days counts several times. Your own visits to your own box are excluded.", + "insights.answerRate": "Answer rate: answered ÷ all questions", + "insights.privacyTitle": "About these numbers", + "insights.privacyNote": "Visit stats only record \"someone visited N times on a given day\". A visitor is an IP hash with a per-day salt — no IP, no user agent, no page path is stored, and the same person cannot be linked across days. The 7-day / 30-day visitor counts are therefore the sum of daily uniques: someone visiting on several days counts several times. Your own visits to your own box are excluded.", "insights.insight.empty": "No data yet. This page comes alive once you get your first question or someone opens your box.", "insights.insight.weekViews": "%s views from %s visitors in the last 7 days.", diff --git a/internal/i18n/dict_ja.go b/internal/i18n/dict_ja.go index c069f87..560753e 100644 --- a/internal/i18n/dict_ja.go +++ b/internal/i18n/dict_ja.go @@ -200,34 +200,37 @@ var jaDict = map[string]string{ "admin.export": "データをエクスポート", // 管理画面設定(追加) - "admin.oldPasswordHint": "パスワード変更時のみ使用。空欄のままなら変更されません", - "admin.newPasswordHint": "6〜64文字。空欄のままなら変更されません", - "admin.notifyEmail": "メール", - "admin.useCustomFg": "カスタム前景色を使用(それ以外は自動マッチ)", - "admin.customFgHint": "有効時のみ適用されます。それ以外は自動生成されます", - "admin.avatarPlaceholder": "アバターをアップロード(1:1 以外は縦横比が崩れます)", - "admin.backgroundPlaceholder": "質問箱の背景をアップロード", - "admin.qrBackgroundPlaceholder": "QRコードの背景画像をアップロード(暗すぎる背景はQRコードが読み取れなくなることがあります)", - "admin.qrPreviewHint": "画像を選択するとプレビューが即時更新されます", - "admin.preview": "ライブプレビュー", - "admin.themePreset": "プリセットテーマ", - "admin.themeDefault": "デフォルト", - "admin.themeDark": "ダーク", - "admin.themeOcean": "オーシャン", - "admin.themeSunset": "サンセット", - "admin.resetDefaults": "🔄 デフォルトに戻す", - "admin.previewHint": "調整はプレビューのみです。「変更を保存」をクリックすると反映されます", - "admin.socialHint": "チェックを入れてプロフィールURLを入力してください(https:// は不要)。チェックのない項目は表示されません。", - "admin.enableAcfun": "AcFun を有効にする", - "admin.enableBili": "Bilibili を有効にする", - "admin.enableWeibo": "Weibo を有効にする", - "admin.enableXiaohongshu": "小紅書 を有効にする", - "admin.enableBlog": "個人ブログを有効にする", - "admin.registerOnly": "登録ユーザーのみ質問を許可", - "admin.updateHarassment": "迷惑行為対策を更新", - "admin.globalBlockWords": "共通ブロックワード", - "admin.globalBlockWordsHint": "ブロックワードは質問・チャット(相手からあなたへのメッセージ)・投票の投稿内容に適用されます。この設定は質問/チャット/投票の3つのタブで共有され、どこで変更してもすぐに反映されます。", - "admin.updateBlockWords": "ブロックワードを保存", + "admin.oldPasswordHint": "パスワード変更時のみ使用。空欄のままなら変更されません", + "admin.newPasswordHint": "6〜64文字。空欄のままなら変更されません", + "admin.notifyEmail": "メール", + "admin.notifyTelegram": "Telegram 通知", + "admin.notifyTelegramPlaceholder": "Telegram Chat ID(空欄なら送信しない)", + "admin.notifyTelegramHint": "@userinfobot に何か送ると Chat ID を取得できます。サイト側で Telegram Bot の設定が必要です。", + "admin.useCustomFg": "カスタム前景色を使用(それ以外は自動マッチ)", + "admin.customFgHint": "有効時のみ適用されます。それ以外は自動生成されます", + "admin.avatarPlaceholder": "アバターをアップロード(1:1 以外は縦横比が崩れます)", + "admin.backgroundPlaceholder": "質問箱の背景をアップロード", + "admin.qrBackgroundPlaceholder": "QRコードの背景画像をアップロード(暗すぎる背景はQRコードが読み取れなくなることがあります)", + "admin.qrPreviewHint": "画像を選択するとプレビューが即時更新されます", + "admin.preview": "ライブプレビュー", + "admin.themePreset": "プリセットテーマ", + "admin.themeDefault": "デフォルト", + "admin.themeDark": "ダーク", + "admin.themeOcean": "オーシャン", + "admin.themeSunset": "サンセット", + "admin.resetDefaults": "🔄 デフォルトに戻す", + "admin.previewHint": "調整はプレビューのみです。「変更を保存」をクリックすると反映されます", + "admin.socialHint": "チェックを入れてプロフィールURLを入力してください(https:// は不要)。チェックのない項目は表示されません。", + "admin.enableAcfun": "AcFun を有効にする", + "admin.enableBili": "Bilibili を有効にする", + "admin.enableWeibo": "Weibo を有効にする", + "admin.enableXiaohongshu": "小紅書 を有効にする", + "admin.enableBlog": "個人ブログを有効にする", + "admin.registerOnly": "登録ユーザーのみ質問を許可", + "admin.updateHarassment": "迷惑行為対策を更新", + "admin.globalBlockWords": "共通ブロックワード", + "admin.globalBlockWordsHint": "ブロックワードは質問・チャット(相手からあなたへのメッセージ)・投票の投稿内容に適用されます。この設定は質問/チャット/投票の3つのタブで共有され、どこで変更してもすぐに反映されます。", + "admin.updateBlockWords": "ブロックワードを保存", // 審核の厳格度:質問 / 投票 / チャットで共通。判定規則は internal/security/censor/policy.go。 "admin.moderationStrictness": "審核の厳格度", @@ -1054,36 +1057,36 @@ var jaDict = map[string]string{ "stats.weekday.7": "日曜日", // 質問箱オーナーのインサイト(/user/insights) - "insights.title": "インサイト", - "insights.heading": "あなたのインサイト", - "insights.lead": "この数字はあなただけのものです。何人が質問箱を開いたか、いくつ質問が届いたか、まだ手つかずの質問がいくつあるか。すべて集計値で、訪問者や質問者の身元は一切含みません。", - "insights.back": "設定に戻る", - "insights.entryTitle": "インサイト", - "insights.entryDesc": "質問箱に何人来たか、質問の対応状況を見る", - "insights.summary": "ひと目で", - "insights.noViews": "まだ訪問の記録がありません。誰かが質問箱を開くと数字が出はじめます。", - "insights.today": "今日", - "insights.week": "過去 7 日間", - "insights.month": "過去 30 日間", - "insights.newQuestions": "新着の質問", - "insights.pv": "回表示", - "insights.uv": "人", - "insights.trendTitle": "過去 30 日間の閲覧推移", - "insights.trendNote": "1 本が 1 日分。淡い部分が閲覧数、濃い部分がその日の重複を除いた訪問者数です。棒にカーソルを合わせると当日の閲覧/訪問者/質問数が出ます。", - "insights.legendPV": "閲覧数(PV)", - "insights.legendUV": "ユニーク訪問者(日ごとに重複排除)", - "insights.legendHover": "カーソルで当日の内訳", - "insights.hourTitle": "いつ来ているか", - "insights.hourNote": "過去 30 日に届いた質問の時間帯分布。最も明るい棒が一番忙しい時間帯です。", - "insights.funnelTitle": "質問の対応状況", - "insights.funnelNote": "すべての質問を「回答済み/既読・未回答/未読」に分けたものです。", + "insights.title": "インサイト", + "insights.heading": "あなたのインサイト", + "insights.lead": "この数字はあなただけのものです。何人が質問箱を開いたか、いくつ質問が届いたか、まだ手つかずの質問がいくつあるか。すべて集計値で、訪問者や質問者の身元は一切含みません。", + "insights.back": "設定に戻る", + "insights.entryTitle": "インサイト", + "insights.entryDesc": "質問箱に何人来たか、質問の対応状況を見る", + "insights.summary": "ひと目で", + "insights.noViews": "まだ訪問の記録がありません。誰かが質問箱を開くと数字が出はじめます。", + "insights.today": "今日", + "insights.week": "過去 7 日間", + "insights.month": "過去 30 日間", + "insights.newQuestions": "新着の質問", + "insights.pv": "回表示", + "insights.uv": "人", + "insights.trendTitle": "過去 30 日間の閲覧推移", + "insights.trendNote": "1 本が 1 日分。淡い部分が閲覧数、濃い部分がその日の重複を除いた訪問者数です。棒にカーソルを合わせると当日の閲覧/訪問者/質問数が出ます。", + "insights.legendPV": "閲覧数(PV)", + "insights.legendUV": "ユニーク訪問者(日ごとに重複排除)", + "insights.legendHover": "カーソルで当日の内訳", + "insights.hourTitle": "いつ来ているか", + "insights.hourNote": "過去 30 日に届いた質問の時間帯分布。最も明るい棒が一番忙しい時間帯です。", + "insights.funnelTitle": "質問の対応状況", + "insights.funnelNote": "すべての質問を「回答済み/既読・未回答/未読」に分けたものです。", "insights.funnelTotal": "すべての質問", "insights.funnelAnswered": "回答済み", "insights.funnelReadOnly": "既読・未回答", "insights.funnelUnread": "未読", - "insights.answerRate": "回答率:回答済み ÷ 全質問", - "insights.privacyTitle": "この数字について", - "insights.privacyNote": "訪問統計は「ある日に何回来たか」だけを記録します。訪問者の識別子は日ごとに塩を変えた IP ハッシュで、IP・UA・閲覧ページは保存しません。そのため日をまたいで同一人物を紐付けることはできず、7 日/30 日の訪問者数は各日の重複排除後の合計です(同じ人が数日来るとその人数分カウントされます)。質問箱の持ち主自身のアクセスは含みません。", + "insights.answerRate": "回答率:回答済み ÷ 全質問", + "insights.privacyTitle": "この数字について", + "insights.privacyNote": "訪問統計は「ある日に何回来たか」だけを記録します。訪問者の識別子は日ごとに塩を変えた IP ハッシュで、IP・UA・閲覧ページは保存しません。そのため日をまたいで同一人物を紐付けることはできず、7 日/30 日の訪問者数は各日の重複排除後の合計です(同じ人が数日来るとその人数分カウントされます)。質問箱の持ち主自身のアクセスは含みません。", "insights.insight.empty": "まだデータがありません。最初の質問が届くか、誰かが質問箱を開くと表示されはじめます。", "insights.insight.weekViews": "過去 7 日間に %s 回の閲覧、%s 人の訪問者。", diff --git a/internal/i18n/dict_zh_cn.go b/internal/i18n/dict_zh_cn.go index 016983c..61a093b 100644 --- a/internal/i18n/dict_zh_cn.go +++ b/internal/i18n/dict_zh_cn.go @@ -200,34 +200,37 @@ var zhCNDict = map[string]string{ "admin.export": "导出数据", // 后台设置(补充) - "admin.oldPasswordHint": "修改密码使用,留空则不修改", - "admin.newPasswordHint": "6-64 位,留空则不修改", - "admin.notifyEmail": "邮件", - "admin.useCustomFg": "启用自定义前景色(否则自动匹配)", - "admin.customFgHint": "仅在启用时生效,否则颜色由系统自动生成", - "admin.avatarPlaceholder": "上传个人头像(头像比例不为1:1就扁了你哦)", - "admin.backgroundPlaceholder": "上传提问箱背景", - "admin.qrBackgroundPlaceholder": "上传二维码背景(颜色过深的背景会导致二维码无法识别)", - "admin.qrPreviewHint": "选择图片后预览将即时更新", - "admin.preview": "实时预览", - "admin.themePreset": "预设主题", - "admin.themeDefault": "默认", - "admin.themeDark": "暗夜", - "admin.themeOcean": "海洋", - "admin.themeSunset": "日落", - "admin.resetDefaults": "🔄 恢复默认值", - "admin.previewHint": "调整仅用于预览,最终效果点击「保存修改」后生效", - "admin.socialHint": "勾选后填写主页地址(无需 https://),未勾选的项将不会在前台显示。", - "admin.enableAcfun": "启用 AcFun", - "admin.enableBili": "启用 Bilibili", - "admin.enableWeibo": "启用 微博", - "admin.enableXiaohongshu": "启用 小红书", - "admin.enableBlog": "启用 个人博客", - "admin.registerOnly": "仅允许注册用户向我提问", - "admin.updateHarassment": "更新防骚扰设置", - "admin.globalBlockWords": "全局屏蔽词", - "admin.globalBlockWordsHint": "屏蔽词对提问、私聊(对方发给你的消息)与投票提交内容生效;三个设置页同步显示同一份设置,任意一处修改即时生效。", - "admin.updateBlockWords": "保存屏蔽词", + "admin.oldPasswordHint": "修改密码使用,留空则不修改", + "admin.newPasswordHint": "6-64 位,留空则不修改", + "admin.notifyEmail": "邮件", + "admin.notifyTelegram": "Telegram 通知", + "admin.notifyTelegramPlaceholder": "Telegram Chat ID(留空则不发送)", + "admin.notifyTelegramHint": "向 @userinfobot 发送任意消息即可获得你的 Chat ID。需站方配置了 Telegram Bot。", + "admin.useCustomFg": "启用自定义前景色(否则自动匹配)", + "admin.customFgHint": "仅在启用时生效,否则颜色由系统自动生成", + "admin.avatarPlaceholder": "上传个人头像(头像比例不为1:1就扁了你哦)", + "admin.backgroundPlaceholder": "上传提问箱背景", + "admin.qrBackgroundPlaceholder": "上传二维码背景(颜色过深的背景会导致二维码无法识别)", + "admin.qrPreviewHint": "选择图片后预览将即时更新", + "admin.preview": "实时预览", + "admin.themePreset": "预设主题", + "admin.themeDefault": "默认", + "admin.themeDark": "暗夜", + "admin.themeOcean": "海洋", + "admin.themeSunset": "日落", + "admin.resetDefaults": "🔄 恢复默认值", + "admin.previewHint": "调整仅用于预览,最终效果点击「保存修改」后生效", + "admin.socialHint": "勾选后填写主页地址(无需 https://),未勾选的项将不会在前台显示。", + "admin.enableAcfun": "启用 AcFun", + "admin.enableBili": "启用 Bilibili", + "admin.enableWeibo": "启用 微博", + "admin.enableXiaohongshu": "启用 小红书", + "admin.enableBlog": "启用 个人博客", + "admin.registerOnly": "仅允许注册用户向我提问", + "admin.updateHarassment": "更新防骚扰设置", + "admin.globalBlockWords": "全局屏蔽词", + "admin.globalBlockWordsHint": "屏蔽词对提问、私聊(对方发给你的消息)与投票提交内容生效;三个设置页同步显示同一份设置,任意一处修改即时生效。", + "admin.updateBlockWords": "保存屏蔽词", // 审核严格度:提问 / 投票 / 私聊共用一份,判定规则见 internal/security/censor/policy.go "admin.moderationStrictness": "审核严格度", @@ -1055,36 +1058,36 @@ var zhCNDict = map[string]string{ "stats.weekday.7": "周日", // 箱主数据面板(/user/insights) - "insights.title": "数据面板", - "insights.heading": "你的数据面板", - "insights.lead": "这些数字只属于你自己:有多少人打开过你的提问箱、你收到了多少提问、还有多少没处理。全部是聚合结果,不含任何一位访客或提问者的身份。", - "insights.back": "返回设置", - "insights.entryTitle": "数据面板", - "insights.entryDesc": "看看有多少人来过你的提问箱、提问处理得怎么样", - "insights.summary": "一眼看懂", - "insights.noViews": "还没有访客记录。有人打开过你的提问箱之后,这里就会开始有数字。", - "insights.today": "今天", - "insights.week": "近 7 天", - "insights.month": "近 30 天", - "insights.newQuestions": "新提问", - "insights.pv": "次浏览", - "insights.uv": "位访客", - "insights.trendTitle": "近 30 天浏览趋势", - "insights.trendNote": "每根柱子是一天,浅色是浏览量,深色是当天去重后的访客数。把鼠标停在柱子上能看到当天的浏览 / 访客 / 提问。", - "insights.legendPV": "浏览量(PV)", - "insights.legendUV": "独立访客(按日去重)", - "insights.legendHover": "悬停查看当天明细", - "insights.hourTitle": "大家什么时候来", - "insights.hourNote": "近 30 天收到的提问分布在一天里的哪个时段,最亮的一根是你最忙的时段。", - "insights.funnelTitle": "提问处理情况", - "insights.funnelNote": "全部提问按「已回答 / 已读未答 / 未读未答」三类拆开。", + "insights.title": "数据面板", + "insights.heading": "你的数据面板", + "insights.lead": "这些数字只属于你自己:有多少人打开过你的提问箱、你收到了多少提问、还有多少没处理。全部是聚合结果,不含任何一位访客或提问者的身份。", + "insights.back": "返回设置", + "insights.entryTitle": "数据面板", + "insights.entryDesc": "看看有多少人来过你的提问箱、提问处理得怎么样", + "insights.summary": "一眼看懂", + "insights.noViews": "还没有访客记录。有人打开过你的提问箱之后,这里就会开始有数字。", + "insights.today": "今天", + "insights.week": "近 7 天", + "insights.month": "近 30 天", + "insights.newQuestions": "新提问", + "insights.pv": "次浏览", + "insights.uv": "位访客", + "insights.trendTitle": "近 30 天浏览趋势", + "insights.trendNote": "每根柱子是一天,浅色是浏览量,深色是当天去重后的访客数。把鼠标停在柱子上能看到当天的浏览 / 访客 / 提问。", + "insights.legendPV": "浏览量(PV)", + "insights.legendUV": "独立访客(按日去重)", + "insights.legendHover": "悬停查看当天明细", + "insights.hourTitle": "大家什么时候来", + "insights.hourNote": "近 30 天收到的提问分布在一天里的哪个时段,最亮的一根是你最忙的时段。", + "insights.funnelTitle": "提问处理情况", + "insights.funnelNote": "全部提问按「已回答 / 已读未答 / 未读未答」三类拆开。", "insights.funnelTotal": "全部提问", "insights.funnelAnswered": "已回答", "insights.funnelReadOnly": "已读未答", "insights.funnelUnread": "未读未答", - "insights.answerRate": "回答率:已回答 ÷ 全部提问", - "insights.privacyTitle": "关于这些数字", - "insights.privacyNote": "访客统计只记录「某天有人来过几次」。访客身份是每天更换盐值的 IP 哈希,不保存 IP、不保存 UA、也不保存访问了哪个页面。因此跨天的同一个人无法被关联,近 7 天 / 30 天的访客数是各天去重后相加 —— 同一个人连续几天来访会被记成几个人。箱主自己打开自己的提问箱不计入统计。", + "insights.answerRate": "回答率:已回答 ÷ 全部提问", + "insights.privacyTitle": "关于这些数字", + "insights.privacyNote": "访客统计只记录「某天有人来过几次」。访客身份是每天更换盐值的 IP 哈希,不保存 IP、不保存 UA、也不保存访问了哪个页面。因此跨天的同一个人无法被关联,近 7 天 / 30 天的访客数是各天去重后相加 —— 同一个人连续几天来访会被记成几个人。箱主自己打开自己的提问箱不计入统计。", "insights.insight.empty": "还没有任何数据。等你收到第一条提问,或者有人打开过你的提问箱,这里就会有变化。", "insights.insight.weekViews": "近 7 天有 %s 次浏览,%s 位访客。", diff --git a/internal/i18n/dict_zh_tw.go b/internal/i18n/dict_zh_tw.go index 11ed2aa..788411b 100644 --- a/internal/i18n/dict_zh_tw.go +++ b/internal/i18n/dict_zh_tw.go @@ -200,34 +200,37 @@ var zhTWDict = map[string]string{ "admin.export": "匯出資料", // 後台設定(補充) - "admin.oldPasswordHint": "修改密碼使用,留空則不修改", - "admin.newPasswordHint": "6-64 位,留空則不修改", - "admin.notifyEmail": "郵件", - "admin.useCustomFg": "啟用自訂前景色(否則自動比對)", - "admin.customFgHint": "僅在啟用時生效,否則顏色由系統自動產生", - "admin.avatarPlaceholder": "上傳個人頭像(頭像比例不為1:1就扁了你哦)", - "admin.backgroundPlaceholder": "上傳提問箱背景", - "admin.qrBackgroundPlaceholder": "上傳QR Code背景(顏色過深的背景會導致QR Code無法辨識)", - "admin.qrPreviewHint": "選擇圖片後預覽將即時更新", - "admin.preview": "即時預覽", - "admin.themePreset": "預設主題", - "admin.themeDefault": "預設", - "admin.themeDark": "暗夜", - "admin.themeOcean": "海洋", - "admin.themeSunset": "日落", - "admin.resetDefaults": "🔄 恢復預設值", - "admin.previewHint": "調整僅用於預覽,最終效果點擊「儲存修改」後生效", - "admin.socialHint": "勾選後填寫主頁地址(無需 https://),未勾選的項目將不會在前台顯示。", - "admin.enableAcfun": "啟用 AcFun", - "admin.enableBili": "啟用 Bilibili", - "admin.enableWeibo": "啟用 微博", - "admin.enableXiaohongshu": "啟用 小紅書", - "admin.enableBlog": "啟用 個人部落格", - "admin.registerOnly": "僅允許註冊使用者向我提問", - "admin.updateHarassment": "更新防騷擾設定", - "admin.globalBlockWords": "全局屏蔽詞", - "admin.globalBlockWordsHint": "屏蔽詞對提問、私聊(對方傳送給你的訊息)與投票提交內容生效;三個設定頁同步顯示同一份設定,任意一處修改即時生效。", - "admin.updateBlockWords": "儲存屏蔽詞", + "admin.oldPasswordHint": "修改密碼使用,留空則不修改", + "admin.newPasswordHint": "6-64 位,留空則不修改", + "admin.notifyEmail": "郵件", + "admin.notifyTelegram": "Telegram 通知", + "admin.notifyTelegramPlaceholder": "Telegram Chat ID(留空則不發送)", + "admin.notifyTelegramHint": "向 @userinfobot 發送任意訊息即可取得你的 Chat ID。需站方配置了 Telegram Bot。", + "admin.useCustomFg": "啟用自訂前景色(否則自動比對)", + "admin.customFgHint": "僅在啟用時生效,否則顏色由系統自動產生", + "admin.avatarPlaceholder": "上傳個人頭像(頭像比例不為1:1就扁了你哦)", + "admin.backgroundPlaceholder": "上傳提問箱背景", + "admin.qrBackgroundPlaceholder": "上傳QR Code背景(顏色過深的背景會導致QR Code無法辨識)", + "admin.qrPreviewHint": "選擇圖片後預覽將即時更新", + "admin.preview": "即時預覽", + "admin.themePreset": "預設主題", + "admin.themeDefault": "預設", + "admin.themeDark": "暗夜", + "admin.themeOcean": "海洋", + "admin.themeSunset": "日落", + "admin.resetDefaults": "🔄 恢復預設值", + "admin.previewHint": "調整僅用於預覽,最終效果點擊「儲存修改」後生效", + "admin.socialHint": "勾選後填寫主頁地址(無需 https://),未勾選的項目將不會在前台顯示。", + "admin.enableAcfun": "啟用 AcFun", + "admin.enableBili": "啟用 Bilibili", + "admin.enableWeibo": "啟用 微博", + "admin.enableXiaohongshu": "啟用 小紅書", + "admin.enableBlog": "啟用 個人部落格", + "admin.registerOnly": "僅允許註冊使用者向我提問", + "admin.updateHarassment": "更新防騷擾設定", + "admin.globalBlockWords": "全局屏蔽詞", + "admin.globalBlockWordsHint": "屏蔽詞對提問、私聊(對方傳送給你的訊息)與投票提交內容生效;三個設定頁同步顯示同一份設定,任意一處修改即時生效。", + "admin.updateBlockWords": "儲存屏蔽詞", // 審核嚴格度:提問 / 投票 / 私聊共用一份,判定規則見 internal/security/censor/policy.go "admin.moderationStrictness": "審核嚴格度", @@ -1054,36 +1057,36 @@ var zhTWDict = map[string]string{ "stats.weekday.7": "週日", // 箱主數據面板(/user/insights) - "insights.title": "數據面板", - "insights.heading": "你的數據面板", - "insights.lead": "這些數字只屬於你自己:有多少人開啟過你的提問箱、你收到了多少提問、還有多少沒處理。全部是彙總結果,不含任何一位訪客或提問者的身分。", - "insights.back": "返回設定", - "insights.entryTitle": "數據面板", - "insights.entryDesc": "看看有多少人來過你的提問箱、提問處理得怎麼樣", - "insights.summary": "一眼看懂", - "insights.noViews": "還沒有訪客紀錄。有人開啟過你的提問箱之後,這裡就會開始有數字。", - "insights.today": "今天", - "insights.week": "近 7 天", - "insights.month": "近 30 天", - "insights.newQuestions": "新提問", - "insights.pv": "次瀏覽", - "insights.uv": "位訪客", - "insights.trendTitle": "近 30 天瀏覽趨勢", - "insights.trendNote": "每根柱子是一天,淺色是瀏覽量,深色是當天去重後的訪客數。把游標停在柱子上能看到當天的瀏覽/訪客/提問。", - "insights.legendPV": "瀏覽量(PV)", - "insights.legendUV": "獨立訪客(按日去重)", - "insights.legendHover": "停留查看當天明細", - "insights.hourTitle": "大家什麼時候來", - "insights.hourNote": "近 30 天收到的提問分布在一天裡的哪個時段,最亮的一根是你最忙的時段。", - "insights.funnelTitle": "提問處理情況", - "insights.funnelNote": "全部提問按「已回答/已讀未答/未讀未答」三類拆開。", + "insights.title": "數據面板", + "insights.heading": "你的數據面板", + "insights.lead": "這些數字只屬於你自己:有多少人開啟過你的提問箱、你收到了多少提問、還有多少沒處理。全部是彙總結果,不含任何一位訪客或提問者的身分。", + "insights.back": "返回設定", + "insights.entryTitle": "數據面板", + "insights.entryDesc": "看看有多少人來過你的提問箱、提問處理得怎麼樣", + "insights.summary": "一眼看懂", + "insights.noViews": "還沒有訪客紀錄。有人開啟過你的提問箱之後,這裡就會開始有數字。", + "insights.today": "今天", + "insights.week": "近 7 天", + "insights.month": "近 30 天", + "insights.newQuestions": "新提問", + "insights.pv": "次瀏覽", + "insights.uv": "位訪客", + "insights.trendTitle": "近 30 天瀏覽趨勢", + "insights.trendNote": "每根柱子是一天,淺色是瀏覽量,深色是當天去重後的訪客數。把游標停在柱子上能看到當天的瀏覽/訪客/提問。", + "insights.legendPV": "瀏覽量(PV)", + "insights.legendUV": "獨立訪客(按日去重)", + "insights.legendHover": "停留查看當天明細", + "insights.hourTitle": "大家什麼時候來", + "insights.hourNote": "近 30 天收到的提問分布在一天裡的哪個時段,最亮的一根是你最忙的時段。", + "insights.funnelTitle": "提問處理情況", + "insights.funnelNote": "全部提問按「已回答/已讀未答/未讀未答」三類拆開。", "insights.funnelTotal": "全部提問", "insights.funnelAnswered": "已回答", "insights.funnelReadOnly": "已讀未答", "insights.funnelUnread": "未讀未答", - "insights.answerRate": "回答率:已回答 ÷ 全部提問", - "insights.privacyTitle": "關於這些數字", - "insights.privacyNote": "訪客統計只記錄「某天有人來過幾次」。訪客身分是每天更換鹽值的 IP 雜湊,不保存 IP、不保存 UA、也不保存造訪了哪個頁面。因此跨天的同一個人無法被關聯,近 7 天/30 天的訪客數是各天去重後相加 —— 同一個人連續幾天造訪會被記成幾個人。箱主自己開啟自己的提問箱不計入統計。", + "insights.answerRate": "回答率:已回答 ÷ 全部提問", + "insights.privacyTitle": "關於這些數字", + "insights.privacyNote": "訪客統計只記錄「某天有人來過幾次」。訪客身分是每天更換鹽值的 IP 雜湊,不保存 IP、不保存 UA、也不保存造訪了哪個頁面。因此跨天的同一個人無法被關聯,近 7 天/30 天的訪客數是各天去重後相加 —— 同一個人連續幾天造訪會被記成幾個人。箱主自己開啟自己的提問箱不計入統計。", "insights.insight.empty": "還沒有任何資料。等你收到第一條提問,或有人開啟過你的提問箱,這裡就會有變化。", "insights.insight.weekViews": "近 7 天有 %s 次瀏覽,%s 位訪客。", diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..d478b8f --- /dev/null +++ b/internal/notify/notify.go @@ -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 +} diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go new file mode 100644 index 0000000..d1f5724 --- /dev/null +++ b/internal/notify/notify_test.go @@ -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 +} diff --git a/route/question/follow_up.go b/route/question/follow_up.go index f406e57..367354c 100644 --- a/route/question/follow_up.go +++ b/route/question/follow_up.go @@ -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")) diff --git a/route/question/page.go b/route/question/page.go index 3bf2002..36776b6 100644 --- a/route/question/page.go +++ b/route/question/page.go @@ -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) diff --git a/route/question/question.go b/route/question/question.go index 5e7a637..c866480 100644 --- a/route/question/question.go +++ b/route/question/question.go @@ -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、内容照常显示, diff --git a/route/user/profile.go b/route/user/profile.go index eeba127..ad83afd 100644 --- a/route/user/profile.go +++ b/route/user/profile.go @@ -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") diff --git a/route/vote/vote.go b/route/vote/vote.go index 7ff78e4..41429a3 100644 --- a/route/vote/vote.go +++ b/route/vote/vote.go @@ -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) diff --git a/templates/user/profile.html b/templates/user/profile.html index 9c5c76c..580594a 100644 --- a/templates/user/profile.html +++ b/templates/user/profile.html @@ -595,6 +595,13 @@ {{t .LangCode "admin.notifyEmail"}} +
+ + +
{{t .LangCode "admin.notifyTelegramHint"}}
+