owpengram-server/internal/app/bots/service.go
Astra d1108c61f1 usernames: report reserved names as taken in the check paths too
account.checkUsername / channels.checkUsername / bots.checkUsername said a
reserved name was available and only updateUsername rejected it. Add the
blocklist check to peerUsernameAvailable (covers account + channel, both
backends) and to bots.Service.CheckUsername, so the client shows "username is
taken" immediately.
2026-09-09 21:41:53 +01:00

1022 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package bots 实现 bot 账号业务BotFather 对话状态机、bot 创建、token 管理与
// botInfo 查询。bot 登录auth.importBotAuthorization在 app/auth 经 store.BotStore
// 直接校验 token不依赖本包。
package bots
import (
"context"
"crypto/rand"
"errors"
"fmt"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"go.uber.org/zap"
telegramloginapp "telesrv/internal/app/telegramlogin"
"telesrv/internal/domain"
"telesrv/internal/links"
"telesrv/internal/store"
)
// blockChecker 报告 userID 是否 block 了 blockedUserIDstore.ContactStore 子集)。
type blockChecker interface {
IsBlocked(ctx context.Context, userID, blockedUserID int64) (bool, error)
}
type publicChannelUsernameResolver interface {
ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error)
}
type stickerSetCreator interface {
CreateStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error)
ListCreatedStickerSets(ctx context.Context, userID int64, offsetID int64, limit int) ([]domain.StickerSet, int, error)
ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error)
GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error)
AddStickerToSet(ctx context.Context, actorUserID int64, ref domain.StickerSetRef, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error)
RemoveStickerFromSet(ctx context.Context, actorUserID int64, documentID int64, accessHash int64) (domain.StickerSet, []domain.Document, error)
}
type userStickerSetInstaller interface {
InstallUserStickerSet(ctx context.Context, userID int64, setID int64, kind domain.StickerSetKind, archived bool, installedDate int) error
}
// gifCatalogSource is the built-in @gif inline bot's read-only view of the
// admin-curated GIF catalog (app/files.Service satisfies it as-is: it already
// exposes GetDocuments for the sticker-set responder above).
type gifCatalogSource interface {
ListGifCatalog(ctx context.Context, onlyEnabled bool) ([]domain.GifCatalogEntry, error)
GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error)
}
type aiChatGenerator interface {
GenerateTextStream(ctx context.Context, req domain.AITextGenerationRequest, emit func(domain.AIComposeText) error) (domain.AIComposeText, error)
}
// verificationApplications is the applicant-side surface of official platform
// verification used by the built-in @verifybot (app/verification.Service
// satisfies it as-is).
//
// It is declared as a narrow port rather than taken as a concrete service for the
// usual reason plus one specific to this feature: every verification rule --
// ownership, public username, restrictions, already-verified, cooldown, rate
// limit, the status machine -- belongs to that service, and the bot must not be
// able to reach past it. Nothing here can write a peer's verified flag.
type verificationApplications interface {
EligibleTargets(ctx context.Context, applicantUserID int64) ([]domain.VerificationTarget, error)
StartDraft(ctx context.Context, req domain.SubmitVerificationApplicationRequest) (domain.VerificationApplication, bool, error)
SaveDraft(ctx context.Context, applicantUserID, applicationID, version int64, draft domain.VerificationDraftInput) (domain.VerificationApplication, error)
Submit(ctx context.Context, applicantUserID, applicationID, version int64) (domain.VerificationApplication, error)
Cancel(ctx context.Context, applicantUserID, applicationID, version int64, reason string) (domain.VerificationApplication, error)
Draft(ctx context.Context, applicantUserID int64) (domain.VerificationApplication, error)
ApplicantApplications(ctx context.Context, applicantUserID int64, limit int) ([]domain.VerificationApplication, error)
Application(ctx context.Context, applicationID int64) (domain.VerificationApplication, error)
}
// botAvatarStore renders an already-stored photo (one a user sent to @BotFather)
// into a bot's profile photo. app/files.Service satisfies it. It is a narrow port
// because a service bot must not reach into the file layer for anything else.
type botAvatarStore interface {
SetAvatarFromExistingPhoto(ctx context.Context, ownerType domain.PeerType, ownerID, sourcePhotoID int64, date int) (domain.Photo, error)
PeerHasAvatar(ctx context.Context, ownerType domain.PeerType, ownerID int64) (bool, error)
}
// The third-party verification ports live in verifierbot.go
// (customVerifications, verifierBotTargets): they are the built-in @verifierbot's
// only way to reach the feature, and are kept next to the dialog that uses them.
// RouterHooks 是 rpc 层回调router 创建后经 SetRouterHooks 延迟注入,打破
// router↔bots 的构造循环;这些能力都依赖 TL/连接层边界,不能在 app 层实现):
// - RevokeBotSessionstoken revoke 后撤销 bot 的全部已登录 session
// authorization + 强制断连)。
// - PushBotCommandsChanged命令变更后给在线相关用户推 updateBotCommands
// (无 pts 的 ephemeral update离线用户靠 bot_info_version bump 兜底)。
// - PushStickerSetsChanged@Stickers 发布后给 creator 当前在线 session 推
// updateStickerSets离线端靠持久化 install 状态 + 下次 getAllStickers 兜底。
type RouterHooks interface {
RevokeBotSessions(ctx context.Context, botUserID int64) error
PushBotCommandsChanged(ctx context.Context, botUserID int64, commands []domain.BotCommand)
PushStickerSetsChanged(ctx context.Context, userID int64, kind domain.StickerSetKind)
}
// TextDraftPusher 推送 @ChatBot AI 流式回复的 transient 文本草稿,由 rpc 层转换为
// UpdateUserTyping/sendMessageTextDraftAction。它独立于普通 bot hooks避免 BotFather
// 和 @Stickers 的测试/依赖被 AI 对话能力污染。
type TextDraftPusher interface {
PushBotTextDraft(ctx context.Context, botUserID, userID, randomID int64, text string)
}
// replyLockStripes 是回复串行化条带数:同一用户的 BotFather 回复落同一条带、
// 串行执行(状态机 RMW 原子 + 回复保序),不同用户并发;固定大小不随用户数增长。
const replyLockStripes = 256
// Service 提供 bot 账号业务。
type Service struct {
users store.UserStore
bots store.BotStore
messages store.MessageStore
blocker blockChecker
channels publicChannelUsernameResolver
reserved reservedUsernameChecker
stickers stickerSetCreator
installer userStickerSetInstaller
aiChat aiChatGenerator
verification verificationApplications
customVerification customVerifications
verifierTargets verifierBotTargets
gifCatalog gifCatalogSource
telegramLogin *telegramloginapp.Service
botAvatar botAvatarStore
hooks RouterHooks
textDrafts TextDraftPusher
userCache store.UserCache
cache *botProfileCache
log *zap.Logger
now func() time.Time
chatBotStreamThrottle time.Duration
publicBaseURL string
// hideThirdPartyVerification mirrors config.HideThirdPartyVerification: while
// true, HandlesBot refuses VerifierBotUserID so @marksbot never answers a
// message. The feature is not fully finished and defaults to hidden; see the
// config field's doc comment.
hideThirdPartyVerification bool
// dialogLimiter bounds how often one applicant can drive a service-bot dialog.
// The verification service already rate-limits application creation; this is the
// separate bound on dialog traffic itself, so a script cannot spin the state
// machine (and its writes) even without ever submitting anything.
dialogLimiter store.RateLimiter
dialogRateLimit int
dialogRateWindow time.Duration
// replySeq 是回复 randomID 在 crypto/rand 失败时的兜底单调序列。
replySeq atomic.Int64
replyLocks [replyLockStripes]sync.Mutex
}
// Option 调整 bots 服务的可选依赖。
type Option func(*Service)
// WithLogger 注入日志器(缺省 zap.NewNop
func WithLogger(log *zap.Logger) Option {
return func(s *Service) {
if log != nil {
s.log = log
}
}
}
// WithNow 注入时钟(测试用)。
func WithNow(now func() time.Time) Option {
return func(s *Service) {
if now != nil {
s.now = now
}
}
}
// WithBlockChecker 注入 block 关系查询BotFather 回复前据此设置 RecipientBlocked
// 用户 block 掉 BotFather 后不再向其收件箱投递(对齐 rpc 发送路径语义)。
func WithBlockChecker(c blockChecker) Option {
return func(s *Service) {
if c != nil {
s.blocker = c
}
}
}
// WithBotAvatarStore injects the ability to set a bot's profile photo from a
// photo a user sent to @BotFather ("Edit Botpic"). Absent it, that action reports
// that it is unavailable on this server.
func WithBotAvatarStore(a botAvatarStore) Option {
return func(s *Service) {
if a != nil {
s.botAvatar = a
}
}
}
// WithPublicChannelUsernameResolver 注入公开频道 username 查询能力,用于 bot
// username 预检,避免 bot 与 public channel 产生同名可见入口。
// reservedUsernameChecker reports whether a name is on the operator blocklist.
type reservedUsernameChecker interface {
IsReserved(ctx context.Context, usernameLower string) (bool, error)
}
// WithReservedUsernames wires the operator username blocklist so CheckUsername
// reports a reserved bot name as taken instead of available.
func WithReservedUsernames(c reservedUsernameChecker) Option {
return func(s *Service) {
if c != nil {
s.reserved = c
}
}
}
func WithPublicChannelUsernameResolver(c publicChannelUsernameResolver) Option {
return func(s *Service) {
if c != nil {
s.channels = c
}
}
}
// WithUserCache 注入 users 基础资料缓存bot 元数据写入first_name/about/
// bot_info_version bump后必须失效该 bot 的缓存条目,否则 TTL 内 getUsers
// 返回旧 first_name 与旧 bot_info_version——version bump 被缓存遮蔽,客户端
// 感知不到变更、不会重拉 getFullUser。
func WithUserCache(c store.UserCache) Option {
return func(s *Service) {
if c != nil {
s.userCache = c
}
}
}
// WithStickerSetCreator 注入 sticker set 创建/查询能力,供内置 @Stickers bot 使用。
func WithStickerSetCreator(c stickerSetCreator) Option {
return func(s *Service) {
if c != nil {
s.stickers = c
}
}
}
// WithUserStickerSets 注入 per-user sticker set 安装状态写入能力。
func WithUserStickerSets(c userStickerSetInstaller) Option {
return func(s *Service) {
if c != nil {
s.installer = c
}
}
}
// WithGifCatalogSource injects the read-only catalog access used by the
// built-in @gif inline bot. Without it, HandlesInlineBot still claims
// GifBotUserID but OnInlineQuery reports handled=true with zero results
// rather than erroring -- an unconfigured catalog is "nothing to show", not a
// bot failure.
func WithGifCatalogSource(c gifCatalogSource) Option {
return func(s *Service) {
if c != nil {
s.gifCatalog = c
}
}
}
// WithAIChatGenerator 注入内置 @ChatBot 使用的 AI 文本生成器。
func WithAIChatGenerator(g aiChatGenerator) Option {
return func(s *Service) {
if g != nil {
s.aiChat = g
}
}
}
// WithVerification injects the official verification service used by the
// built-in @verifybot. Without it the bot still answers, but every command
// reports that verification is unavailable rather than half-running the dialog.
func WithVerification(v verificationApplications) Option {
return func(s *Service) {
if v != nil {
s.verification = v
}
}
}
// WithCustomVerification injects the third-party verification service used by the
// built-in @verifierbot. Without it the bot still answers, but every command
// reports that third-party verification is unavailable rather than half-running the
// dialog.
func WithCustomVerification(v customVerifications) Option {
return func(s *Service) {
if v != nil {
s.customVerification = v
}
}
}
// WithHideThirdPartyVerification mirrors config.HideThirdPartyVerification:
// while true, HandlesBot refuses VerifierBotUserID, so @marksbot never
// receives or answers a message.
func WithHideThirdPartyVerification(hidden bool) Option {
return func(s *Service) {
s.hideThirdPartyVerification = hidden
}
}
// WithVerifierTargets injects the directory of an applicant's own peers used by
// @verifierbot's subject picker. It is optional: with nothing injected the bot
// falls back to the official verification service's EligibleTargets, which
// enumerates exactly the same peers (only its eligibility verdicts, which answer a
// different question, are ignored).
func WithVerifierTargets(t verifierBotTargets) Option {
return func(s *Service) {
if t != nil {
s.verifierTargets = t
}
}
}
// WithDialogRateLimiter bounds service-bot dialog traffic per user. A zero limit
// or a nil limiter disables the bound, which is what a deployment without Redis
// gets.
func WithDialogRateLimiter(limiter store.RateLimiter, limit int, window time.Duration) Option {
return func(s *Service) {
if limiter == nil || limit <= 0 || window <= 0 {
return
}
s.dialogLimiter = limiter
s.dialogRateLimit = limit
s.dialogRateWindow = window
}
}
// WithTelegramLogin injects the OIDC application service used by BotFather.
// BotFather never writes the login tables directly.
func WithTelegramLogin(login *telegramloginapp.Service) Option {
return func(s *Service) {
if login != nil {
s.telegramLogin = login
}
}
}
// WithAIChatStreamThrottle 调整 @ChatBot 流式草稿推送的最小时间间隔(测试用)。
func WithAIChatStreamThrottle(d time.Duration) Option {
return func(s *Service) {
if d >= 0 {
s.chatBotStreamThrottle = d
}
}
}
func WithPublicBaseURL(baseURL string) Option {
return func(s *Service) {
s.publicBaseURL = links.NormalizeBaseURL(baseURL)
}
}
// invalidateUserCache 在 bot 的 users 行变更(含 version bump后清缓存。
// 失效失败只记日志:缓存最长 TTL 后自愈,不阻塞写路径。
func (s *Service) invalidateUserCache(ctx context.Context, botUserID int64) {
if s.userCache == nil {
return
}
if err := s.userCache.Delete(ctx, []int64{botUserID}); err != nil {
s.log.Warn("invalidate bot user cache", zap.Int64("bot_user_id", botUserID), zap.Error(err))
}
}
func (s *Service) invalidateBotProfileCache(botUserID int64) {
if s.cache != nil {
s.cache.delete(botUserID)
}
}
func (s *Service) invalidateBotReadCaches(ctx context.Context, botUserID int64) {
s.invalidateBotProfileCache(botUserID)
s.invalidateUserCache(ctx, botUserID)
}
// InvalidateBotProfileReadModel 供 ReadModelChangeListener 在 user_base 事件(bot 写会
// bump bot_info_version)时跨实例失效本进程 bot 资料缓存。
func (s *Service) InvalidateBotProfileReadModel(userID int64) {
if s == nil {
return
}
s.invalidateBotProfileCache(userID)
}
// FlushBotProfileReadModel 供 listener 重连时整表 flush兜住断连窗口内丢失的 user_base 通知。
func (s *Service) FlushBotProfileReadModel() {
if s == nil || s.cache == nil {
return
}
s.cache.flush()
}
// SetRouterHooks 注入 rpc 层回调router 创建后装配,与 P1 的
// SetLifecycleObserver 同款延迟注入)。
func (s *Service) SetRouterHooks(h RouterHooks) {
if s != nil {
s.hooks = h
}
}
// SetTextDraftPusher 注入 @ChatBot 流式草稿推送边界。
func (s *Service) SetTextDraftPusher(p TextDraftPusher) {
if s != nil {
s.textDrafts = p
}
}
func (s *Service) SetAIChatGenerator(g aiChatGenerator) {
if s != nil {
s.aiChat = g
}
}
// SetVerification injects the official verification service after construction.
// The bots service is built before the peer directories that service depends on,
// so in the shipped process this is the wiring order that actually exists (same
// deferred-injection pattern as SetRouterHooks).
func (s *Service) SetVerification(v verificationApplications) {
if s != nil && v != nil {
s.verification = v
}
}
// SetCustomVerification injects the third-party verification service after
// construction. The bots service is built before the stores and directories that
// service depends on, so in the shipped process this is the wiring order that
// actually exists (same deferred-injection pattern as SetVerification).
func (s *Service) SetCustomVerification(v customVerifications) {
if s != nil && v != nil {
s.customVerification = v
}
}
// SetVerifierTargets injects @verifierbot's subject directory after construction.
func (s *Service) SetVerifierTargets(t verifierBotTargets) {
if s != nil && t != nil {
s.verifierTargets = t
}
}
// NewService 创建 bots 服务。
func NewService(users store.UserStore, bots store.BotStore, messages store.MessageStore, opts ...Option) *Service {
s := &Service{
users: users,
bots: bots,
messages: messages,
cache: newBotProfileCache(botProfileCacheMaxEntries, botProfileCacheTTL),
log: zap.NewNop(),
now: time.Now,
chatBotStreamThrottle: defaultChatBotStreamThrottle,
publicBaseURL: links.DefaultPublicBaseURL,
}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *Service) botProfile(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) {
if s == nil || s.bots == nil || botUserID == 0 {
return domain.BotProfile{}, false, nil
}
if s.cache != nil {
return s.cache.getOrLoad(ctx, botUserID, func() (domain.BotProfile, bool, error) {
return s.bots.GetBot(ctx, botUserID)
})
}
return s.bots.GetBot(ctx, botUserID)
}
func (s *Service) botProfiles(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) {
if s == nil || s.bots == nil || len(botUserIDs) == 0 {
return nil, nil
}
ids := uniqueBotUserIDs(botUserIDs)
if len(ids) == 0 {
return nil, nil
}
if s.cache == nil {
return s.loadBotProfiles(ctx, ids)
}
return s.cache.getMany(ctx, ids, s.loadBotProfiles)
}
func (s *Service) loadBotProfiles(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) {
if batch, ok := s.bots.(botBatchStore); ok {
return batch.GetBots(ctx, botUserIDs)
}
out := make(map[int64]domain.BotProfile)
for _, id := range uniqueBotUserIDs(botUserIDs) {
profile, found, err := s.bots.GetBot(ctx, id)
if err != nil {
return nil, err
}
if found {
out[id] = profile
}
}
return out, nil
}
// BotInfo 返回 bot 的元数据userFull.bot_info hydrate 用)。
func (s *Service) BotInfo(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) {
return s.botProfile(ctx, botUserID)
}
type botBatchStore interface {
GetBots(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error)
}
// BotInfos 批量返回 bot 元数据,供频道 full info / participants 这类高频富化路径避免逐 bot 点查。
func (s *Service) BotInfos(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) {
return s.botProfiles(ctx, botUserIDs)
}
func uniqueBotUserIDs(ids []int64) []int64 {
if len(ids) == 0 {
return nil
}
seen := make(map[int64]struct{}, len(ids))
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
// CheckUsername 校验 bot username 语法与全局可见入口占用users + public channels
func (s *Service) CheckUsername(ctx context.Context, ownerUserID int64, username string) (bool, error) {
if s == nil || s.users == nil || ownerUserID == 0 {
return false, domain.ErrBotUsernameInvalid
}
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
if !domain.ValidBotUsername(username) {
return false, domain.ErrBotUsernameInvalid
}
if s.reserved != nil {
if r, err := s.reserved.IsReserved(ctx, strings.ToLower(username)); err != nil {
return false, err
} else if r {
return false, nil
}
}
if _, found, err := s.users.ByUsername(ctx, username); err != nil {
return false, err
} else if found {
return false, nil
}
if s.channels != nil {
if _, found, err := s.channels.ResolvePublicChannelUsername(ctx, ownerUserID, username); err != nil {
return false, err
} else if found {
return false, nil
}
}
return true, nil
}
// CreateBot 创建一个新 bot 账号users 行is_bot, bot_info_version=1, 无 phone+
// bots 行owner、token。返回新账号与完整 token唯一一次返回明文的途径之一
func (s *Service) CreateBot(ctx context.Context, ownerUserID int64, name, username string) (domain.User, string, error) {
if s == nil || s.users == nil || s.bots == nil || ownerUserID == 0 {
return domain.User{}, "", domain.ErrBotNameInvalid
}
name = strings.TrimSpace(name)
if name == "" || utf8.RuneCountInString(name) > domain.MaxBotNameLength {
return domain.User{}, "", domain.ErrBotNameInvalid
}
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
if !domain.ValidBotUsername(username) {
return domain.User{}, "", domain.ErrBotUsernameInvalid
}
ok, err := s.CheckUsername(ctx, ownerUserID, username)
if err != nil {
return domain.User{}, "", err
}
if !ok {
return domain.User{}, "", domain.ErrUsernameOccupied
}
count, err := s.bots.CountBotsByOwner(ctx, ownerUserID)
if err != nil {
return domain.User{}, "", err
}
if count >= domain.MaxBotsPerOwner {
return domain.User{}, "", domain.ErrBotsTooMany
}
accessHash, err := randomInt64()
if err != nil {
return domain.User{}, "", err
}
secret, err := randomTokenSecret()
if err != nil {
return domain.User{}, "", err
}
u, profile, err := s.bots.CreateBotAccount(ctx, domain.User{
AccessHash: accessHash,
FirstName: name,
Username: username,
Bot: true,
BotInfoVersion: 1,
}, domain.BotProfile{
OwnerUserID: ownerUserID,
TokenSecret: secret,
})
if err != nil {
return domain.User{}, "", err
}
if s.cache != nil {
s.cache.put(u.ID, profile, true)
}
return u, domain.FormatBotToken(u.ID, secret), nil
}
// ListOwnedBots 返回当前 owner 管理的 bot 用户列表(排除 BotFather 种子)。
func (s *Service) ListOwnedBots(ctx context.Context, ownerUserID int64) ([]domain.User, error) {
owned, err := s.ownedBots(ctx, ownerUserID)
if err != nil {
return nil, err
}
out := make([]domain.User, 0, len(owned))
for _, item := range owned {
out = append(out, item.user)
}
return out, nil
}
// botAccountDeleter is the optional store capability used to permanently delete
// a user-created bot. Only the Postgres store implements it, so the memory store
// and other BotStore mocks are unaffected.
type botAccountDeleter interface {
DeleteBotAccount(ctx context.Context, botUserID int64) (domain.User, error)
}
// DeleteBot permanently removes a user-created bot. System service bots are
// rejected. Live sessions are dropped and the bot's caches are invalidated so
// the deletion is visible immediately. Returns the tombstoned user.
func (s *Service) DeleteBot(ctx context.Context, botUserID int64) (domain.User, error) {
if s == nil || s.bots == nil || botUserID == 0 {
return domain.User{}, domain.ErrBotNotFound
}
if domain.IsSystemUserID(botUserID) {
return domain.User{}, domain.ErrBotNotFound
}
deleter, ok := s.bots.(botAccountDeleter)
if !ok {
return domain.User{}, fmt.Errorf("bot deletion is not supported by the configured store")
}
// Session revocation is part of the deletion invariant: a deleted bot must
// never retain an authenticated connection. Fail closed before tombstoning
// when the hook is unavailable or revocation fails.
if s.hooks == nil {
return domain.User{}, domain.ErrBotSessionsNotRevoked
}
if err := s.hooks.RevokeBotSessions(ctx, botUserID); err != nil {
s.log.Warn("revoke bot sessions before delete", zap.Int64("bot_user_id", botUserID), zap.Error(err))
return domain.User{}, domain.ErrBotSessionsNotRevoked
}
u, err := deleter.DeleteBotAccount(ctx, botUserID)
if err != nil {
return domain.User{}, err
}
s.invalidateBotReadCaches(ctx, botUserID)
return u, nil
}
// ExportBotToken 返回 bot tokenrevoke=true 时先轮换 secret 并撤销已登录 session。
func (s *Service) ExportBotToken(ctx context.Context, ownerUserID, botUserID int64, revoke bool) (string, error) {
if revoke {
return s.RevokeBotToken(ctx, ownerUserID, botUserID)
}
profile, found, err := s.botProfile(ctx, botUserID)
if err != nil {
return "", err
}
if !found || profile.OwnerUserID != ownerUserID || botUserID == domain.BotFatherUserID || profile.TokenSecret == "" {
return "", domain.ErrBotNotFound
}
return domain.FormatBotToken(botUserID, profile.TokenSecret), nil
}
// AdminExportBotToken returns a non-system bot's current token through the
// admin path (no owner check), without rotating it. System bots (built-in,
// seeded at reserved ids) have no exportable token.
func (s *Service) AdminExportBotToken(ctx context.Context, botUserID int64) (string, error) {
if s == nil || s.bots == nil || botUserID <= 0 {
return "", domain.ErrBotNotFound
}
if domain.IsSystemUserID(botUserID) || botUserID == domain.BotFatherUserID {
return "", fmt.Errorf("system bots have no exportable token")
}
profile, found, err := s.botProfile(ctx, botUserID)
if err != nil {
return "", err
}
if !found || profile.TokenSecret == "" {
return "", domain.ErrBotNotFound
}
return domain.FormatBotToken(botUserID, profile.TokenSecret), nil
}
// RevokeBotToken 生成新 token 随机段并落库;旧 token 立即不可登录,并踢掉所有
// 已凭旧 token 登录的 session经注入的 SessionRevoker
func (s *Service) RevokeBotToken(ctx context.Context, ownerUserID, botUserID int64) (string, error) {
profile, found, err := s.botProfile(ctx, botUserID)
if err != nil {
return "", err
}
if !found || profile.OwnerUserID != ownerUserID || botUserID == domain.BotFatherUserID {
return "", domain.ErrBotNotFound
}
secret, err := randomTokenSecret()
if err != nil {
return "", err
}
if err := s.bots.UpdateBotTokenSecret(ctx, botUserID, secret); err != nil {
return "", err
}
s.invalidateBotProfileCache(botUserID)
token := domain.FormatBotToken(botUserID, secret)
// 撤销已登录 session旧 token 已不可重新登录,但已建立的连接仍持有 auth_key
// 必须主动失效(删 authorization + 断连),否则旧持有者继续以 bot 身份操作。
// secret 已轮换不可回滚,故失败时仍返回新 token但透出 ErrBotSessionsNotRevoked
// 让调用方诚实告知用户「需重试以确保旧 session 终止」,绝不谎称已止血。
if s.hooks != nil {
if err := s.hooks.RevokeBotSessions(ctx, botUserID); err != nil {
s.log.Warn("revoke bot sessions", zap.Int64("bot_user_id", botUserID), zap.Error(err))
return token, domain.ErrBotSessionsNotRevoked
}
}
return token, nil
}
// SetBotCommands 覆盖式写入 bot 的 default scope 命令bots.setBotCommands /
// BotFather /setcommands 共用收口)。校验命令名/描述/数量;写库(含 version bump
// 成功后给在线相关用户推 updateBotCommands。返回 bump 后的 bot_info_version。
func (s *Service) SetBotCommands(ctx context.Context, botUserID int64, commands []domain.BotCommand) (int, error) {
if len(commands) > domain.MaxBotCommands {
return 0, domain.ErrBotCommandInvalid
}
clean := make([]domain.BotCommand, 0, len(commands))
for _, c := range commands {
cmd := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(c.Command, "/")))
desc := strings.TrimSpace(c.Description)
if !domain.ValidBotCommandName(cmd) || desc == "" || len(desc) > domain.MaxBotCommandDescriptionLen {
return 0, domain.ErrBotCommandInvalid
}
clean = append(clean, domain.BotCommand{Command: cmd, Description: desc, Ephemeral: c.Ephemeral})
}
// 同值短路bot 框架启动时普遍无条件重发相同命令集,跳过可避免无意义的
// bot_info_version bump驱动全体客户端多打一轮 getFullUser与多余推送。
// 非原子(读后他写不影响正确性:要么对方已 bump、要么我们多 bump 一次)。
cur, found, err := s.botProfile(ctx, botUserID)
if err != nil {
return 0, err
}
if !found {
return 0, domain.ErrBotNotFound
}
if botCommandsEqual(cur.Commands, clean) {
return 0, nil // 无变更;调用方忽略返回的 version
}
version, err := s.bots.UpdateBotCommands(ctx, botUserID, clean)
if err != nil {
return 0, err
}
s.invalidateBotReadCaches(ctx, botUserID)
if s.hooks != nil {
s.hooks.PushBotCommandsChanged(ctx, botUserID, clean)
}
return version, nil
}
func botCommandsEqual(a, b []domain.BotCommand) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].Command != b[i].Command || a[i].Description != b[i].Description || a[i].Ephemeral != b[i].Ephemeral {
return false
}
}
return true
}
// GetBotCommands 返回 bot 的 default scope 命令。
func (s *Service) GetBotCommands(ctx context.Context, botUserID int64) ([]domain.BotCommand, error) {
profile, found, err := s.botProfile(ctx, botUserID)
if err != nil {
return nil, err
}
if !found {
return nil, domain.ErrBotNotFound
}
return profile.Commands, nil
}
// SetBotInfo 更新 bot 的 nameusers.first_name/aboutusers.about/description
// bots.description返回 bump 后的 bot_info_version。
func (s *Service) SetBotInfo(ctx context.Context, botUserID int64, upd domain.BotInfoUpdate) (int, error) {
if upd.SetName {
upd.Name = strings.TrimSpace(upd.Name)
if upd.Name == "" || utf8.RuneCountInString(upd.Name) > domain.MaxBotNameLength {
return 0, domain.ErrBotInfoInvalid
}
}
if upd.SetAbout && utf8.RuneCountInString(upd.About) > domain.MaxBotAboutLen {
return 0, domain.ErrBotInfoInvalid
}
if upd.SetDescription && utf8.RuneCountInString(upd.Description) > domain.MaxBotDescriptionLen {
return 0, domain.ErrBotInfoInvalid
}
if !upd.SetName && !upd.SetAbout && !upd.SetDescription {
return 0, domain.ErrBotInfoInvalid
}
version, err := s.bots.UpdateBotInfo(ctx, botUserID, upd)
if err != nil {
return 0, err
}
s.invalidateBotReadCaches(ctx, botUserID)
return version, nil
}
// GetBotInfo 返回 bot 的 name/about/descriptionname=users.first_name、
// about=users.about、description=bots.description
func (s *Service) GetBotInfo(ctx context.Context, botUserID int64) (name, about, description string, err error) {
profile, found, err := s.botProfile(ctx, botUserID)
if err != nil {
return "", "", "", err
}
if !found {
return "", "", "", domain.ErrBotNotFound
}
u, found, err := s.users.ByID(ctx, botUserID)
if err != nil {
return "", "", "", err
}
if !found {
return "", "", "", domain.ErrBotNotFound
}
return u.FirstName, u.About, profile.Description, nil
}
// SetBotMenuButton 设置 bot 的 menu buttonper-bot 全局),返回新 bot_info_version。
func (s *Service) SetBotMenuButton(ctx context.Context, botUserID int64, button domain.BotMenuButton) (int, error) {
switch button.Type {
case domain.BotMenuButtonDefault, domain.BotMenuButtonCommands:
button.Text, button.URL = "", ""
case domain.BotMenuButtonWebView:
button.Text = strings.TrimSpace(button.Text)
button.URL = strings.TrimSpace(button.URL)
if button.Text == "" || len(button.Text) > domain.MaxBotMenuButtonTextLen ||
button.URL == "" || len(button.URL) > domain.MaxBotMenuButtonURLLen {
return 0, domain.ErrBotMenuButtonInvalid
}
// 强制 https对齐官方 BUTTON_URL_INVALIDmenu button URL 经
// userFull.bot_info.menu_button 下发给所有交互用户的客户端 webview 入口,
// 拒绝 javascript:/file:/intent: 等非 https scheme防 bot 投毒。
if u, err := url.Parse(button.URL); err != nil || u.Scheme != "https" || u.Host == "" {
return 0, domain.ErrBotMenuButtonInvalid
}
default:
return 0, domain.ErrBotMenuButtonInvalid
}
version, err := s.bots.UpdateBotMenuButton(ctx, botUserID, button)
if err != nil {
return 0, err
}
if button.Type == domain.BotMenuButtonWebView {
if _, _, err := s.EnsureMenuBotApp(ctx, botUserID, button); err != nil {
return 0, err
}
}
s.invalidateBotReadCaches(ctx, botUserID)
return version, nil
}
// GetBotMenuButton 返回 bot 的 menu button。
func (s *Service) GetBotMenuButton(ctx context.Context, botUserID int64) (domain.BotMenuButton, error) {
profile, found, err := s.botProfile(ctx, botUserID)
if err != nil {
return domain.BotMenuButton{}, err
}
if !found {
return domain.BotMenuButton{}, domain.ErrBotNotFound
}
return profile.MenuButton, nil
}
// SetInlinePlaceholder 设置 inline mode placeholder空字符串表示关闭 inline mode。
func (s *Service) SetInlinePlaceholder(ctx context.Context, botUserID int64, placeholder string) (int, error) {
placeholder = strings.TrimSpace(placeholder)
if utf8.RuneCountInString(placeholder) > domain.MaxBotInlinePlaceholderLen {
return 0, domain.ErrBotInlinePlaceholderInvalid
}
version, err := s.bots.SetBotInlinePlaceholder(ctx, botUserID, placeholder)
if err != nil {
return 0, err
}
s.invalidateBotReadCaches(ctx, botUserID)
return version, nil
}
// SetInlineGeo 设置 bot 是否可在 inline query 中接收用户位置。
func (s *Service) SetInlineGeo(ctx context.Context, botUserID int64, enabled bool) (int, error) {
version, err := s.bots.SetBotInlineGeo(ctx, botUserID, enabled)
if err != nil {
return 0, err
}
s.invalidateBotReadCaches(ctx, botUserID)
return version, nil
}
// SetJoinGroups 设置 bot 能否被加入群组allow=true → bot_nochats=false
func (s *Service) SetJoinGroups(ctx context.Context, botUserID int64, allow bool) (int, error) {
version, err := s.bots.SetBotNochats(ctx, botUserID, !allow)
if err != nil {
return 0, err
}
s.invalidateBotReadCaches(ctx, botUserID)
return version, nil
}
// SetPrivacy 设置 bot 群内 privacy modeenabled=true 隐私模式开 → bot_chat_history=false
// 即 bot 只看命令/回复enabled=false → 关闭隐私 → bot_chat_history=true能看全部消息
func (s *Service) SetPrivacy(ctx context.Context, botUserID int64, enabled bool) (int, error) {
version, err := s.bots.SetBotChatHistory(ctx, botUserID, !enabled)
if err != nil {
return 0, err
}
s.invalidateBotReadCaches(ctx, botUserID)
return version, nil
}
// ErrBotUserpicUnsupported reports that this server has no file layer wired for
// setting a bot's profile photo (WithBotAvatarStore was not supplied).
var ErrBotUserpicUnsupported = errors.New("bot profile photo is not supported by this server")
// SetBotUserpic makes an already-stored photo (one a user sent to @BotFather) the
// bot's current profile photo. sourcePhotoID is a message photo id; the file
// layer re-renders it into the avatar size set.
func (s *Service) SetBotUserpic(ctx context.Context, botUserID, sourcePhotoID int64) error {
if s == nil || botUserID == 0 {
return domain.ErrBotNotFound
}
if s.botAvatar == nil {
return ErrBotUserpicUnsupported
}
if sourcePhotoID <= 0 {
return domain.ErrPhotoInvalid
}
if _, err := s.botAvatar.SetAvatarFromExistingPhoto(ctx, domain.PeerTypeUser, botUserID, sourcePhotoID, int(s.now().Unix())); err != nil {
return err
}
s.invalidateBotReadCaches(ctx, botUserID)
return nil
}
// CanSendMessage reports whether botUserID has explicit permission to initiate
// direct messages with userID.
func (s *Service) CanSendMessage(ctx context.Context, userID, botUserID int64) (bool, error) {
if s == nil || s.bots == nil || userID == 0 || botUserID == 0 || userID == botUserID {
return false, nil
}
return s.bots.CanBotSendMessage(ctx, botUserID, userID)
}
// AllowSendMessage records an explicit user grant for botUserID to message userID.
func (s *Service) AllowSendMessage(ctx context.Context, userID, botUserID int64, fromRequest bool) (bool, error) {
if s == nil || s.bots == nil || userID == 0 || botUserID == 0 || userID == botUserID {
return false, domain.ErrBotNotFound
}
return s.bots.AllowBotSendMessage(ctx, botUserID, userID, fromRequest)
}
// OwnsBot 报告 ownerUserID 是否为 botUserID 的 owner非 BotFather 自身)。
func (s *Service) OwnsBot(ctx context.Context, ownerUserID, botUserID int64) (bool, error) {
profile, found, err := s.botProfile(ctx, botUserID)
if err != nil {
return false, err
}
return found && profile.OwnerUserID == ownerUserID && botUserID != domain.BotFatherUserID, nil
}
// tokenSecretAlphabet 对齐官方 token 随机段字符集。
const tokenSecretAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"
func randomTokenSecret() (string, error) {
raw := make([]byte, domain.BotTokenSecretLength)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("rand: %w", err)
}
out := make([]byte, len(raw))
for i, b := range raw {
out[i] = tokenSecretAlphabet[int(b)%len(tokenSecretAlphabet)]
}
return string(out), nil
}
func randomInt64() (int64, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, fmt.Errorf("rand: %w", err)
}
v := int64(uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 |
uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7]))
return v, nil
}