Skip to content

Commit 432bc7a

Browse files
ernadoclaude
andcommitted
feat(updates): map incoming message media (fixes media example)
convertMessage never translated m.Media, so Message.Photo/Document/etc. were always empty and the media example's hasPhoto/hasDocument predicates never fired. Map incoming media onto the Bot API fields with proper file_id encoding: photos (all sizes), the document family (document/video/video note/voice/audio/animation/sticker by attribute) and contacts. Captions and caption entities are filled from m.Message on media messages. Enable Logging() middleware in the example for observability. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6ff9d74 commit 432bc7a

4 files changed

Lines changed: 301 additions & 6 deletions

File tree

convert.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -207,11 +207,22 @@ func (b *Bot) convertMessage(ctx context.Context, m *tg.Message) (*Message, erro
207207
r.ForwardOrigin = origin
208208
}
209209

210-
if m.Message != "" {
211-
r.Text = m.Message
212-
}
213-
if len(m.Entities) > 0 {
214-
r.Entities = entitiesFromTg(m.Entities)
210+
if media, ok := m.GetMedia(); ok {
211+
convertMessageMedia(media, r)
212+
// On a media message, m.Message is the caption.
213+
if m.Message != "" {
214+
r.Caption = m.Message
215+
if len(m.Entities) > 0 {
216+
r.CaptionEntities = entitiesFromTg(m.Entities)
217+
}
218+
}
219+
} else {
220+
if m.Message != "" {
221+
r.Text = m.Message
222+
}
223+
if len(m.Entities) > 0 {
224+
r.Entities = entitiesFromTg(m.Entities)
225+
}
215226
}
216227
if mkp, ok := m.ReplyMarkup.(*tg.ReplyInlineMarkup); ok {
217228
r.ReplyMarkup = inlineKeyboardFromTg(mkp)

convert_media.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package botapi
2+
3+
import (
4+
"github.com/gotd/td/fileid"
5+
"github.com/gotd/td/tg"
6+
)
7+
8+
// encodeFileID encodes a parsed file location into the Bot API file_id and
9+
// file_unique_id strings. The unique-id derivation lives in file.go.
10+
func encodeFileID(f fileid.FileID) (fileID, uniqueID string) {
11+
fileID, _ = fileid.EncodeFileID(f)
12+
return fileID, fileUniqueID(f)
13+
}
14+
15+
// convertMessageMedia maps an incoming tg media attachment onto the Bot API
16+
// Message media fields. Only the attachment types a bot can receive are mapped;
17+
// web pages and unsupported kinds are ignored.
18+
func convertMessageMedia(media tg.MessageMediaClass, r *Message) {
19+
switch media := media.(type) {
20+
case *tg.MessageMediaPhoto:
21+
if p, ok := media.Photo.(*tg.Photo); ok {
22+
r.Photo = photoSizesFromTg(p)
23+
}
24+
case *tg.MessageMediaDocument:
25+
if d, ok := media.Document.(*tg.Document); ok {
26+
setDocumentMedia(d, r)
27+
}
28+
case *tg.MessageMediaContact:
29+
r.Contact = &Contact{
30+
PhoneNumber: media.PhoneNumber,
31+
FirstName: media.FirstName,
32+
LastName: media.LastName,
33+
UserID: media.UserID,
34+
VCard: media.Vcard,
35+
}
36+
}
37+
}
38+
39+
// photoSizesFromTg converts a tg.Photo into the Bot API list of photo sizes,
40+
// each carrying a usable file_id.
41+
func photoSizesFromTg(photo *tg.Photo) []PhotoSize {
42+
type sized interface {
43+
GetW() int
44+
GetH() int
45+
GetType() string
46+
}
47+
48+
var out []PhotoSize
49+
for _, sz := range photo.Sizes {
50+
size, ok := sz.(sized)
51+
if !ok {
52+
continue
53+
}
54+
t := size.GetType()
55+
if t == "" {
56+
continue
57+
}
58+
59+
var fileSize int
60+
switch s := sz.(type) {
61+
case *tg.PhotoSize:
62+
fileSize = s.Size
63+
case *tg.PhotoCachedSize:
64+
fileSize = len(s.Bytes)
65+
}
66+
67+
fileID, uniqueID := encodeFileID(fileid.FromPhoto(photo, rune(t[0])))
68+
out = append(out, PhotoSize{
69+
FileID: fileID,
70+
FileUniqueID: uniqueID,
71+
Width: size.GetW(),
72+
Height: size.GetH(),
73+
FileSize: fileSize,
74+
})
75+
}
76+
return out
77+
}
78+
79+
// setDocumentMedia inspects a document's attributes and fills the matching Bot
80+
// API media field (sticker, video, video note, voice, audio, animation, or a
81+
// generic document).
82+
func setDocumentMedia(d *tg.Document, r *Message) {
83+
fileID, uniqueID := encodeFileID(fileid.FromDocument(d))
84+
85+
var (
86+
fileName string
87+
width, height int
88+
duration int
89+
animated, round bool
90+
)
91+
for _, attr := range d.Attributes {
92+
switch a := attr.(type) {
93+
case *tg.DocumentAttributeFilename:
94+
fileName = a.FileName
95+
case *tg.DocumentAttributeImageSize:
96+
width, height = a.W, a.H
97+
case *tg.DocumentAttributeVideo:
98+
width, height, duration, round = a.W, a.H, int(a.Duration), a.RoundMessage
99+
case *tg.DocumentAttributeAnimated:
100+
animated = true
101+
}
102+
}
103+
104+
// The attribute set determines the concrete Bot API type. Order matters:
105+
// stickers, then animations, then video/voice/audio, else a plain document.
106+
for _, attr := range d.Attributes {
107+
switch a := attr.(type) {
108+
case *tg.DocumentAttributeSticker:
109+
r.Sticker = &Sticker{
110+
FileID: fileID,
111+
FileUniqueID: uniqueID,
112+
Type: StickerRegular,
113+
Width: width,
114+
Height: height,
115+
Emoji: a.Alt,
116+
FileSize: int(d.Size),
117+
}
118+
return
119+
case *tg.DocumentAttributeAudio:
120+
if a.Voice {
121+
r.Voice = &Voice{
122+
FileID: fileID,
123+
FileUniqueID: uniqueID,
124+
Duration: a.Duration,
125+
MIMEType: d.MimeType,
126+
FileSize: d.Size,
127+
}
128+
} else {
129+
r.Audio = &Audio{
130+
FileID: fileID,
131+
FileUniqueID: uniqueID,
132+
Duration: a.Duration,
133+
Performer: a.Performer,
134+
Title: a.Title,
135+
FileName: fileName,
136+
MIMEType: d.MimeType,
137+
FileSize: d.Size,
138+
}
139+
}
140+
return
141+
}
142+
}
143+
144+
switch {
145+
case animated:
146+
r.Animation = &Animation{
147+
FileID: fileID,
148+
FileUniqueID: uniqueID,
149+
Width: width,
150+
Height: height,
151+
Duration: duration,
152+
FileName: fileName,
153+
MIMEType: d.MimeType,
154+
FileSize: d.Size,
155+
}
156+
case round:
157+
r.VideoNote = &VideoNote{
158+
FileID: fileID,
159+
FileUniqueID: uniqueID,
160+
Length: width,
161+
Duration: duration,
162+
FileSize: int(d.Size),
163+
}
164+
case width != 0 || height != 0:
165+
r.Video = &Video{
166+
FileID: fileID,
167+
FileUniqueID: uniqueID,
168+
Width: width,
169+
Height: height,
170+
Duration: duration,
171+
FileName: fileName,
172+
MIMEType: d.MimeType,
173+
FileSize: d.Size,
174+
}
175+
default:
176+
r.Document = &Document{
177+
FileID: fileID,
178+
FileUniqueID: uniqueID,
179+
FileName: fileName,
180+
MIMEType: d.MimeType,
181+
FileSize: d.Size,
182+
}
183+
}
184+
}

convert_media_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package botapi
2+
3+
import (
4+
"testing"
5+
6+
"github.com/gotd/td/tg"
7+
)
8+
9+
func TestConvertMessageMedia_Photo(t *testing.T) {
10+
photo := &tg.Photo{
11+
ID: 10,
12+
AccessHash: 20,
13+
FileReference: []byte{1, 2, 3},
14+
DCID: 2,
15+
Sizes: []tg.PhotoSizeClass{
16+
&tg.PhotoSize{Type: "x", W: 800, H: 600, Size: 1234},
17+
},
18+
}
19+
var r Message
20+
convertMessageMedia(&tg.MessageMediaPhoto{Photo: photo}, &r)
21+
22+
if len(r.Photo) != 1 {
23+
t.Fatalf("want 1 photo size, got %d", len(r.Photo))
24+
}
25+
got := r.Photo[0]
26+
if got.Width != 800 || got.Height != 600 || got.FileSize != 1234 {
27+
t.Fatalf("dimensions: %#v", got)
28+
}
29+
if got.FileID == "" || got.FileUniqueID == "" {
30+
t.Fatalf("file ids not set: %#v", got)
31+
}
32+
}
33+
34+
func docWith(attrs ...tg.DocumentAttributeClass) *tg.MessageMediaDocument {
35+
return &tg.MessageMediaDocument{Document: &tg.Document{
36+
ID: 1,
37+
AccessHash: 2,
38+
FileReference: []byte{9},
39+
DCID: 2,
40+
Size: 5000,
41+
MimeType: "application/octet-stream",
42+
Attributes: attrs,
43+
}}
44+
}
45+
46+
func TestConvertMessageMedia_Document(t *testing.T) {
47+
var r Message
48+
convertMessageMedia(docWith(&tg.DocumentAttributeFilename{FileName: "report.pdf"}), &r)
49+
if r.Document == nil {
50+
t.Fatal("document not set")
51+
}
52+
if r.Document.FileName != "report.pdf" || r.Document.FileSize != 5000 || r.Document.FileID == "" {
53+
t.Fatalf("document: %#v", r.Document)
54+
}
55+
}
56+
57+
func TestConvertMessageMedia_Video(t *testing.T) {
58+
var r Message
59+
convertMessageMedia(docWith(&tg.DocumentAttributeVideo{W: 1280, H: 720, Duration: 30}), &r)
60+
if r.Video == nil {
61+
t.Fatalf("video not set: %#v", r)
62+
}
63+
if r.Video.Width != 1280 || r.Video.Height != 720 || r.Video.Duration != 30 {
64+
t.Fatalf("video: %#v", r.Video)
65+
}
66+
}
67+
68+
func TestConvertMessageMedia_Voice(t *testing.T) {
69+
var r Message
70+
convertMessageMedia(docWith(&tg.DocumentAttributeAudio{Voice: true, Duration: 7}), &r)
71+
if r.Voice == nil || r.Voice.Duration != 7 {
72+
t.Fatalf("voice: %#v", r.Voice)
73+
}
74+
if r.Audio != nil {
75+
t.Fatal("voice must not also set Audio")
76+
}
77+
}
78+
79+
func TestConvertMessageMedia_Sticker(t *testing.T) {
80+
var r Message
81+
convertMessageMedia(docWith(
82+
&tg.DocumentAttributeSticker{Alt: "😀"},
83+
&tg.DocumentAttributeImageSize{W: 512, H: 512},
84+
), &r)
85+
if r.Sticker == nil || r.Sticker.Emoji != "😀" || r.Sticker.Width != 512 {
86+
t.Fatalf("sticker: %#v", r.Sticker)
87+
}
88+
}
89+
90+
func TestConvertMessageMedia_Contact(t *testing.T) {
91+
var r Message
92+
convertMessageMedia(&tg.MessageMediaContact{
93+
PhoneNumber: "+1",
94+
FirstName: "Ada",
95+
Vcard: "BEGIN:VCARD",
96+
}, &r)
97+
if r.Contact == nil || r.Contact.PhoneNumber != "+1" || r.Contact.VCard != "BEGIN:VCARD" {
98+
t.Fatalf("contact: %#v", r.Contact)
99+
}
100+
}

examples/media/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func main() {
4646
log.Fatal("Create bot", zap.Error(err))
4747
}
4848

49-
bot.Use(botapi.Recover(), botapi.Timeout(time.Minute))
49+
bot.Use(botapi.Recover(), botapi.Timeout(time.Minute), botapi.Logging())
5050

5151
bot.OnCommand("start", func(c *botapi.Context) error {
5252
_, err := c.Reply("Send me a photo or a file and I'll send it back. Or try /photo.")

0 commit comments

Comments
 (0)