Interactive /mybots menu for @BotFather

Button-driven bot management: paginated picker, per-bot API token /
revoke, Edit Bot (name/description/about/commands/botpic), Bot Settings
toggles (inline/groups/privacy), and delete. Navigation edits the menu
message in place via a new editServiceBotMessage helper.

Edit Botpic accepts a photo the user sends to @BotFather and sets it as
the bot's profile photo (new files.SetAvatarFromExistingPhoto, wired
through bots.SetBotUserpic / WithBotAvatarStore); photos.uploadProfilePhoto
does not accept a bot target so this is the only route.
This commit is contained in:
Astra 2026-09-08 13:31:05 +01:00
parent 1ebf8ddb5a
commit 11142f74c5
10 changed files with 1467 additions and 26 deletions

View file

@ -6,6 +6,7 @@ package bots
import (
"context"
"crypto/rand"
"errors"
"fmt"
"net/url"
"strings"
@ -76,6 +77,13 @@ type verificationApplications interface {
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)
}
// 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.
@ -120,6 +128,7 @@ type Service struct {
verifierTargets verifierBotTargets
gifCatalog gifCatalogSource
telegramLogin *telegramloginapp.Service
botAvatar botAvatarStore
hooks RouterHooks
textDrafts TextDraftPusher
userCache store.UserCache
@ -176,6 +185,17 @@ func WithBlockChecker(c blockChecker) Option {
}
}
// 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 产生同名可见入口。
func WithPublicChannelUsernameResolver(c publicChannelUsernameResolver) Option {
@ -902,6 +922,30 @@ func (s *Service) SetPrivacy(ctx context.Context, botUserID int64, enabled bool)
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) {