Skip to content

Commit c64d097

Browse files
ernadoclaude
andcommitted
feat: add business account profile photo methods
SetBusinessAccountProfilePhoto (photos.uploadProfilePhoto, static or animated) and RemoveBusinessAccountProfilePhoto (photos.updateProfilePhoto with inputPhotoEmpty), both via the business-connection wrapper. is_public maps to the MTProto Fallback (public/fallback photo). Adds the InputProfilePhoto union. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a6e2d39 commit c64d097

2 files changed

Lines changed: 216 additions & 0 deletions

File tree

business_photo.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
6+
"github.com/gotd/td/tg"
7+
)
8+
9+
// InputProfilePhoto is a sealed union describing a profile photo to set on a
10+
// managed business account.
11+
//
12+
// Concrete variants: InputProfilePhotoStatic, InputProfilePhotoAnimated.
13+
type InputProfilePhoto interface {
14+
isInputProfilePhoto()
15+
}
16+
17+
// InputProfilePhotoStatic is a static profile photo.
18+
type InputProfilePhotoStatic struct {
19+
// Photo is the static profile photo, an uploaded file (file_id and URL are
20+
// not accepted by Telegram for profile photos).
21+
Photo InputFile
22+
}
23+
24+
// InputProfilePhotoAnimated is an animated profile photo (a short video).
25+
type InputProfilePhotoAnimated struct {
26+
// Animation is the animated profile photo, an uploaded file.
27+
Animation InputFile
28+
// MainFrameTimestamp is the timestamp in seconds of the frame used as a
29+
// static profile photo. Defaults to the start of the animation.
30+
MainFrameTimestamp float64
31+
}
32+
33+
func (InputProfilePhotoStatic) isInputProfilePhoto() {}
34+
func (InputProfilePhotoAnimated) isInputProfilePhoto() {}
35+
36+
// uploadProfileFile uploads a profile photo file. Profile photos must be raw
37+
// uploads; a file_id or URL references an existing document and cannot back the
38+
// InputFile the profile-photo RPCs need.
39+
func (b *Bot) uploadProfileFile(ctx context.Context, f InputFile) (tg.InputFileClass, error) {
40+
up, ok := f.(*InputFileUpload)
41+
if !ok {
42+
return nil, &Error{Code: 400, Description: "Bad Request: profile photo must be an uploaded file"}
43+
}
44+
45+
return b.uploadInputFile(ctx, up)
46+
}
47+
48+
// SetBusinessAccountProfilePhoto changes the profile photo of a managed business
49+
// account. When isPublic is true the photo is set as the public (fallback) photo,
50+
// shown to users who cannot see the account's main photo. The bot must have the
51+
// can_edit_profile_photo business bot right.
52+
//
53+
// The switch over the sealed InputProfilePhoto union is exhaustive
54+
// (gochecksumtype).
55+
func (b *Bot) SetBusinessAccountProfilePhoto(
56+
ctx context.Context, businessConnectionID string, photo InputProfilePhoto, isPublic bool,
57+
) error {
58+
req := &tg.PhotosUploadProfilePhotoRequest{}
59+
60+
switch p := photo.(type) {
61+
case InputProfilePhotoStatic:
62+
file, err := b.uploadProfileFile(ctx, p.Photo)
63+
if err != nil {
64+
return err
65+
}
66+
67+
req.SetFile(file)
68+
case InputProfilePhotoAnimated:
69+
video, err := b.uploadProfileFile(ctx, p.Animation)
70+
if err != nil {
71+
return err
72+
}
73+
74+
req.SetVideo(video)
75+
76+
if p.MainFrameTimestamp != 0 {
77+
req.SetVideoStartTs(p.MainFrameTimestamp)
78+
}
79+
default:
80+
return &Error{Code: 400, Description: "Bad Request: invalid profile photo"}
81+
}
82+
83+
if isPublic {
84+
req.SetFallback(true)
85+
}
86+
87+
return b.invokeBusiness(ctx, businessConnectionID, req, &tg.PhotosPhoto{})
88+
}
89+
90+
// RemoveBusinessAccountProfilePhoto removes the profile photo of a managed
91+
// business account. When isPublic is true the public (fallback) photo is removed
92+
// instead of the main one. The bot must have the can_edit_profile_photo business
93+
// bot right.
94+
func (b *Bot) RemoveBusinessAccountProfilePhoto(ctx context.Context, businessConnectionID string, isPublic bool) error {
95+
req := &tg.PhotosUpdateProfilePhotoRequest{ID: &tg.InputPhotoEmpty{}}
96+
97+
if isPublic {
98+
req.SetFallback(true)
99+
}
100+
101+
return b.invokeBusiness(ctx, businessConnectionID, req, &tg.PhotosPhoto{})
102+
}

