This commit is contained in:
parent
fa53f7c641
commit
e8c2e7867e
169
backend/controllers/plugin_telegram.go
Normal file
169
backend/controllers/plugin_telegram.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
442
backend/services/plugin/telegram_binding.go
Normal file
442
backend/services/plugin/telegram_binding.go
Normal file
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user