443 lines
17 KiB
Go
443 lines
17 KiB
Go
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
|
||
}
|