business_photo_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/gotd/td/tg"
8+
)
9+
10+
func TestSetBusinessAccountProfilePhotoStatic(t *testing.T) {
11+
inv := newMockInvoker()
12+
inv.reply(tg.InvokeWithBusinessConnectionRequestTypeID, &tg.PhotosPhoto{Photo: &tg.PhotoEmpty{}})
13+
14+
photo := InputProfilePhotoStatic{Photo: &InputFileUpload{Name: "p.jpg", Bytes: []byte("img")}}
15+
if err := newMockBot(inv).SetBusinessAccountProfilePhoto(context.Background(), "bc1", photo, true); err != nil {
16+
t.Fatalf("SetBusinessAccountProfilePhoto: %v", err)
17+
}
18+
19+
wrapper := tg.InvokeWithBusinessConnectionRequest{Query: &tg.PhotosUploadProfilePhotoRequest{}}
20+
21+
inv.decode(t, tg.InvokeWithBusinessConnectionRequestTypeID, &wrapper)
22+
23+
if wrapper.ConnectionID != "bc1" {
24+
t.Fatalf("connection id = %q", wrapper.ConnectionID)
25+
}
26+
27+
req, ok := wrapper.Query.(*tg.PhotosUploadProfilePhotoRequest)
28+
if !ok {
29+
t.Fatalf("query = %#v", wrapper.Query)
30+
}
31+
32+
if _, ok := req.GetFile(); !ok {
33+
t.Fatal("static photo should set File")
34+
}
35+
36+
if !req.GetFallback() {
37+
t.Fatal("is_public should set Fallback")
38+
}
39+
}
40+
41+
func TestSetBusinessAccountProfilePhotoAnimated(t *testing.T) {
42+
inv := newMockInvoker()
43+
inv.reply(tg.InvokeWithBusinessConnectionRequestTypeID, &tg.PhotosPhoto{Photo: &tg.PhotoEmpty{}})
44+
45+
photo := InputProfilePhotoAnimated{
46+
Animation: &InputFileUpload{Name: "a.mp4", Bytes: []byte("vid")},
47+
MainFrameTimestamp: 1.5,
48+
}
49+
if err := newMockBot(inv).SetBusinessAccountProfilePhoto(context.Background(), "bc1", photo, false); err != nil {
50+
t.Fatalf("SetBusinessAccountProfilePhoto: %v", err)
51+
}
52+
53+
wrapper := tg.InvokeWithBusinessConnectionRequest{Query: &tg.PhotosUploadProfilePhotoRequest{}}
54+
55+
inv.decode(t, tg.InvokeWithBusinessConnectionRequestTypeID, &wrapper)
56+
57+
req, ok := wrapper.Query.(*tg.PhotosUploadProfilePhotoRequest)
58+
if !ok {
59+
t.Fatalf("query = %#v", wrapper.Query)
60+
}
61+
62+
if _, ok := req.GetVideo(); !ok {
63+
t.Fatal("animated photo should set Video")
64+
}
65+
66+
if ts, ok := req.GetVideoStartTs(); !ok || ts != 1.5 {
67+
t.Fatalf("video start ts = %v, ok=%v", ts, ok)
68+
}
69+
70+
if req.GetFallback() {
71+
t.Fatal("Fallback should be unset when is_public is false")
72+
}
73+
}
74+
75+
func TestSetBusinessAccountProfilePhotoRejectsFileID(t *testing.T) {
76+
inv := newMockInvoker()
77+
78+
photo := InputProfilePhotoStatic{Photo: FileID("abc")}
79+
80+
err := newMockBot(inv).SetBusinessAccountProfilePhoto(context.Background(), "bc1", photo, false)
81+
if err == nil {
82+
t.Fatal("expected error for non-upload profile photo")
83+
}
84+
85+
if inv.count() != 0 {
86+
t.Fatal("should not make an RPC for an invalid file")
87+
}
88+
}
89+
90+
func TestRemoveBusinessAccountProfilePhoto(t *testing.T) {
91+
inv := newMockInvoker()
92+
inv.reply(tg.InvokeWithBusinessConnectionRequestTypeID, &tg.PhotosPhoto{Photo: &tg.PhotoEmpty{}})
93+
94+
if err := newMockBot(inv).RemoveBusinessAccountProfilePhoto(context.Background(), "bc1", true); err != nil {
95+
t.Fatalf("RemoveBusinessAccountProfilePhoto: %v", err)
96+
}
97+
98+
wrapper := tg.InvokeWithBusinessConnectionRequest{Query: &tg.PhotosUpdateProfilePhotoRequest{}}
99+
100+
inv.decode(t, tg.InvokeWithBusinessConnectionRequestTypeID, &wrapper)
101+
102+
req, ok := wrapper.Query.(*tg.PhotosUpdateProfilePhotoRequest)
103+
if !ok {
104+
t.Fatalf("query = %#v", wrapper.Query)
105+
}
106+
107+
if _, ok := req.ID.(*tg.InputPhotoEmpty); !ok {
108+
t.Fatalf("id = %#v, want empty", req.ID)
109+
}
110+
111+
if !req.GetFallback() {
112+
t.Fatal("is_public should set Fallback")
113+
}
114+
}

0 commit comments

Comments
 (0)