fix: upload attachments nested in rich messages and InputMedia thumbnails (#298) - #299
Conversation
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #299 +/- ##
==========================================
+ Coverage 48.50% 51.75% +3.25%
==========================================
Files 34 34
Lines 2835 2935 +100
==========================================
+ Hits 1375 1519 +144
+ Misses 1401 1370 -31
+ Partials 59 46 -13 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
@negasus Pls review 🙏 |
|
Reviewed this against the branch head (b19d161). The direction is right and I verified the core fix end-to-end: with Two blockers before this can merge, though. Both are the typed-nil interface trap, and both are new on this branch — the equivalent code passes on 1.
|
|
@negasus Fixed |
|
When you'll approve this PR? We need these bug fixes |
|
@negasus could take a look? We really need this. |
negasus
left a comment
There was a problem hiding this comment.
Thanks for the fix — the core of it is right. The attach:// walker covers every InputRichBlock* variant that can actually carry media (Table, Map, Buttons, PullQuotation, ExpandableBlockQuotation genuinely have no nested media or blocks), all five InputFile thumbnail fields get a GetThumbnail(), and go vet / go test ./... pass.
A few things I'd like addressed before merging. The first three I'd consider blocking: two of them are the same panic this PR claims to have fixed, on paths it doesn't cover, and the third is a regression for existing callers. Details inline.
| } | ||
|
|
||
| func addFormFieldInputFileUpload(form *multipart.Writer, fieldName string, value *models.InputFileUpload) error { | ||
| func addFormFieldInputFileUpload(form formWriter, fieldName string, value *models.InputFileUpload) error { |
There was a problem hiding this comment.
value.Data is dereferenced without a nil guard, so a typed-nil *models.InputFileUpload in a top-level models.InputFile field panics:
var thumb *models.InputFileUpload
b.SendVideo(ctx, &bot.SendVideoParams{
ChatID: 1,
Video: &models.InputFileString{Data: "file_id"},
Thumbnail: thumb,
})
// runtime error: invalid memory address or nil pointer dereferenceThe form is built in the goroutine in raw_request.go:31 with no recover, so this takes the process down. The PR introduces isNilValue and guards exactly this shape for nested thumbnails, and the CHANGELOG entry says a typed nil InputFile thumbnail "no longer panics" — which reads as covering this path too, but doesn't. One line at the top fixes it:
if isNilValue(value) {
return fmt.Errorf("nil value for field %s", fieldName)
}There was a problem hiding this comment.
Fixed. isNilValue guard at the top of addFormFieldInputFileUpload, so it returns nil value for field thumbnail instead of panicking.
| } | ||
|
|
||
| func addFormFieldInputMediaItem(form *multipart.Writer, value inputMedia) ([]byte, error) { | ||
| func addFormFieldInputMediaItem(form formWriter, value inputMedia) ([]byte, error) { |
There was a problem hiding this comment.
Same class of bug, also unguarded: a typed-nil element inside []models.InputMedia / []models.InputPaidMedia reaches here and then addInputMediaAttachment → value.GetMedia() on a nil receiver.
b.SendMediaGroup(ctx, &bot.SendMediaGroupParams{
ChatID: 1,
Media: []models.InputMedia{
&models.InputMediaPhoto{Media: "file_id"},
(*models.InputMediaPhoto)(nil),
},
})
// panicsThis PR adds precisely that guard (isNilValue(media.Media)) for InputRichMessage.Media below, so it'd be good to close the much more common media-slice path in the same pass.
There was a problem hiding this comment.
Fixed. Guarded in addFormFieldInputMediaSlice (nil value for field media at index 1) and in addFormFieldInputMedia for the single-field case.
|
|
||
| func (f *requestForm) CreateFormFile(fieldName, filename string) (io.Writer, error) { | ||
| if _, ok := f.fileNames[fieldName]; ok { | ||
| return nil, fmt.Errorf("duplicate form part name %q", fieldName) |
There was a problem hiding this comment.
Making a duplicate part name a hard error turns a previously working pattern into a failed call: referencing one file from two entries under a single attach:// name.
// both entries point at the same part
Media: []models.InputMedia{
&models.InputMediaPhoto{Media: "attach://p.jpg", MediaAttachment: r1},
&models.InputMediaPhoto{Media: "attach://p.jpg"},
}
// now: duplicate form part name "p.jpg"Before, two parts were written and Telegram resolved both references to the first — which is the intended result when the content is the same. Same story for two rich-message photo blocks sharing a name, and for []models.InputSticker (createNewStickerSet reusing one image across two emoji entries).
Reusing the already-written part when the name repeats, and only erroring when a second different reader is offered for the same name, would fix the real ambiguity without breaking these callers.
There was a problem hiding this comment.
Fixed. The form now tracks name -> reader. A repeated name with the same reader, or with no reader at all, reuses the part already written; only a second different reader errors. Sticker sets included.
One correction: your second snippet (second entry with no reader) failed on main too, with nil attachment for attach://p.jpg from #296, so it is not a regression from this PR. It works now, because the nil check only applies when no part of that name exists yet.
| // requestForm rejects two file parts sharing a name. The name of a part is what an | ||
| // attach:// reference resolves against, so a duplicate silently makes Telegram resolve | ||
| // both references to the first file. | ||
| type requestForm struct { |
There was a problem hiding this comment.
Minor: requestForm only intercepts CreateFormFile, so the invariant the README now states ("part names must be unique within one request") is only half enforced — a file part and a form field can still collide:
b.EditMessageMedia(ctx, &bot.EditMessageMediaParams{
Media: &models.InputMediaVideo{Media: "attach://media", MediaAttachment: r},
})
// builds fine, emits two parts both named "media" (one file, one field)That's the exact ambiguity the check was added to prevent. Tracking CreateFormField names in the same map closes it.
There was a problem hiding this comment.
Fixed. Dropped the embedded formWriter so the wrapper cannot be bypassed, and added addFieldPart. File parts and form fields now share one name space, so your EditMessageMedia example errors.
| // upload (a file_id or an URL) carries no content and is left to the encoder. | ||
| func addInputFileAttachment(form formWriter, value models.InputFile) error { | ||
| upload, ok := value.(*models.InputFileUpload) | ||
| if !ok || upload == nil { |
There was a problem hiding this comment.
Minor: this trades the panic for a server-side rejection rather than a working call. The field is skipped for upload, but the encoder still emits "thumbnail":null:
{"type":"video","media":"file_id","thumbnail":null}and Telegram rejects a null where it expects a String. The caller's intent with a nil thumbnail is clearly "no thumbnail", so omitting the key entirely would let the call succeed. Same for InputRichMessageMedia.Media → "media":null.
There was a problem hiding this comment.
Fixed for the thumbnail: typed nils are normalised at marshal time, so omitempty drops the key.
Went the other way on InputRichMessageMedia.Media, which now errors. media is required there, so omitting the key still leaves an invalid entry, and it is the same class as the nil media-slice item above. Say the word if you would rather have it omitted.
| if readerIsNil(upload.Data) { | ||
| return fmt.Errorf("nil data for attach://%s", upload.Filename) | ||
| } | ||
| w, errCreateField := form.CreateFormFile(upload.Filename, upload.Filename) |
There was a problem hiding this comment.
Minor: upload.Data is validated just above but upload.Filename isn't. With Thumbnail: &models.InputFileUpload{Data: r} (no filename) the form gets a part named "" and the media JSON gets "thumbnail":"attach://". Telegram answers with an opaque Bad Request instead of the library catching it, which is inconsistent with the nil-Data check right above.
There was a problem hiding this comment.
Fixed. An empty Filename on a nested upload errors: empty filename for nested upload, it is the attach:// reference.
| // thumbnail of an InputMedia. Filename doubles as the name of the form part. | ||
| func (i *InputFileUpload) MarshalJSON() ([]byte, error) { | ||
| return []byte(`"@` + i.Filename + `"`), nil | ||
| return []byte(`"attach://` + i.Filename + `"`), nil |
There was a problem hiding this comment.
Minor, but worth doing while this comment is being written: the JSON string is built by concatenation with no escaping. Now that Filename doubles as the attach:// reference and the part name, a filename containing " or \ produces invalid JSON and the request fails late — after the file part has already been streamed into the pipe:
&models.InputFileUpload{Filename: "a\"b\\c.jpg", Data: r}
// json: error calling MarshalJSON for type *models.InputFileUpload:
// invalid character 'b' after top-level valuereturn json.Marshal("attach://" + i.Filename)InputFileString.MarshalJSON below has the identical flaw for a file_id/URL containing a quote.
There was a problem hiding this comment.
Fixed. Both use json.Marshal now.
negasus
left a comment
There was a problem hiding this comment.
Re-checked all seven points on b85209e, against a main worktree for comparison. Everything I raised is fixed, and I verified each one by building the form rather than by reading the diff:
- typed nil
*InputFileUploadin a top levelInputFilefield →nil value for field thumbnail. Worth naming: this one panicked onmain, so the PR closes a pre-existing crash, not one it introduced. - typed nil in
[]InputMedia, in a singleInputMediafield, and inInputRichMessage.Media→ all three error, none panic. - duplicate part names: the same reader, or none, writes one part and reuses it with the right content; a second different reader errors. And your correction stands —
{reader}, {no reader}errors onmaintoo, so that was not a regression from this branch. My mistake. - file part / form field name space is now shared:
attach://mediaerrors in botheditMessageMediaandsendMediaGroup. Onmainthat silently emitted two parts namedmedia. - a nil thumbnail is omitted rather than sent as
null, forInputMedia*andInputPaidMediaVideo, including a typed nil*InputFileString. - an empty nested
Filenameerrors;MarshalJSONescapes properly on both types.
The core fix does what it says: nested rich-message media and container blocks (checked Collage → Photo) write their file parts, and a nested thumbnail is uploaded and referenced as attach://thumb.jpg where main emitted "thumbnail":"@thumb.jpg" with no part at all. Block coverage is complete — List, BlockQuotation, Collage, Slideshow, Details are the only variants nesting []InputRichBlock, and all six media blocks are walked. All four params fields are wired, and the two pointer ones are omitempty, so a nil never reaches the walker. go vet, gofmt and go test -race ./... are clean, and no existing test assertion was weakened.
On InputRichMessageMedia.Media: erroring is the right call, media is required there, so omitting the key would only produce an invalid entry.
Merging. Five notes below, none blocking, all fine as a follow-up:
-
sameReaderrequires a comparable dynamic type. A reader that is a value of a non-comparable struct type, used twice under one name, now errorsduplicate form part namewheremainaccepted it. That is a false positive rather than a real ambiguity — narrow, since nearly every reader is a pointer, butreflect.Type.Comparable()fails into "different" silently. -
One reader used under two different part names still writes the second part empty, because the reader is already drained, and says nothing. Not a regression, but it is the same class of silent corruption this PR closes, and the
filePartsmap now makes it detectable. -
Order dependence:
[{no reader}, {reader}]under one name errors, while the reverse succeeds. Same onmain; a line in the README or a pre-scan would settle it. -
A
Filenamecontaining CR or LF diverges —mime/multipartpercent-escapes the part name (a%0Ab.jpg) while the JSON keeps the literal, so the reference can never resolve."and\round-trip correctly. Very much an edge case; rejecting control characters in a nestedFilenamewould close it. -
The CHANGELOG doesn't flag the file-part/form-field collision as breaking.
attach://mediais a natural name and now hard-errors onsendMediaGroup,editMessageMediaandsendPaidMedia. It was already broken against Telegram, but a line saying so would help anyone whose code stops building a request.
Thanks for the thorough turnaround on this.
The entries from #299 landed in the v1.24.0 section, which was already tagged at 5842ef8, so they are moved into a section of their own. Minor bump rather than a patch: the repository has only ever cut x.y.0, and rejecting duplicate part names can turn a request that used to build into an early error, so that entry is marked [BREAKING] and names the shape most likely to hit it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSLNciCkYnHQYBg6kAGmkY
Fixes #298, and a second attachment bug in the same code path that surfaced while fixing it.
buildRequestForm had no case for models.InputRichMessage, so the field fell through to addFormFieldDefault and was written with a plain json.Marshal. The attach:// references were serialized into the JSON, but no file part was ever created, so Telegram had nothing to resolve them against and the readers were never read.
The attach:// walking that addFormFieldInputMediaItem and addFormFieldInputMediaSlice do for top level InputMedia fields simply never reached anything nested inside a rich message.
Fixed by adding addFormFieldInputRichMessage, which uploads the referenced attachments before encoding the field:
Wired into the type switch for both shapes the params use: models.InputRichMessage (sendRichMessage, sendRichMessageDraft) and *models.InputRichMessage (editMessageText, editEphemeralMessageText).
The upload half of addFormFieldInputMediaItem is extracted into addInputMediaAttachment so the walker reuses it instead of duplicating the attach:// handling. The encoding of the field itself is unchanged.
Same class of bug, found by tracing the rest of the attach:// handling.
InputFileUpload.MarshalJSON emitted "@", which is not a Bot API reference. At the top level of a request this never showed: a Thumbnail field there becomes its own form part named thumbnail and the marshalled value is not used. Nested inside an InputMedia, the marshalled value is what Telegram reads, so the thumbnail was broken twice over: an unusable reference, and no file part to point at.
Affects InputMediaVideo, InputMediaAnimation, InputMediaAudio, InputMediaDocument and InputPaidMediaVideo, and therefore sendMediaGroup, editMessageMedia, editEphemeralMessageMedia, sendPaidMedia and the media blocks of a rich message.
Fixed by:
A nil Data on a nested upload returns an error rather than panicking, matching the treatment of nil MediaAttachment from #296.
Compatibility
No API is removed or renamed. Two behaviour changes worth naming: