owpengram-server/internal/app/files/bot_avatar.go
Astra 5afafc60c4 /mybots: Edit Bot summary screen, return-to-menu, clickable @mentions
- Edit Bot now shows the current value of every field (Name/About/
  Description/Botpic/Commands) like BotFather, with real botpic status
  via a new PeerHasAvatar port method.
- After editing a field the dialog lands back on a fresh Edit Bot menu
  (working "Back to bot" / "Bots list" buttons) instead of ending, so a
  follow-up button press no longer reports the button as expired.
- Service-bot messages now render @username as a tappable mention entity.
2026-09-14 11:53:43 +01:00

84 lines
2.9 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
}
// PeerHasAvatar reports whether ownerType/ownerID has a current profile photo.
func (s *Service) PeerHasAvatar(ctx context.Context, ownerType domain.PeerType, ownerID int64) (bool, error) {
_, ok, err := s.CurrentProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile)
return ok, err
}
// 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
}