Skip to content

Commit 831ea9e

Browse files
ernadoclaude
andcommitted
feat(edit): implement EditMessageMedia
Replace a message's media and caption: InputMedia -> tg.InputMedia for photo and the document family, accepting file_id, URL or upload. Caption honors explicit entities or a parse mode; reply markup via SendOption. Drop editMessageMedia from the conformance deferred list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e3dd0dc commit 831ea9e

4 files changed

Lines changed: 150 additions & 3 deletions

File tree

conformance_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ var coveredByOtherMeans = map[string]string{
2121
// implemented. Each is an acknowledged gap tracked in docs/roadmap.md; the
2222
// conformance test allows them so it can still catch *unacknowledged* drift.
2323
var deferredMethods = map[string]string{
24-
"editMessageMedia": "Phase 3 deferred",
2524
"answerPreCheckoutQuery": "payments — deferred until payment updates land",
2625
"answerShippingQuery": "payments — deferred until payment updates land",
2726
"sendInvoice": "payments — deferred",

edit_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+
6+
"github.com/gotd/td/tg"
7+
)
8+
9+
// EditMessageMedia replaces the media (and caption) of a message. The new media
10+
// may reference an existing file_id, an HTTP URL Telegram fetches, or a local
11+
// upload.
12+
func (b *Bot) EditMessageMedia(ctx context.Context, chat ChatID, messageID int, media InputMedia, opts ...SendOption) (*Message, error) {
13+
var cfg sendConfig
14+
for _, o := range opts {
15+
o(&cfg)
16+
}
17+
18+
peer, err := b.resolveInputPeer(ctx, chat)
19+
if err != nil {
20+
return nil, err
21+
}
22+
23+
tgMedia, capt, err := b.inputMediaToTg(ctx, media)
24+
if err != nil {
25+
return nil, err
26+
}
27+
28+
req := &tg.MessagesEditMessageRequest{Peer: peer, ID: messageID}
29+
req.SetMedia(tgMedia)
30+
31+
msg, entities, err := b.caption(ctx, capt)
32+
if err != nil {
33+
return nil, err
34+
}
35+
if msg != "" {
36+
req.Message = msg
37+
}
38+
if len(entities) > 0 {
39+
req.Entities = entities
40+
}
41+
if cfg.markup != nil {
42+
mkp, err := replyMarkupToTg(cfg.markup)
43+
if err != nil {
44+
return nil, err
45+
}
46+
req.SetReplyMarkup(mkp)
47+
}
48+
49+
resp, err := b.raw.MessagesEditMessage(ctx, req)
50+
return b.sentMessage(ctx, peer, resp, err)
51+
}
52+
53+
// captionData is the caption text and how to format it, extracted from an
54+
// InputMedia.
55+
type captionData struct {
56+
text string
57+
parseMode ParseMode
58+
entities []MessageEntity
59+
}
60+
61+
// caption resolves a caption into a message string and MTProto entities,
62+
// honoring explicit entities or a parse mode.
63+
func (b *Bot) caption(ctx context.Context, c captionData) (string, []tg.MessageEntityClass, error) {
64+
if len(c.entities) > 0 {
65+
return c.text, entitiesToTg(c.entities), nil
66+
}
67+
if c.parseMode != ParseModeNone && c.text != "" {
68+
return b.styledMessage(ctx, c.text, c.parseMode)
69+
}
70+
return c.text, nil, nil
71+
}
72+
73+
// inputMediaToTg converts a Bot API InputMedia into the MTProto media and its
74+
// caption. The switch over the sealed InputMedia union is exhaustive.
75+
func (b *Bot) inputMediaToTg(ctx context.Context, m InputMedia) (tg.InputMediaClass, captionData, error) {
76+
switch m := m.(type) {
77+
case *InputMediaPhoto:
78+
media, err := b.photoInputMedia(ctx, m.Media)
79+
return media, captionData{m.Caption, m.ParseMode, m.CaptionEntities}, err
80+
case *InputMediaVideo:
81+
media, err := b.documentInputMedia(ctx, m.Media, "video/mp4")
82+
return media, captionData{m.Caption, m.ParseMode, m.CaptionEntities}, err
83+
case *InputMediaAnimation:
84+
media, err := b.documentInputMedia(ctx, m.Media, "video/mp4")
85+
return media, captionData{m.Caption, m.ParseMode, m.CaptionEntities}, err
86+
case *InputMediaAudio:
87+
media, err := b.documentInputMedia(ctx, m.Media, "audio/mpeg")
88+
return media, captionData{m.Caption, m.ParseMode, m.CaptionEntities}, err
89+
case *InputMediaDocument:
90+
media, err := b.documentInputMedia(ctx, m.Media, "application/octet-stream")
91+
return media, captionData{m.Caption, m.ParseMode, m.CaptionEntities}, err
92+
default:
93+
return nil, captionData{}, &Error{Code: 400, Description: "Bad Request: unsupported input media type"}
94+
}
95+
}
96+
97+
// photoInputMedia resolves an InputFile into MTProto photo media.
98+
//
99+
// The switch over the sealed InputFile union is exhaustive.
100+
func (b *Bot) photoInputMedia(ctx context.Context, file InputFile) (tg.InputMediaClass, error) {
101+
switch f := file.(type) {
102+
case InputFileID:
103+
photo, err := inputPhotoFromFileID(string(f))
104+
if err != nil {
105+
return nil, err
106+
}
107+
return &tg.InputMediaPhoto{ID: photo}, nil
108+
case InputFileURL:
109+
return &tg.InputMediaPhotoExternal{URL: string(f)}, nil
110+
case *InputFileUpload:
111+
uploaded, err := b.uploadInputFile(ctx, f)
112+
if err != nil {
113+
return nil, err
114+
}
115+
return &tg.InputMediaUploadedPhoto{File: uploaded}, nil
116+
default:
117+
return nil, &Error{Code: 400, Description: descInvalidFile}
118+
}
119+
}
120+
121+
// documentInputMedia resolves an InputFile into MTProto document media.
122+
//
123+
// The switch over the sealed InputFile union is exhaustive.
124+
func (b *Bot) documentInputMedia(ctx context.Context, file InputFile, mimeType string) (tg.InputMediaClass, error) {
125+
switch f := file.(type) {
126+
case InputFileID:
127+
doc, err := inputDocumentFromFileID(string(f))
128+
if err != nil {
129+
return nil, err
130+
}
131+
return &tg.InputMediaDocument{ID: doc}, nil
132+
case InputFileURL:
133+
return &tg.InputMediaDocumentExternal{URL: string(f)}, nil
134+
case *InputFileUpload:
135+
uploaded, err := b.uploadInputFile(ctx, f)
136+
if err != nil {
137+
return nil, err
138+
}
139+
media := &tg.InputMediaUploadedDocument{File: uploaded, MimeType: mimeType}
140+
if f.Name != "" {
141+
media.Attributes = []tg.DocumentAttributeClass{&tg.DocumentAttributeFilename{FileName: f.Name}}
142+
}
143+
return media, nil
144+
default:
145+
return nil, &Error{Code: 400, Description: descInvalidFile}
146+
}
147+
}

errors_map.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const (
1515
descChatNotFound = "Bad Request: chat not found"
1616
descWrongFileID = "Bad Request: wrong file identifier/HTTP URL specified"
1717
descUnauthorized = "Unauthorized"
18+
descInvalidFile = "Bad Request: invalid file"
1819

1920
descNotModified = "Bad Request: message is not modified: specified new message content " +
2021
"and reply markup are exactly the same as a current content and reply markup of the message"

media.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ func (b *Bot) photoMedia(ctx context.Context, file InputFile, caption []styling.
5959
}
6060
return message.UploadedPhoto(upFile, caption...), nil
6161
default:
62-
return nil, &Error{Code: 400, Description: "Bad Request: invalid file"}
62+
return nil, &Error{Code: 400, Description: descInvalidFile}
6363
}
6464
}
6565

@@ -92,7 +92,7 @@ func (b *Bot) documentMedia(ctx context.Context, file InputFile, caption []styli
9292
}
9393
return b, nil
9494
default:
95-
return nil, &Error{Code: 400, Description: "Bad Request: invalid file"}
95+
return nil, &Error{Code: 400, Description: descInvalidFile}
9696
}
9797
}
9898

0 commit comments

Comments
 (0)