Skip to content

Commit fbfa1c4

Browse files
ernadoclaude
andcommitted
feat(send): implement SendPhoto and SendDocument
Resolve the sealed InputFile union into a media option for all three cases: existing file_id (decoded via fileid into input photo/document), remote URL (PhotoExternal/DocumentExternal), and local upload (via the uploader -> UploadedPhoto/UploadedDocument, preserving the filename). Captions are styled by parse mode. A shared sendResolvedMedia helper styles, resolves and sends. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8e64290 commit fbfa1c4

2 files changed

Lines changed: 190 additions & 0 deletions

File tree

media.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"io"
6+
7+
"github.com/gotd/td/fileid"
8+
"github.com/gotd/td/telegram/message"
9+
"github.com/gotd/td/telegram/message/styling"
10+
"github.com/gotd/td/telegram/uploader"
11+
"github.com/gotd/td/tg"
12+
)
13+
14+
// uploadInputFile uploads a local InputFileUpload and returns the MTProto input
15+
// file handle.
16+
func (b *Bot) uploadInputFile(ctx context.Context, f *InputFileUpload) (tg.InputFileClass, error) {
17+
up := uploader.NewUploader(b.raw)
18+
name := f.Name
19+
if name == "" {
20+
name = "file"
21+
}
22+
switch {
23+
case f.Path != "":
24+
file, err := up.FromPath(ctx, f.Path)
25+
return file, asAPIError(err)
26+
case f.Bytes != nil:
27+
file, err := up.FromBytes(ctx, name, f.Bytes)
28+
return file, asAPIError(err)
29+
case f.Reader != nil:
30+
file, err := up.FromReader(ctx, name, io.Reader(f.Reader))
31+
return file, asAPIError(err)
32+
default:
33+
return nil, &Error{Code: 400, Description: "Bad Request: empty file"}
34+
}
35+
}
36+
37+
// photoMedia resolves an InputFile into a photo media option.
38+
//
39+
// The switch over the sealed InputFile union is exhaustive.
40+
func (b *Bot) photoMedia(ctx context.Context, file InputFile, caption []styling.StyledTextOption) (message.MediaOption, error) {
41+
switch f := file.(type) {
42+
case InputFileID:
43+
fid, err := fileid.DecodeFileID(string(f))
44+
if err != nil {
45+
return nil, &Error{Code: 400, Description: "Bad Request: wrong file identifier/HTTP URL specified"}
46+
}
47+
photo := &tg.InputMediaPhoto{ID: &tg.InputPhoto{
48+
ID: fid.ID,
49+
AccessHash: fid.AccessHash,
50+
FileReference: fid.FileReference,
51+
}}
52+
return message.Media(photo, caption...), nil
53+
case InputFileURL:
54+
return message.PhotoExternal(string(f), caption...), nil
55+
case *InputFileUpload:
56+
upFile, err := b.uploadInputFile(ctx, f)
57+
if err != nil {
58+
return nil, err
59+
}
60+
return message.UploadedPhoto(upFile, caption...), nil
61+
default:
62+
return nil, &Error{Code: 400, Description: "Bad Request: invalid file"}
63+
}
64+
}
65+
66+
// documentMedia resolves an InputFile into a general document media option.
67+
//
68+
// The switch over the sealed InputFile union is exhaustive.
69+
func (b *Bot) documentMedia(ctx context.Context, file InputFile, caption []styling.StyledTextOption) (message.MediaOption, error) {
70+
switch f := file.(type) {
71+
case InputFileID:
72+
fid, err := fileid.DecodeFileID(string(f))
73+
if err != nil {
74+
return nil, &Error{Code: 400, Description: "Bad Request: wrong file identifier/HTTP URL specified"}
75+
}
76+
doc := &tg.InputMediaDocument{ID: &tg.InputDocument{
77+
ID: fid.ID,
78+
AccessHash: fid.AccessHash,
79+
FileReference: fid.FileReference,
80+
}}
81+
return message.Media(doc, caption...), nil
82+
case InputFileURL:
83+
return message.DocumentExternal(string(f), caption...), nil
84+
case *InputFileUpload:
85+
upFile, err := b.uploadInputFile(ctx, f)
86+
if err != nil {
87+
return nil, err
88+
}
89+
b := message.UploadedDocument(upFile, caption...)
90+
if f.Name != "" {
91+
b = b.Filename(f.Name)
92+
}
93+
return b, nil
94+
default:
95+
return nil, &Error{Code: 400, Description: "Bad Request: invalid file"}
96+
}
97+
}
98+
99+
// sendResolvedMedia styles the caption, resolves the media and sends it.
100+
func (b *Bot) sendResolvedMedia(
101+
ctx context.Context,
102+
chat ChatID,
103+
caption string,
104+
build func(ctx context.Context, caption []styling.StyledTextOption) (message.MediaOption, error),
105+
opts ...SendOption,
106+
) (*Message, error) {
107+
var cfg sendConfig
108+
for _, o := range opts {
109+
o(&cfg)
110+
}
111+
112+
styled, err := styledText(caption, cfg.parseMode, b.peers.UserResolveHook(ctx))
113+
if err != nil {
114+
return nil, err
115+
}
116+
media, err := build(ctx, styled)
117+
if err != nil {
118+
return nil, err
119+
}
120+
121+
peer, err := b.resolveInputPeer(ctx, chat)
122+
if err != nil {
123+
return nil, err
124+
}
125+
builder := &b.sender.To(peer).Builder
126+
builder, err = b.applySendConfig(builder, cfg)
127+
if err != nil {
128+
return nil, err
129+
}
130+
131+
resp, err := builder.Media(ctx, media)
132+
return b.sentMessage(ctx, peer, resp, err)
133+
}
134+
135+
// SendPhoto sends a photo from a file_id, URL or local upload.
136+
func (b *Bot) SendPhoto(ctx context.Context, chat ChatID, photo InputFile, caption string, opts ...SendOption) (*Message, error) {
137+
return b.sendResolvedMedia(ctx, chat, caption, func(ctx context.Context, c []styling.StyledTextOption) (message.MediaOption, error) {
138+
return b.photoMedia(ctx, photo, c)
139+
}, opts...)
140+
}
141+
142+
// SendDocument sends a general file from a file_id, URL or local upload.
143+
func (b *Bot) SendDocument(ctx context.Context, chat ChatID, document InputFile, caption string, opts ...SendOption) (*Message, error) {
144+
return b.sendResolvedMedia(ctx, chat, caption, func(ctx context.Context, c []styling.StyledTextOption) (message.MediaOption, error) {
145+
return b.documentMedia(ctx, document, c)
146+
}, opts...)
147+
}

