Skip to content

Commit 9dc8c08

Browse files
ernadoclaude
andcommitted
feat: send media on behalf of a business account
Route the media send paths (sendResolvedMedia, sendMedia, SendMediaGroup) through senderFor(cfg) so WithBusinessConnection wraps the send RPC. File uploads already run on b.raw (the bot session) inside the media builders, so they stay unwrapped; only messages.sendMedia (and, for albums, uploadMedia + sendMultiMedia) are wrapped in invokeWithBusinessConnection. Adds BusinessContext.SendPhoto/SendDocument and broadens the WithBusinessConnection doc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 101779f commit 9dc8c08

6 files changed

Lines changed: 199 additions & 5 deletions

File tree

business_context.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,18 @@ func (c *BusinessContext) DeleteMessages(ctx context.Context, messageIDs []int)
7878
func (c *BusinessContext) SendMessage(ctx context.Context, chat ChatID, text string, opts ...SendOption) (*Message, error) {
7979
return c.bot.SendMessage(ctx, chat, text, append(opts, WithBusinessConnection(c.connectionID))...)
8080
}
81+
82+
// SendPhoto sends a photo to a chat on behalf of the connected business account.
83+
func (c *BusinessContext) SendPhoto(
84+
ctx context.Context, chat ChatID, photo InputFile, caption string, opts ...SendOption,
85+
) (*Message, error) {
86+
return c.bot.SendPhoto(ctx, chat, photo, caption, append(opts, WithBusinessConnection(c.connectionID))...)
87+
}
88+
89+
// SendDocument sends a general file to a chat on behalf of the connected business
90+
// account.
91+
func (c *BusinessContext) SendDocument(
92+
ctx context.Context, chat ChatID, document InputFile, caption string, opts ...SendOption,
93+
) (*Message, error) {
94+
return c.bot.SendDocument(ctx, chat, document, caption, append(opts, WithBusinessConnection(c.connectionID))...)
95+
}

business_media_test.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/gotd/td/bin"
9+
"github.com/gotd/td/tg"
10+
)
11+
12+
// businessAlbumHandler answers the wrapped RPCs an album send issues on behalf of
13+
// a business account: uploadMedia per item (returning a usable photo) and the
14+
// final sendMultiMedia.
15+
func businessAlbumHandler(t *testing.T, connSeen *string) func(*bin.Buffer) (bin.Encoder, error) {
16+
t.Helper()
17+
18+
return func(buf *bin.Buffer) (bin.Encoder, error) {
19+
if err := buf.ConsumeID(tg.InvokeWithBusinessConnectionRequestTypeID); err != nil {
20+
return nil, err
21+
}
22+
23+
id, err := buf.String()
24+
if err != nil {
25+
return nil, err
26+
}
27+
28+
*connSeen = id
29+
30+
inner, err := buf.PeekID()
31+
if err != nil {
32+
return nil, err
33+
}
34+
35+
switch inner {
36+
case tg.MessagesUploadMediaRequestTypeID:
37+
m := &tg.MessageMediaPhoto{}
38+
m.SetPhoto(&tg.Photo{
39+
ID: 1, AccessHash: 2, FileReference: []byte{0}, DCID: 2,
40+
Sizes: []tg.PhotoSizeClass{&tg.PhotoSize{Type: "x", W: 1, H: 1, Size: 1}},
41+
})
42+
43+
return m, nil
44+
case tg.MessagesSendMultiMediaRequestTypeID:
45+
return multiMessageUpdates(11, 12), nil
46+
default:
47+
return nil, fmt.Errorf("unexpected inner request %#x", inner)
48+
}
49+
}
50+
}
51+
52+
func TestSendPhotoWithBusinessConnection(t *testing.T) {
53+
inv := newMockInvoker()
54+
inv.reply(tg.InvokeWithBusinessConnectionRequestTypeID, sendMediaOK())
55+
56+
b := newMockBot(inv)
57+
58+
photo := &InputFileUpload{Name: "p.jpg", Bytes: []byte("img")}
59+
if _, err := b.SendPhoto(context.Background(), userRef(10, 20), photo, "hi", WithBusinessConnection("bc1")); err != nil {
60+
t.Fatalf("SendPhoto: %v", err)
61+
}
62+
63+
// The file upload must stay in the bot session, unwrapped.
64+
if !inv.called(tg.UploadSaveFilePartRequestTypeID) {
65+
t.Fatal("expected a direct file upload")
66+
}
67+
68+
wrapper := tg.InvokeWithBusinessConnectionRequest{Query: &tg.MessagesSendMediaRequest{}}
69+
70+
inv.decode(t, tg.InvokeWithBusinessConnectionRequestTypeID, &wrapper)
71+
72+
if wrapper.ConnectionID != "bc1" {
73+
t.Fatalf("connection id = %q", wrapper.ConnectionID)
74+
}
75+
76+
send, ok := wrapper.Query.(*tg.MessagesSendMediaRequest)
77+
if !ok {
78+
t.Fatalf("query = %#v", wrapper.Query)
79+
}
80+
81+
if _, ok := send.Media.(*tg.InputMediaUploadedPhoto); !ok {
82+
t.Fatalf("media = %#v, want uploaded photo", send.Media)
83+
}
84+
}
85+
86+
func TestSendDocumentWithBusinessConnection(t *testing.T) {
87+
inv := newMockInvoker()
88+
inv.reply(tg.InvokeWithBusinessConnectionRequestTypeID, sendMediaOK())
89+
90+
b := newMockBot(inv)
91+
92+
doc := &InputFileUpload{Name: "f.bin", Bytes: []byte("data")}
93+
if _, err := b.SendDocument(context.Background(), userRef(10, 20), doc, "", WithBusinessConnection("bc2")); err != nil {
94+
t.Fatalf("SendDocument: %v", err)
95+
}
96+
97+
wrapper := tg.InvokeWithBusinessConnectionRequest{Query: &tg.MessagesSendMediaRequest{}}
98+
99+
inv.decode(t, tg.InvokeWithBusinessConnectionRequestTypeID, &wrapper)
100+
101+
if wrapper.ConnectionID != "bc2" {
102+
t.Fatalf("connection id = %q", wrapper.ConnectionID)
103+
}
104+
105+
send, ok := wrapper.Query.(*tg.MessagesSendMediaRequest)
106+
if !ok {
107+
t.Fatalf("query = %#v", wrapper.Query)
108+
}
109+
110+
if _, ok := send.Media.(*tg.InputMediaUploadedDocument); !ok {
111+
t.Fatalf("media = %#v, want uploaded document", send.Media)
112+
}
113+
}
114+
115+
func TestSendPhotoByFileIDWithBusinessConnection(t *testing.T) {
116+
fid := documentFileID(t, 0x91)
117+
118+
inv := newMockInvoker()
119+
inv.reply(tg.InvokeWithBusinessConnectionRequestTypeID, sendMediaOK())
120+
121+
b := newMockBot(inv)
122+
123+
// A file_id send performs no upload; only the send is wrapped.
124+
if _, err := b.SendPhoto(context.Background(), userRef(10, 20), FileID(fid), "", WithBusinessConnection("bc3")); err != nil {
125+
t.Fatalf("SendPhoto: %v", err)
126+
}
127+
128+
if inv.called(tg.UploadSaveFilePartRequestTypeID) {
129+
t.Fatal("a file_id send must not upload")
130+
}
131+
132+
if !inv.called(tg.InvokeWithBusinessConnectionRequestTypeID) {
133+
t.Fatal("expected a wrapped send")
134+
}
135+
}
136+
137+
func TestSendMediaGroupWithBusinessConnection(t *testing.T) {
138+
inv := newMockInvoker()
139+
140+
var connSeen string
141+
142+
inv.handle(tg.InvokeWithBusinessConnectionRequestTypeID, businessAlbumHandler(t, &connSeen))
143+
144+
b := newMockBot(inv)
145+
146+
media := []InputMedia{
147+
&InputMediaPhoto{Media: &InputFileUpload{Name: "a.jpg", Bytes: []byte("a")}},
148+
&InputMediaPhoto{Media: &InputFileUpload{Name: "b.jpg", Bytes: []byte("b")}},
149+
}
150+
151+
msgs, err := b.SendMediaGroup(context.Background(), userRef(10, 20), media, WithBusinessConnection("bc7"))
152+
if err != nil {
153+
t.Fatalf("SendMediaGroup: %v", err)
154+
}
155+
156+
if len(msgs) != 2 {
157+
t.Fatalf("got %d messages", len(msgs))
158+
}
159+
160+
if connSeen != "bc7" {
161+
t.Fatalf("connection id = %q", connSeen)
162+
}
163+
164+
// Uploads happen in the bot session, unwrapped.
165+
if !inv.called(tg.UploadSaveFilePartRequestTypeID) {
166+
t.Fatal("expected direct file uploads")
167+
}
168+
169+
// The final wrapped call is the multi-media send.
170+
wrapper := tg.InvokeWithBusinessConnectionRequest{Query: &tg.MessagesSendMultiMediaRequest{}}
171+
172+
inv.decode(t, tg.InvokeWithBusinessConnectionRequestTypeID, &wrapper)
173+
174+
if _, ok := wrapper.Query.(*tg.MessagesSendMultiMediaRequest); !ok {
175+
t.Fatalf("final query = %#v, want sendMultiMedia", wrapper.Query)
176+
}
177+
}

media.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ func (b *Bot) sendResolvedMedia(
134134
return nil, err
135135
}
136136

137-
builder := &b.sender.To(peer).Builder
137+
builder := &b.senderFor(cfg).To(peer).Builder
138138

139139
builder, err = b.applySendConfig(builder, cfg)
140140
if err != nil {

media_group.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ func (b *Bot) SendMediaGroup(ctx context.Context, chat ChatID, media []InputMedi
180180
return nil, err
181181
}
182182

183-
builder := &b.sender.To(peer).Builder
183+
builder := &b.senderFor(cfg).To(peer).Builder
184184

185185
builder, err = b.applySendConfig(builder, cfg)
186186
if err != nil {

send.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,10 @@ func WithParseMode(m ParseMode) SendOption { return func(c *sendConfig) { c.pars
4747
// WithBusinessConnection sends the message on behalf of the business account
4848
// identified by the given connection id, instead of as the bot itself.
4949
//
50-
// It currently applies to SendMessage (text). Sending media on behalf of a
51-
// business account is not yet supported.
50+
// It applies to the text and media send methods (SendMessage, SendPhoto,
51+
// SendDocument, SendVideo, SendAudio, SendMediaGroup, …). Files are uploaded in
52+
// the bot's own session; only the resulting send is performed on behalf of the
53+
// account.
5254
func WithBusinessConnection(connectionID string) SendOption {
5355
return func(c *sendConfig) { c.businessConn = connectionID }
5456
}

send_other.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func (b *Bot) sendMedia(ctx context.Context, chat ChatID, media message.MediaOpt
2020
return nil, err
2121
}
2222

23-
builder := &b.sender.To(peer).Builder
23+
builder := &b.senderFor(cfg).To(peer).Builder
2424

2525
builder, err = b.applySendConfig(builder, cfg)
2626
if err != nil {

0 commit comments

Comments
 (0)