From e8c2e7867ea16313d43bededa47d0db322214339 Mon Sep 17 00:00:00 2001 From: smail Date: Mon, 28 Sep 2026 22:03:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=90=84=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E6=8E=A8=E9=80=81=E6=8E=88=E6=9D=83=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E5=89=8D=E6=A3=80=E6=9F=A5=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/plugin_telegram.go | 169 ++++++++ backend/controllers/plugin_v1.go | 5 + backend/hooks/base.go | 27 +- backend/listen/http_server/api_v1_compat.go | 19 +- backend/services/plugin/config.go | 26 +- backend/services/plugin/telegram_binding.go | 442 ++++++++++++++++++++ 6 files changed, 661 insertions(+), 27 deletions(-) create mode 100644 backend/controllers/plugin_telegram.go create mode 100644 backend/services/plugin/telegram_binding.go diff --git a/backend/controllers/plugin_telegram.go b/backend/controllers/plugin_telegram.go new file mode 100644 index 0000000..6954d83 --- /dev/null +++ b/backend/controllers/plugin_telegram.go @@ -0,0 +1,169 @@ +package controllers + +import ( + "encoding/json" + "net/http" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/dto/response" + "git.pcux.cn/xuans/Smail/hooks" + "git.pcux.cn/xuans/Smail/hooks/framework" + "git.pcux.cn/xuans/Smail/services/access" + pluginservice "git.pcux.cn/xuans/Smail/services/plugin" + appcontext "git.pcux.cn/xuans/Smail/utils/context" +) + +func telegramRequestService(ctx *appcontext.Context, w http.ResponseWriter, recent bool) *pluginservice.ConfigService { + if !authorizedPluginUser(ctx) { + writeConfigAccessError(w, &access.Denial{Code: access.CodeAccessDenied}) + return nil + } + if recent && !ctx.RecentAuth { + writeConfigAccessError(w, &access.Denial{Code: access.CodeRecentAuthRequired}) + return nil + } + service := hooks.PluginConfigService() + if service == nil { response.NewErrorResponse(response.ServerError, "通知配置服务不可用", "").FPrint(w) } + return service +} + +func telegramPublicSettings(service *pluginservice.ConfigService, status pluginservice.UserSettingsStatus) map[string]any { + settings := status.Settings + return map[string]any{ + "version": status.Version, "bound": settings.ChatID != "", "displayName": settings.DisplayName, + "needsRebind": service.TelegramBindingNeedsRebind(settings), + "enabled": settings.ChatID != "" && settings.Verified && !settings.Disabled, + "showContent": settings.ShowContent, "spoilerContent": settings.SpoilerContent, + "sendAttachments": settings.SendAttachments, "disableLinkPreview": settings.DisableLinkPreview, + } +} + +func writeTelegramUserSettings(w http.ResponseWriter, service *pluginservice.ConfigService, userID int, status pluginservice.UserSettingsStatus) { + data := telegramPublicSettings(service, status) + data["synced"] = hooks.SyncTelegramUserSettings(userID, status.Settings) == nil + response.NewSuccessResponse(data).FPrint(w) +} + +// TelegramUserSettings 的接收身份只能来自授权流程,页面仅维护本人偏好。 +func TelegramUserSettings(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) { + service := telegramRequestService(ctx, w, req.Method != http.MethodGet) + if service == nil { return } + if req.Method == http.MethodGet { + status, err := service.GetOwnTelegramSettings(ctx.UserID) + if err != nil { writeTelegramSettingsError(w, err); return } + data := telegramPublicSettings(service, status) + configured, message := service.TelegramBindingAvailability() + _, running := hooks.GetHook("telegram_push") + if configured && !running { message = "Telegram 推送插件未运行,请联系超级管理员" } + data["configured"], data["running"], data["available"], data["message"] = configured, running, configured && running, message + response.NewSuccessResponse(data).FPrint(w) + return + } + if req.Method != http.MethodPut && req.Method != http.MethodDelete { + w.Header().Set("Allow", "GET, PUT, DELETE") + http.Error(w, "请求方法不支持", http.StatusMethodNotAllowed) + return + } + var input struct { + Version uint64 `json:"version"` + Enabled bool `json:"enabled"` + ShowContent bool `json:"showContent"` + SpoilerContent bool `json:"spoilerContent"` + SendAttachments bool `json:"sendAttachments"` + DisableLinkPreview bool `json:"disableLinkPreview"` + } + if !decodePluginJSONBody(w, req, &input) { return } + preferences := pluginservice.TelegramUserSettings{Disabled: !input.Enabled, ShowContent: input.ShowContent, + SpoilerContent: input.SpoilerContent, SendAttachments: input.SendAttachments, DisableLinkPreview: input.DisableLinkPreview} + status, err := service.UpdateOwnTelegramPreferences(ctx.UserID, preferences, req.Method == http.MethodDelete, input.Version) + if err != nil { writeTelegramSettingsError(w, err); return } + writeTelegramUserSettings(w, service, ctx.UserID, status) +} + +func TelegramBinding(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) { + var input struct { Action string `json:"action"`; Ticket string `json:"ticket"` } + if !decodePluginJSONBody(w, req, &input) { return } + service := telegramRequestService(ctx, w, input.Action == "begin" || input.Action == "confirm") + if service == nil { return } + actor, err := notificationBindingActor(ctx) + if err != nil { writeTelegramSettingsError(w, pluginservice.ErrTelegramBinding); return } + var result any + switch input.Action { + case "begin": + if _, running := hooks.GetHook("telegram_push"); !running { + writeTelegramSettingsError(w, pluginservice.TelegramNotice("Telegram 推送插件未运行,请联系超级管理员")) + return + } + result, err = service.BeginTelegramBinding(req.Context(), actor) + case "status": + result, err = service.GetTelegramBinding(actor, input.Ticket) + case "cancel": + err = service.CancelTelegramBinding(actor, input.Ticket) + case "confirm": + var status pluginservice.UserSettingsStatus + status, err = service.ConfirmTelegramBinding(actor, input.Ticket) + if err == nil { writeTelegramUserSettings(w, service, ctx.UserID, status); return } + default: + err = pluginservice.TelegramNotice("不支持的绑定操作") + } + if err != nil { writeTelegramSettingsError(w, err); return } + response.NewSuccessResponse(result).FPrint(w) +} + +func TelegramCallbackSettings(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) { + if ctx == nil || ctx.UserID <= 0 || ctx.EffectiveRole() != access.RoleSuperAdmin { + writeConfigAccessError(w, &access.Denial{Code: access.CodeAccessDenied}) + return + } + service := telegramRequestService(ctx, w, req.Method == http.MethodPost) + if service == nil { return } + var status pluginservice.TelegramCallbackStatus + var err error + switch req.Method { + case http.MethodGet: + status, err = service.TelegramCallbackState(req.Context()) + case http.MethodPost: + status, err = service.ConfigureTelegramCallback(req.Context(), ctx.UserID) + default: + w.Header().Set("Allow", "GET, POST") + http.Error(w, "请求方法不支持", http.StatusMethodNotAllowed) + return + } + if err != nil { writeTelegramSettingsError(w, err); return } + response.NewSuccessResponse(status).FPrint(w) +} + +// TelegramBindingCallback 不使用网页登录会话,只接受带独立回调密钥的 Telegram 请求。 +func TelegramBindingCallback(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + service := hooks.PluginConfigService() + if !config.IsSetupReady() || service == nil { http.Error(w, "服务未就绪", http.StatusServiceUnavailable); return } + var update pluginservice.TelegramBindingUpdate + decoder := json.NewDecoder(http.MaxBytesReader(w, req.Body, 64<<10)) + if decoder.Decode(&update) != nil { http.Error(w, "回调格式无效", http.StatusBadRequest); return } + if err := service.ClaimTelegramBinding(req.Header.Get("X-Telegram-Bot-Api-Secret-Token"), update); err != nil { + http.Error(w, "回调认证失败", http.StatusForbidden) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func TestBoundTelegramMessage(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) { + service := telegramRequestService(ctx, w, true) + if service == nil { return } + status, err := service.GetOwnTelegramSettings(ctx.UserID) + if err != nil || !service.TelegramCanNotify(status.Settings) { + writeTelegramSettingsError(w, pluginservice.TelegramNotice("请先绑定本人 Telegram 并启用通知")) + return + } + hook, _ := hooks.GetHook("telegram_push") + provider, ok := hook.(framework.TestMessageProvider) + if !ok { writeTelegramSettingsError(w, pluginservice.TelegramNotice("Telegram 推送插件未运行,请联系超级管理员")); return } + if err := service.ReserveTelegramTest(ctx.UserID); err != nil { writeTelegramSettingsError(w, err); return } + if provider.TestMessage(pluginservice.TelegramDeliverySettings(status.Settings)) != nil { + writeTelegramSettingsError(w, pluginservice.TelegramNotice("测试消息发送失败,请确认没有屏蔽机器人")) + return + } + response.NewSuccessResponse(nil).FPrint(w) +} diff --git a/backend/controllers/plugin_v1.go b/backend/controllers/plugin_v1.go index 0496c50..8504040 100644 --- a/backend/controllers/plugin_v1.go +++ b/backend/controllers/plugin_v1.go @@ -164,6 +164,11 @@ func decodePluginJSONBody(w http.ResponseWriter, req *http.Request, target any) } func writeTelegramSettingsError(w http.ResponseWriter, err error) { + var notice pluginservice.TelegramNotice + if errors.Is(err, pluginservice.ErrTelegramBinding) || errors.As(err, ¬ice) { + response.NewErrorResponse(response.ParamsError, err.Error(), "").FPrint(w) + return + } if errors.Is(err, pluginservice.ErrInvalidTelegramChatID) { response.NewErrorResponse(response.ParamsError, "Telegram 聊天 ID 格式不正确", "").FPrint(w) return diff --git a/backend/hooks/base.go b/backend/hooks/base.go index f971944..a966a70 100644 --- a/backend/hooks/base.go +++ b/backend/hooks/base.go @@ -118,6 +118,19 @@ func (h *HookSender) callHook(ctx *context.Context, endpoint string, dto framewo } func (h *HookSender) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email, ue []*models.UserEmail) { + if h.name == "telegram_push" { + if pluginConfigService == nil { return } + allowed := make([]*models.UserEmail, 0, len(ue)) + for _, recipient := range ue { + if recipient == nil || recipient.UserID <= 0 { continue } + status, err := pluginConfigService.GetOwnTelegramSettings(recipient.UserID) + if err != nil || !pluginConfigService.TelegramCanNotify(status.Settings) { continue } + if h.ConfigureUserSettings(recipient.UserID, pluginservice.TelegramDeliverySettings(status.Settings)) != nil { continue } + allowed = append(allowed, recipient) + } + if len(allowed) == 0 { return } + ue = allowed + } if h.name == "wechat_push" { if pluginConfigService == nil { return @@ -488,11 +501,8 @@ func SyncTelegramUserSettings(userID int, settings pluginservice.TelegramUserSet if !ok { return errors.New("telegram plugin does not support user settings") } - payload, err := json.Marshal(settings) - if err != nil { - return err - } - return configurer.ConfigureUserSettings(userID, payload) + if pluginConfigService == nil || !pluginConfigService.TelegramCanNotify(settings) { settings.Disabled = true } + return configurer.ConfigureUserSettings(userID, pluginservice.TelegramDeliverySettings(settings)) } func syncTelegramUserSettings(logicalID string, hook *HookSender) error { if logicalID != "telegram_push" { @@ -518,11 +528,8 @@ func syncTelegramUserSetting(hook *HookSender, userID int, settings pluginservic if hook == nil || userID <= 0 { return errors.New("invalid plugin user settings target") } - payload, err := json.Marshal(settings) - if err != nil { - return err - } - return hook.ConfigureUserSettings(userID, payload) + if pluginConfigService == nil || !pluginConfigService.TelegramCanNotify(settings) { settings.Disabled = true } + return hook.ConfigureUserSettings(userID, pluginservice.TelegramDeliverySettings(settings)) } func pluginInstanceForStartup(logicalID string) (config.PluginInstance, uint64, bool) { manager := config.CurrentManager() diff --git a/backend/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go index 732ebc9..e636aaf 100644 --- a/backend/listen/http_server/api_v1_compat.go +++ b/backend/listen/http_server/api_v1_compat.go @@ -164,6 +164,9 @@ func registerV1TOTPRoute(mux *http.ServeMux) { } func registerV1PluginRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/plugin/settings/telegram_push/binding", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.TelegramBinding)))) + mux.HandleFunc("/api/v1/plugin/settings/telegram_push/callback", limitBody(4<<10, contextIterceptor(controllers.TelegramCallbackSettings))) + mux.HandleFunc("/api/v1/plugin/binding/telegram/callback", onlyMethod(http.MethodPost, limitBody(64<<10, controllers.TelegramBindingCallback))) mux.HandleFunc("/api/v1/plugin/settings/wechat_push/user", limitBody(4<<10, contextIterceptor(controllers.WeChatUserSettings))) mux.HandleFunc("/api/v1/plugin/settings/wechat_push/binding", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.WeChatBinding)))) mux.HandleFunc("/api/v1/plugin/settings/wechat_push/test", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.TestWeChatMessage)))) @@ -171,21 +174,9 @@ func registerV1PluginRoutes(mux *http.ServeMux) { for _, id := range []string{"telegram_push", "wechat_push"} { mux.HandleFunc("/api/v1/plugin/secrets/"+id, limitBody(16<<10, contextIterceptor(controllers.PluginSecret))) } - getUserSettings := contextIterceptor(controllers.GetTelegramUserSettings) - updateUserSettings := limitBody(16<<10, contextIterceptor(controllers.UpdateTelegramUserSettings)) - mux.HandleFunc("/api/v1/plugin/settings/telegram_push/user", func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - getUserSettings.ServeHTTP(w, r) - case http.MethodPut: - updateUserSettings.ServeHTTP(w, r) - default: - w.Header().Set("Allow", "GET, PUT") - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } - }) + mux.HandleFunc("/api/v1/plugin/settings/telegram_push/user", limitBody(4<<10, contextIterceptor(controllers.TelegramUserSettings))) mux.HandleFunc("/api/v1/plugin/settings/telegram_push/bot", onlyMethod(http.MethodGet, contextIterceptor(controllers.GetTelegramBotInfo))) - mux.HandleFunc("/api/v1/plugin/settings/telegram_push/test", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.TestTelegramMessage)))) + mux.HandleFunc("/api/v1/plugin/settings/telegram_push/test", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.TestBoundTelegramMessage)))) mux.HandleFunc("/api/v1/plugin/settings/", contextIterceptor(rewriteV1PathPrefix("/api/plugin/settings/", controllers.SettingsHtml))) registerV1MethodRoute(mux, "/api/v1/plugin/list", "/api/plugin/list", http.MethodGet, 0, controllers.GetPluginList) } diff --git a/backend/services/plugin/config.go b/backend/services/plugin/config.go index b1e62f5..e0e06df 100644 --- a/backend/services/plugin/config.go +++ b/backend/services/plugin/config.go @@ -52,6 +52,10 @@ type SecretStatus struct { // TelegramUserSettings 是 Telegram 插件唯一允许由用户维护的设置。 type TelegramUserSettings struct { ChatID string `json:"chatId"` + BotID string `json:"botId,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Verified bool `json:"verified,omitempty"` + Disabled bool `json:"disabled,omitempty"` ShowContent bool `json:"showContent"` SpoilerContent bool `json:"spoilerContent"` SendAttachments bool `json:"sendAttachments"` @@ -111,6 +115,9 @@ type ConfigService struct { wechatBindings map[string]weChatBinding weChatTestMu sync.Mutex weChatTestAttempts map[int]time.Time + telegramBindings map[string]telegramBinding + telegramAttempts map[string]time.Time + telegramSetupMu sync.Mutex } func NewConfigService(engine *xorm.Engine, vault *credential.Vault) (*ConfigService, error) { @@ -310,8 +317,8 @@ func (s *ConfigService) openSecretForPlugin(pluginID, fieldName string) (string, } func (s *ConfigService) GetOwnTelegramSettings(actorUserID int) (UserSettingsStatus, error) { - if actorUserID <= 0 { - return UserSettingsStatus{}, ErrPluginStoreUnavailable + if _, err := s.activeNotificationUser(actorUserID); err != nil { + return UserSettingsStatus{}, err } return s.getTelegramSettings(actorUserID) } @@ -377,11 +384,21 @@ func (s *ConfigService) UpdateOwnTelegramSettings(actorUserID int, settings Tele } - return s.updateTelegramSettings(actorUserID, settings, expectedVersion) + // 旧写入口不能伪造授权身份,也不能把任意聊天 ID 作为接收人。 + current, err := s.GetOwnTelegramSettings(actorUserID) + if err != nil { return UserSettingsStatus{}, err } + if settings.Verified || settings.BotID != "" || settings.DisplayName != "" || + (settings.ChatID != "" && settings.ChatID != current.Settings.ChatID) { + return UserSettingsStatus{}, ErrTelegramBinding + } + return s.UpdateOwnTelegramPreferences(actorUserID, settings, settings.ChatID == "", expectedVersion) } // UpdateTelegramSettingsForUser 查询目标账户的当前角色后才进行跨用户授权。 func (s *ConfigService) UpdateTelegramSettingsForUser(actor models.User, targetUserID int, settings TelegramUserSettings, expectedVersion uint64) (UserSettingsStatus, error) { + if settings.ChatID != "" || settings.Verified || settings.BotID != "" || settings.DisplayName != "" { + return UserSettingsStatus{}, ErrTelegramBinding + } if s == nil || !accountgroup.CanAccess(s.engine, actor.ID, actor.EffectiveRole(), targetUserID) { return UserSettingsStatus{}, &access.Denial{Code: access.CodeAccessDenied} } @@ -404,6 +421,9 @@ func (s *ConfigService) UpdateTelegramSettingsForUser(actor models.User, targetU } func (s *ConfigService) PreviewBulkTelegramSettings(actor models.User, targetUserIDs []int, settings TelegramUserSettings, overwrite bool) (BulkPreview, error) { + if settings.ChatID != "" || settings.Verified || settings.BotID != "" || settings.DisplayName != "" { + return BulkPreview{}, ErrTelegramBinding + } if actor.ID <= 0 || actor.EffectiveRole() != access.RoleSuperAdmin { return BulkPreview{}, ErrPluginBulkPreviewDenied } diff --git a/backend/services/plugin/telegram_binding.go b/backend/services/plugin/telegram_binding.go new file mode 100644 index 0000000..cb5bf28 --- /dev/null +++ b/backend/services/plugin/telegram_binding.go @@ -0,0 +1,442 @@ +package plugin + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/services/access" + "git.pcux.cn/xuans/Smail/utils/credential" +) + +const TelegramCallbackPath = "/api/v1/plugin/binding/telegram/callback" + +var ErrTelegramBinding = errors.New("Telegram 绑定已失效,请重新授权") +var telegramTokenPattern = regexp.MustCompile(`^([1-9][0-9]{0,18}):[A-Za-z0-9_-]{20,100}$`) +var telegramUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9_]{5,32}$`) + +type TelegramNotice string + +func (notice TelegramNotice) Error() string { return string(notice) } + +type TelegramBindingStatus struct { + Ticket string `json:"ticket,omitempty"` + URL string `json:"url,omitempty"` + Phase string `json:"phase"` + DisplayName string `json:"displayName,omitempty"` + ExpiresAt time.Time `json:"expiresAt"` +} + +type telegramBinding struct { + Actor NotificationBindingActor + Version uint64 + Fingerprint string + CreatedAt time.Time + Settings TelegramUserSettings + Status TelegramBindingStatus +} + +type telegramBindingConfig struct { + Token string + BotID string + Callback string + Secret string + Fingerprint string +} + +func (s *ConfigService) telegramConfiguration() (telegramBindingConfig, error) { + var result telegramBindingConfig + manager, current := config.CurrentManager(), config.CurrentConfig() + if s == nil || s.vault == nil || manager == nil || current == nil { + return result, TelegramNotice("请联系超级管理员配置 Telegram 机器人") + } + instance, found := manager.Snapshot().Plugins.Plugins[telegramPluginID] + if !found || !instance.Enabled { + return result, TelegramNotice("请联系超级管理员启用 Telegram 通知") + } + var settings struct { + WebDomain string `json:"webDomain"` + } + domain := strings.TrimSpace(current.WebDomain) + parsed, err := url.Parse("https://" + domain) + if json.Unmarshal(instance.Settings, &settings) != nil || err != nil || domain == "" || parsed.Host != domain || + parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" || + !strings.EqualFold(settings.WebDomain, domain) { + return result, TelegramNotice("机器人网页域名与后台域名不一致,请联系超级管理员") + } + token, err := s.openSecretForPlugin(telegramPluginID, "botToken") + parts := telegramTokenPattern.FindStringSubmatch(token) + if err != nil || len(parts) != 2 { + return result, TelegramNotice("请联系超级管理员配置有效的机器人令牌") + } + fingerprint, err := s.vault.Fingerprint("telegram-binding\x00" + token + "\x00" + domain) + if err != nil { + return result, ErrPluginStoreUnavailable + } + secret, err := s.vault.Fingerprint("telegram-callback-secret\x00" + token + "\x00" + domain) + if err != nil { + return result, ErrPluginStoreUnavailable + } + // 回调地址只携带配置标识,不包含机器人令牌或请求认证密钥。 + return telegramBindingConfig{Token: token, BotID: parts[1], Secret: secret, Fingerprint: fingerprint, + Callback: "https://" + domain + TelegramCallbackPath + "?v=" + fingerprint[:16]}, nil +} + +func requestTelegramBindingJSON(ctx context.Context, configuration telegramBindingConfig, method string, input any, target any) error { + if method != "getMe" && method != "getWebhookInfo" && method != "setWebhook" { + return ErrTelegramBinding + } + body, err := json.Marshal(input) + if err != nil { + return ErrTelegramBinding + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.telegram.org/bot"+configuration.Token+"/"+method, bytes.NewReader(body)) + if err != nil { + return ErrTelegramBinding + } + request.Header.Set("Content-Type", "application/json") + client := credential.NewRestrictedHTTPClientForTargets(10_000, []credential.NetworkTarget{{Scheme: "https", Host: "api.telegram.org", Port: 443}}) + response, err := client.Do(request) + if err != nil { + return TelegramNotice("无法连接 Telegram,请稍后重试") + } + defer response.Body.Close() + data, err := io.ReadAll(io.LimitReader(response.Body, (64<<10)+1)) + var envelope struct { + OK bool `json:"ok"` + Result json.RawMessage `json:"result"` + } + if err != nil || len(data) > 64<<10 || response.StatusCode != http.StatusOK || json.Unmarshal(data, &envelope) != nil || !envelope.OK { + // 不把含令牌的请求地址、外部错误正文或回调密钥写入日志和响应。 + return TelegramNotice("Telegram 请求失败,请检查系统机器人配置") + } + if target != nil && json.Unmarshal(envelope.Result, target) != nil { + return ErrTelegramBinding + } + return nil +} + +type TelegramCallbackStatus struct { + Ready bool `json:"ready"` + Message string `json:"message"` +} + +func (s *ConfigService) TelegramBindingAvailability() (bool, string) { + if _, err := s.telegramConfiguration(); err != nil { return false, err.Error() } + return true, "" +} + +func (s *ConfigService) TelegramCallbackState(ctx context.Context) (TelegramCallbackStatus, error) { + configuration, err := s.telegramConfiguration() + if err != nil { + return TelegramCallbackStatus{}, err + } + var info struct { + URL string `json:"url"` + } + if err := requestTelegramBindingJSON(ctx, configuration, "getWebhookInfo", struct{}{}, &info); err != nil { + return TelegramCallbackStatus{}, err + } + if info.URL != configuration.Callback { + return TelegramCallbackStatus{Message: "请联系超级管理员启用或更新 Telegram 授权回调"}, nil + } + return TelegramCallbackStatus{Ready: true}, nil +} + +func (s *ConfigService) ConfigureTelegramCallback(ctx context.Context, actorUserID int) (TelegramCallbackStatus, error) { + actor, err := s.activeNotificationUser(actorUserID) + if err != nil || actor.EffectiveRole() != access.RoleSuperAdmin { + return TelegramCallbackStatus{}, &access.Denial{Code: access.CodeAccessDenied} + } + s.telegramSetupMu.Lock() + defer s.telegramSetupMu.Unlock() + configuration, err := s.telegramConfiguration() + if err != nil { + return TelegramCallbackStatus{}, err + } + var info struct { + URL string `json:"url"` + } + if err := requestTelegramBindingJSON(ctx, configuration, "getWebhookInfo", struct{}{}, &info); err != nil { + return TelegramCallbackStatus{}, err + } + if info.URL != "" && info.URL != configuration.Callback { + existing, parseErr := url.Parse(info.URL) + wanted, _ := url.Parse(configuration.Callback) + if parseErr != nil || existing.Scheme != "https" || existing.Host != wanted.Host || existing.User != nil || + existing.Path != TelegramCallbackPath || existing.Fragment != "" || len(existing.Query()) != 1 || existing.Query().Get("v") == "" { + return TelegramCallbackStatus{}, TelegramNotice("机器人已有其他回调,未覆盖;请使用专用机器人或先确认原应用的接入方式") + } + } + input := map[string]any{"url": configuration.Callback, "secret_token": configuration.Secret, + "allowed_updates": []string{"message"}, "drop_pending_updates": false, "max_connections": 4} + var accepted bool + if err := requestTelegramBindingJSON(ctx, configuration, "setWebhook", input, &accepted); err != nil || !accepted { + return TelegramCallbackStatus{}, TelegramNotice("Telegram 授权回调设置失败") + } + current, err := s.telegramConfiguration() + if err != nil || current.Fingerprint != configuration.Fingerprint { + return TelegramCallbackStatus{}, TelegramNotice("系统配置已变更,请重新设置授权回调") + } + return s.TelegramCallbackState(ctx) +} + +func (s *ConfigService) reserveTelegramAttempt(userID int, action string, interval time.Duration) error { + if _, err := s.activeNotificationUser(userID); err != nil { + return err + } + s.bindingMu.Lock() + defer s.bindingMu.Unlock() + now := s.currentTime() + if s.telegramAttempts == nil { + s.telegramAttempts = make(map[string]time.Time) + } + for key, expires := range s.telegramAttempts { + if !expires.After(now) { + delete(s.telegramAttempts, key) + } + } + key := action + ":" + strconv.Itoa(userID) + if _, found := s.telegramAttempts[key]; found || len(s.telegramAttempts) >= 4096 { + return TelegramNotice("操作过于频繁,请稍后重试") + } + s.telegramAttempts[key] = now.Add(interval) + return nil +} + +func (s *ConfigService) BeginTelegramBinding(ctx context.Context, actor NotificationBindingActor) (TelegramBindingStatus, error) { + if err := s.validateBindingActor(actor); err != nil { + return TelegramBindingStatus{}, ErrTelegramBinding + } + if err := s.reserveTelegramAttempt(actor.UserID, "begin", 10*time.Second); err != nil { + return TelegramBindingStatus{}, err + } + configuration, err := s.telegramConfiguration() + if err != nil { + return TelegramBindingStatus{}, err + } + callback, err := s.TelegramCallbackState(ctx) + if err != nil { + return TelegramBindingStatus{}, err + } + if !callback.Ready { + return TelegramBindingStatus{}, TelegramNotice(callback.Message) + } + var me struct { + ID int64 `json:"id"` + IsBot bool `json:"is_bot"` + Username string `json:"username"` + } + if err := requestTelegramBindingJSON(ctx, configuration, "getMe", struct{}{}, &me); err != nil { + return TelegramBindingStatus{}, err + } + if !me.IsBot || strconv.FormatInt(me.ID, 10) != configuration.BotID || !telegramUsernamePattern.MatchString(me.Username) { + return TelegramBindingStatus{}, TelegramNotice("机器人身份无效,请联系超级管理员") + } + current, err := s.GetOwnTelegramSettings(actor.UserID) + if err != nil { + return TelegramBindingStatus{}, err + } + var nonce [32]byte + if _, err := rand.Read(nonce[:]); err != nil { + return TelegramBindingStatus{}, ErrPluginStoreUnavailable + } + ticket := base64.RawURLEncoding.EncodeToString(nonce[:]) + now := s.currentTime() + status := TelegramBindingStatus{Ticket: ticket, Phase: "waiting", ExpiresAt: now.Add(5 * time.Minute), + URL: "https://t.me/" + me.Username + "?start=smail_" + ticket} + s.bindingMu.Lock() + defer s.bindingMu.Unlock() + if s.telegramBindings == nil { + s.telegramBindings = make(map[string]telegramBinding) + } + for key, binding := range s.telegramBindings { + if !binding.Status.ExpiresAt.After(now) || binding.Actor.UserID == actor.UserID { + delete(s.telegramBindings, key) + } + } + if len(s.telegramBindings) >= 2048 { + return TelegramBindingStatus{}, TelegramNotice("绑定请求较多,请稍后重试") + } + s.telegramBindings[ticket] = telegramBinding{Actor: actor, Version: current.Version, Fingerprint: configuration.Fingerprint, + CreatedAt: now, Settings: TelegramUserSettings{BotID: configuration.BotID, DisableLinkPreview: true}, Status: status} + return status, nil +} + +func (s *ConfigService) GetTelegramBinding(actor NotificationBindingActor, ticket string) (TelegramBindingStatus, error) { + if err := s.validateBindingActor(actor); err != nil { + return TelegramBindingStatus{}, ErrTelegramBinding + } + s.bindingMu.Lock() + defer s.bindingMu.Unlock() + binding, found := s.telegramBindings[ticket] + if !found || binding.Actor != actor || !binding.Status.ExpiresAt.After(s.currentTime()) { + return TelegramBindingStatus{}, ErrTelegramBinding + } + return binding.Status, nil +} + +func (s *ConfigService) CancelTelegramBinding(actor NotificationBindingActor, ticket string) error { + if err := s.validateBindingActor(actor); err != nil { + return ErrTelegramBinding + } + s.bindingMu.Lock() + defer s.bindingMu.Unlock() + if binding, found := s.telegramBindings[ticket]; found && binding.Actor != actor { + return ErrTelegramBinding + } + delete(s.telegramBindings, ticket) + return nil +} + +type TelegramBindingUpdate struct { + Message *struct { + Date int64 `json:"date"` + Text string `json:"text"` + ForwardOrigin json.RawMessage `json:"forward_origin"` + From *struct { + ID int64 `json:"id"` + IsBot bool `json:"is_bot"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Username string `json:"username"` + } `json:"from"` + Chat struct { + ID int64 `json:"id"` + Type string `json:"type"` + } `json:"chat"` + } `json:"message"` +} + +func (s *ConfigService) ClaimTelegramBinding(secret string, update TelegramBindingUpdate) error { + configuration, err := s.telegramConfiguration() + if err != nil || len(secret) != len(configuration.Secret) || secret == "" || + subtle.ConstantTimeCompare([]byte(secret), []byte(configuration.Secret)) != 1 { + return ErrTelegramBinding + } + message := update.Message + if message == nil || message.From == nil || message.From.IsBot || message.Chat.Type != "private" || + message.From.ID <= 0 || message.From.ID != message.Chat.ID || len(message.ForwardOrigin) != 0 { + return nil + } + parts := strings.Fields(message.Text) + if len(parts) != 2 || parts[0] != "/start" || !strings.HasPrefix(parts[1], "smail_") { + return nil + } + ticket := strings.TrimPrefix(parts[1], "smail_") + if len(ticket) != 43 { + return nil + } + s.bindingMu.Lock() + defer s.bindingMu.Unlock() + binding, found := s.telegramBindings[ticket] + now := s.currentTime() + if !found || binding.Status.Phase != "waiting" || !binding.Status.ExpiresAt.After(now) || + binding.Fingerprint != configuration.Fingerprint || message.Date < binding.CreatedAt.Unix() || message.Date > now.Add(time.Minute).Unix() { + return nil + } + name := strings.TrimSpace(message.From.FirstName + " " + message.From.LastName) + if message.From.Username != "" { + name += " (@" + message.From.Username + ")" + } + if len([]rune(name)) > 160 { + name = string([]rune(name)[:160]) + } + if name == "" { + name = "已授权的 Telegram 用户" + } + binding.Settings.ChatID = strconv.FormatInt(message.Chat.ID, 10) + binding.Settings.DisplayName = name + binding.Settings.Verified = true + binding.Status.DisplayName, binding.Status.Phase = name, "claimed" + s.telegramBindings[ticket] = binding + return nil +} + +func (s *ConfigService) ConfirmTelegramBinding(actor NotificationBindingActor, ticket string) (UserSettingsStatus, error) { + if err := s.validateBindingActor(actor); err != nil { + return UserSettingsStatus{}, ErrTelegramBinding + } + configuration, err := s.telegramConfiguration() + if err != nil { + return UserSettingsStatus{}, err + } + s.bindingMu.Lock() + defer s.bindingMu.Unlock() + binding, found := s.telegramBindings[ticket] + if !found || binding.Actor != actor || binding.Status.Phase != "claimed" || !binding.Status.ExpiresAt.After(s.currentTime()) || + binding.Fingerprint != configuration.Fingerprint { + return UserSettingsStatus{}, ErrTelegramBinding + } + delete(s.telegramBindings, ticket) + return s.updateTelegramSettings(actor.UserID, binding.Settings, binding.Version) +} + +func (s *ConfigService) TelegramBindingNeedsRebind(settings TelegramUserSettings) bool { + if settings.ChatID == "" { + return false + } + configuration, err := s.telegramConfiguration() + return err != nil || !settings.Verified || settings.BotID != configuration.BotID +} + +func (s *ConfigService) TelegramCanNotify(settings TelegramUserSettings) bool { + return settings.ChatID != "" && !settings.Disabled && !s.TelegramBindingNeedsRebind(settings) +} + +func (s *ConfigService) UpdateOwnTelegramPreferences(userID int, preferences TelegramUserSettings, unbind bool, expectedVersion uint64) (UserSettingsStatus, error) { + if _, err := s.activeNotificationUser(userID); err != nil { + return UserSettingsStatus{}, err + } + current, err := s.GetOwnTelegramSettings(userID) + if err != nil { + return UserSettingsStatus{}, err + } + if current.Version != expectedVersion { + return UserSettingsStatus{}, ErrPluginSettingConflict + } + settings := current.Settings + if unbind { + settings = TelegramUserSettings{Disabled: true, DisableLinkPreview: true} + } else { + if !preferences.Disabled && (settings.ChatID == "" || s.TelegramBindingNeedsRebind(settings)) { + return UserSettingsStatus{}, ErrTelegramBinding + } + settings.Disabled, settings.ShowContent = preferences.Disabled, preferences.ShowContent + settings.SpoilerContent, settings.SendAttachments = preferences.SpoilerContent, preferences.SendAttachments + settings.DisableLinkPreview = preferences.DisableLinkPreview + } + return s.updateTelegramSettings(userID, settings, expectedVersion) +} + +func (s *ConfigService) ReserveTelegramTest(userID int) error { + return s.reserveTelegramAttempt(userID, "test", 30*time.Second) +} + +// TelegramDeliverySettings 只向插件下发实际发信字段,不泄露绑定元数据。 +func TelegramDeliverySettings(settings TelegramUserSettings) json.RawMessage { + chatID := settings.ChatID + if !settings.Verified || settings.Disabled { + chatID = "" + } + payload, _ := json.Marshal(struct { + ChatID string `json:"chatId"` + ShowContent bool `json:"showContent"` + SpoilerContent bool `json:"spoilerContent"` + SendAttachments bool `json:"sendAttachments"` + DisableLinkPreview bool `json:"disableLinkPreview"` + }{chatID, settings.ShowContent, settings.SpoilerContent, settings.SendAttachments, settings.DisableLinkPreview}) + return payload +}