media_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
)
8+
9+
func newTestBot(t *testing.T) *Bot {
10+
t.Helper()
11+
b, err := New("123:abc", Options{AppID: 1, AppHash: "hash"})
12+
if err != nil {
13+
t.Fatal(err)
14+
}
15+
return b
16+
}
17+
18+
func TestPhotoMediaURL(t *testing.T) {
19+
b := newTestBot(t)
20+
// URL media needs no network to construct.
21+
media, err := b.photoMedia(context.Background(), FileURL("https://example.com/p.jpg"), nil)
22+
if err != nil || media == nil {
23+
t.Fatalf("url photo media: got %v, %v", media, err)
24+
}
25+
}
26+
27+
func TestMediaEmptyUploadRejected(t *testing.T) {
28+
b := newTestBot(t)
29+
_, err := b.documentMedia(context.Background(), &InputFileUpload{}, nil)
30+
var apiErr *Error
31+
if !errors.As(err, &apiErr) || apiErr.Code != 400 {
32+
t.Fatalf("empty upload should be a 400, got %v", err)
33+
}
34+
}
35+
36+
func TestPhotoMediaBadFileID(t *testing.T) {
37+
b := newTestBot(t)
38+
_, err := b.photoMedia(context.Background(), FileID("not-a-valid-file-id"), nil)
39+
var apiErr *Error
40+
if !errors.As(err, &apiErr) || apiErr.Code != 400 {
41+
t.Fatalf("bad file_id should be a 400, got %v", err)
42+
}
43+
}

0 commit comments

Comments
 (0)