Skip to content

Commit 07853cc

Browse files
ernadoclaude
andcommitted
feat(send): implement SendMediaGroup (uploaded albums)
SendMediaGroup sends 2-10 InputMedia items as an album. Each item is resolved to a typed MultiMediaOption (photo/video/animation/audio/document) and sent via the sender's Album builder; the response is unpacked into one Message per item. file_id and URL items are rejected for now, since only freshly uploaded files satisfy the high-level album API. The InputMedia switch is exhaustive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 33df89c commit 07853cc

2 files changed

Lines changed: 199 additions & 0 deletions

File tree

media_group.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
6+
"github.com/gotd/td/telegram/message"
7+
"github.com/gotd/td/telegram/message/styling"
8+
"github.com/gotd/td/tg"
9+
)
10+
11+
// errNonUploadInAlbum is returned for album items that reference a file_id or
12+
// URL. The high-level send API can only place freshly uploaded files in a group.
13+
var errNonUploadInAlbum = &Error{
14+
Code: 400,
15+
Description: "Bad Request: media group items must be uploaded files (file_id and URL are not supported yet)",
16+
}
17+
18+
// photoMulti resolves a photo InputFile into an album item. Only uploads are
19+
// supported in groups.
20+
func (b *Bot) photoMulti(ctx context.Context, file InputFile, caption []styling.StyledTextOption) (message.MultiMediaOption, error) {
21+
up, ok := file.(*InputFileUpload)
22+
if !ok {
23+
return nil, errNonUploadInAlbum
24+
}
25+
upFile, err := b.uploadInputFile(ctx, up)
26+
if err != nil {
27+
return nil, err
28+
}
29+
return message.UploadedPhoto(upFile, caption...), nil
30+
}
31+
32+
// docMulti resolves a document-family InputFile into an album item, applying the
33+
// typed attribute builder. Only uploads are supported in groups.
34+
func (b *Bot) docMulti(
35+
ctx context.Context,
36+
file InputFile,
37+
caption []styling.StyledTextOption,
38+
typed func(*message.UploadedDocumentBuilder) message.MultiMediaOption,
39+
) (message.MultiMediaOption, error) {
40+
up, ok := file.(*InputFileUpload)
41+
if !ok {
42+
return nil, errNonUploadInAlbum
43+
}
44+
upFile, err := b.uploadInputFile(ctx, up)
45+
if err != nil {
46+
return nil, err
47+
}
48+
doc := message.UploadedDocument(upFile, caption...)
49+
if up.Name != "" {
50+
doc = doc.Filename(up.Name)
51+
}
52+
return typed(doc), nil
53+
}
54+
55+
// inputMediaToMulti resolves a single InputMedia into an album item.
56+
//
57+
// The switch over the sealed InputMedia union is exhaustive.
58+
func (b *Bot) inputMediaToMulti(ctx context.Context, m InputMedia) (message.MultiMediaOption, error) {
59+
resolver := b.peers.UserResolveHook(ctx)
60+
plain := func(d *message.UploadedDocumentBuilder) message.MultiMediaOption { return d }
61+
switch m := m.(type) {
62+
case *InputMediaPhoto:
63+
caption, err := styledText(m.Caption, m.ParseMode, resolver)
64+
if err != nil {
65+
return nil, err
66+
}
67+
return b.photoMulti(ctx, m.Media, caption)
68+
case *InputMediaVideo:
69+
caption, err := styledText(m.Caption, m.ParseMode, resolver)
70+
if err != nil {
71+
return nil, err
72+
}
73+
return b.docMulti(ctx, m.Media, caption, func(d *message.UploadedDocumentBuilder) message.MultiMediaOption { return d.Video() })
74+
case *InputMediaAnimation:
75+
caption, err := styledText(m.Caption, m.ParseMode, resolver)
76+
if err != nil {
77+
return nil, err
78+
}
79+
return b.docMulti(ctx, m.Media, caption, func(d *message.UploadedDocumentBuilder) message.MultiMediaOption { return d.GIF() })
80+
case *InputMediaAudio:
81+
caption, err := styledText(m.Caption, m.ParseMode, resolver)
82+
if err != nil {
83+
return nil, err
84+
}
85+
return b.docMulti(ctx, m.Media, caption, func(d *message.UploadedDocumentBuilder) message.MultiMediaOption { return d.Audio() })
86+
case *InputMediaDocument:
87+
caption, err := styledText(m.Caption, m.ParseMode, resolver)
88+
if err != nil {
89+
return nil, err
90+
}
91+
return b.docMulti(ctx, m.Media, caption, plain)
92+
default:
93+
return nil, &Error{Code: 400, Description: "Bad Request: invalid media"}
94+
}
95+
}
96+
97+
// sentMessages extracts every message produced by a send into Bot API messages.
98+
func (b *Bot) sentMessages(ctx context.Context, resp tg.UpdatesClass, sendErr error) ([]*Message, error) {
99+
if sendErr != nil {
100+
return nil, asAPIError(sendErr)
101+
}
102+
103+
var updates []tg.UpdateClass
104+
switch u := resp.(type) {
105+
case *tg.Updates:
106+
updates = u.Updates
107+
case *tg.UpdatesCombined:
108+
updates = u.Updates
109+
default:
110+
return nil, nil
111+
}
112+
113+
var out []*Message
114+
for _, upd := range updates {
115+
var msg tg.MessageClass
116+
switch u := upd.(type) {
117+
case *tg.UpdateNewMessage:
118+
msg = u.Message
119+
case *tg.UpdateNewChannelMessage:
120+
msg = u.Message
121+
default:
122+
continue
123+
}
124+
m, ok := msg.(*tg.Message)
125+
if !ok {
126+
continue
127+
}
128+
converted, err := b.convertMessage(ctx, m)
129+
if err != nil {
130+
return nil, err
131+
}
132+
out = append(out, converted)
133+
}
134+
return out, nil
135+
}
136+
137+
// SendMediaGroup sends a group of 2-10 photos, videos, documents or audio files
138+
// as an album. Items referencing an existing file_id are not yet supported.
139+
func (b *Bot) SendMediaGroup(ctx context.Context, chat ChatID, media []InputMedia, opts ...SendOption) ([]*Message, error) {
140+
if len(media) < 2 || len(media) > 10 {
141+
return nil, &Error{Code: 400, Description: "Bad Request: media group must include 2-10 items"}
142+
}
143+
144+
var cfg sendConfig
145+
for _, o := range opts {
146+
o(&cfg)
147+
}
148+
149+
items := make([]message.MultiMediaOption, len(media))
150+
for i, m := range media {
151+
mm, err := b.inputMediaToMulti(ctx, m)
152+
if err != nil {
153+
return nil, err
154+
}
155+
items[i] = mm
156+
}
157+
158+
peer, err := b.resolveInputPeer(ctx, chat)
159+
if err != nil {
160+
return nil, err
161+
}
162+
builder := &b.sender.To(peer).Builder
163+
builder, err = b.applySendConfig(builder, cfg)
164+
if err != nil {
165+
return nil, err
166+
}
167+
168+
resp, err := builder.Album(ctx, items[0], items[1:]...)
169+
return b.sentMessages(ctx, resp, err)
170+
}

media_group_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
)
8+
9+
func TestSendMediaGroupCountValidation(t *testing.T) {
10+
b := newTestBot(t)
11+
_, err := b.SendMediaGroup(context.Background(), ID(1), []InputMedia{
12+
&InputMediaPhoto{Type: InputMediaPhotoType, Media: FileFromBytes("a", []byte("x"))},
13+
})
14+
var apiErr *Error
15+
if !errors.As(err, &apiErr) || apiErr.Code != 400 {
16+
t.Fatalf("single-item group should be a 400, got %v", err)
17+
}
18+
}
19+
20+
func TestMediaGroupRejectsNonUpload(t *testing.T) {
21+
b := newTestBot(t)
22+
_, err := b.inputMediaToMulti(context.Background(), &InputMediaPhoto{
23+
Type: InputMediaPhotoType,
24+
Media: FileURL("https://example.com/a.jpg"),
25+
})
26+
if !errors.Is(err, error(errNonUploadInAlbum)) {
27+
t.Fatalf("URL item in album should be rejected, got %v", err)
28+
}
29+
}

0 commit comments

Comments
 (0)