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:
parent
196f90f9b1
commit
f474a360d7
10 changed files with 1467 additions and 26 deletions
78
internal/app/files/bot_avatar.go
Normal file
78
internal/app/files/bot_avatar.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue