owpengram-server/internal/app/files/bot_avatar.go
Astra f474a360d7 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.
2026-09-14 11:52:52 +01:00

78 lines
2.6 KiB
Go

package files
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
)
// maxBotAvatarSourceBytes bounds the source image @BotFather turns into a bot's
// profile photo. A profile photo a user sends in a chat is a server-rendered
// message-size JPEG, comfortably under this.
const maxBotAvatarSourceBytes = 12 << 20
// SetAvatarFromExistingPhoto renders an already-stored photo (typically one a
// user just sent to a service bot) into the s/a/c avatar size set and makes it
// the current profile photo of ownerType/ownerID.
//
// It is the path @BotFather's "Edit Botpic" uses: photos.uploadProfilePhoto#bot
// is not accepted, and the source is a message photo rather than a fresh upload,
// so the ordinary UploadProfilePhoto path does not apply.
func (s *Service) SetAvatarFromExistingPhoto(ctx context.Context, ownerType domain.PeerType, ownerID, sourcePhotoID int64, date int) (domain.Photo, error) {
if ownerID <= 0 || sourcePhotoID <= 0 {
return domain.Photo{}, domain.ErrPhotoInvalid
}
source, found, err := s.media.GetPhoto(ctx, sourcePhotoID)
if err != nil {
return domain.Photo{}, err
}
if !found {
return domain.Photo{}, domain.ErrPhotoInvalid
}
data, ok := s.photoSourceBytes(ctx, source)
if !ok || !s.ValidateAvatarUpload(data) {
return domain.Photo{}, domain.ErrPhotoInvalid
}
if date == 0 {
date = int(time.Now().Unix())
}
photo, err := s.createAvatarPhoto(ctx, data, ownerID)
if err != nil {
return domain.Photo{}, err
}
if err := s.media.AddProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photo.ID, date); err != nil {
return domain.Photo{}, err
}
return photo, nil
}
// photoSourceBytes reads the original image bytes behind a stored photo. Every
// static size of a photo written by putPhotoStaticSizes points at the same
// stored object, so any one size yields the full image.
func (s *Service) photoSourceBytes(ctx context.Context, photo domain.Photo) ([]byte, bool) {
for _, size := range photo.Sizes {
if size.Type == "" {
continue
}
key := fmt.Sprintf("photo:%d:%s", photo.ID, size.Type)
blob, found, err := s.media.GetFileBlob(ctx, key)
if err != nil || !found || blob.Size <= 0 || blob.Size > maxBotAvatarSourceBytes {
continue
}
backend, err := s.backendFor(blob.Backend)
if err != nil {
continue
}
body, total, err := backend.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
if err != nil || total != blob.Size || int64(len(body)) != blob.Size {
s.log.Warn("read bot avatar source blob failed", zap.String("location_key", key), zap.Error(err))
continue
}
return body, true
}
return nil, false
}