fix(api): emit bare interface for all sealed-interface unions

Optional fields whose type was a sealed-interface union without an
auto-decode discriminator (BotCommandScope, InputMedia, InputPaidMedia,
InputProfilePhoto, InputStoryContent, InputMessageContent,
InputPollMedia, InputPollOptionMedia, InlineQueryResult,
PassportElementError) were emitted as *<Union> — pointer to interface,
which is a Go anti-pattern: interfaces are already nil-able, and
callers were forced to take addresses of concrete variants.

The codegen path goType() guarded against pointer-wrapping using
knownDiscriminators (only 13 unions with auto-decode dispatch), missing
the 10 marker-only sealed interfaces. New package var
knownInterfaceTypes is built from buildUnionTypeSet at emitter
construction and covers both kinds. goType() now consults that.

Net effect:
- api/methods.gen.go: 3 *BotCommandScope and 2 *InputPollMedia fields
  become bare interface
- api/types.gen.go: 14 *InputMessageContent and 1 *InputPollOptionMedia
  fields become bare interface

Regression tests in api/unionparam_test.go cover both shapes:
direct concrete-variant assignment, and nil omitempty on the bare
interface field.
This commit is contained in:
2026-05-09 19:06:59 +01:00
parent 13ea7097e1
commit 6ab80c27e1
5 changed files with 135 additions and 49 deletions
+5 -5
View File
@@ -1875,7 +1875,7 @@ type SendPollParams struct {
// A JSON-serialized list of special entities that appear in the poll explanation. It can be specified instead of explanation_parse_mode
ExplanationEntities []MessageEntity `json:"explanation_entities,omitempty"`
// Media added to the quiz explanation
ExplanationMedia *InputPollMedia `json:"explanation_media,omitempty"`
ExplanationMedia InputPollMedia `json:"explanation_media,omitempty"`
// Amount of time in seconds the poll will be active after creation, 5-2628000. Can't be used together with close_date.
OpenPeriod *int64 `json:"open_period,omitempty"`
// Point in time (Unix timestamp) when the poll will be automatically closed. Must be at least 5 and no more than 2628000 seconds in the future. Can't be used together with open_period.
@@ -1889,7 +1889,7 @@ type SendPollParams struct {
// A JSON-serialized list of special entities that appear in the poll description, which can be specified instead of description_parse_mode
DescriptionEntities []MessageEntity `json:"description_entities,omitempty"`
// Media added to the poll description
Media *InputPollMedia `json:"media,omitempty"`
Media InputPollMedia `json:"media,omitempty"`
// Sends the message silently. Users will receive a notification with no sound.
DisableNotification *bool `json:"disable_notification,omitempty"`
// Protects the contents of the sent message from forwarding and saving
@@ -3140,7 +3140,7 @@ type SetMyCommandsParams struct {
// A JSON-serialized list of bot commands to be set as the list of the bot's commands. At most 100 commands can be specified.
Commands []BotCommand `json:"commands"`
// A JSON-serialized object, describing scope of users for which the commands are relevant. Defaults to BotCommandScopeDefault.
Scope *BotCommandScope `json:"scope,omitempty"`
Scope BotCommandScope `json:"scope,omitempty"`
// A two-letter ISO 639-1 language code. If empty, commands will be applied to all users from the given scope, for whose language there are no dedicated commands
LanguageCode string `json:"language_code,omitempty"`
}
@@ -3157,7 +3157,7 @@ func SetMyCommands(ctx context.Context, b *client.Bot, p *SetMyCommandsParams) (
// Use this method to delete the list of the bot's commands for the given scope and user language. After deletion, higher level commands will be shown to affected users. Returns True on success.
type DeleteMyCommandsParams struct {
// A JSON-serialized object, describing scope of users for which the commands are relevant. Defaults to BotCommandScopeDefault.
Scope *BotCommandScope `json:"scope,omitempty"`
Scope BotCommandScope `json:"scope,omitempty"`
// A two-letter ISO 639-1 language code. If empty, commands will be applied to all users from the given scope, for whose language there are no dedicated commands
LanguageCode string `json:"language_code,omitempty"`
}
@@ -3174,7 +3174,7 @@ func DeleteMyCommands(ctx context.Context, b *client.Bot, p *DeleteMyCommandsPar
// Use this method to get the current list of the bot's commands for the given scope and user language. Returns an Array of BotCommand objects. If commands aren't set, an empty list is returned.
type GetMyCommandsParams struct {
// A JSON-serialized object, describing scope of users. Defaults to BotCommandScopeDefault.
Scope *BotCommandScope `json:"scope,omitempty"`
Scope BotCommandScope `json:"scope,omitempty"`
// A two-letter ISO 639-1 language code or an empty string
LanguageCode string `json:"language_code,omitempty"`
}
+19 -19
View File
@@ -1288,7 +1288,7 @@ type InputPollOption struct {
// Optional. A JSON-serialized list of special entities that appear in the poll option text. It can be specified instead of text_parse_mode
TextEntities []MessageEntity `json:"text_entities,omitempty"`
// Optional. Media added to the poll option
Media *InputPollOptionMedia `json:"media,omitempty"`
Media InputPollOptionMedia `json:"media,omitempty"`
}
// This object represents an answer of a user in a non-anonymous poll.
@@ -4550,7 +4550,7 @@ type InlineQueryResultPhoto struct {
// Optional. Inline keyboard attached to the message
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
// Optional. Content of the message to be sent instead of the photo
InputMessageContent *InputMessageContent `json:"input_message_content,omitempty"`
InputMessageContent InputMessageContent `json:"input_message_content,omitempty"`
}
// Represents a link to an animated GIF file. By default, this animated GIF file will be sent by the user with optional caption. Alternatively, you can use input_message_content to send a message with the specified content instead of the animation.
@@ -4584,7 +4584,7 @@ type InlineQueryResultGif struct {
// Optional. Inline keyboard attached to the message
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
// Optional. Content of the message to be sent instead of the GIF animation
InputMessageContent *InputMessageContent `json:"input_message_content,omitempty"`
InputMessageContent InputMessageContent `json:"input_message_content,omitempty"`
}
// Represents a link to a video animation (H.264/MPEG-4 AVC video without sound). By default, this animated MPEG-4 file will be sent by the user with optional caption. Alternatively, you can use input_message_content to send a message with the specified content instead of the animation.
@@ -4618,7 +4618,7 @@ type InlineQueryResultMpeg4Gif struct {
// Optional. Inline keyboard attached to the message
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
// Optional. Content of the message to be sent instead of the video animation
InputMessageContent *InputMessageContent `json:"input_message_content,omitempty"`
InputMessageContent InputMessageContent `json:"input_message_content,omitempty"`
}
// Represents a link to a page containing an embedded video player or a video file. By default, this video file will be sent by the user with an optional caption. Alternatively, you can use input_message_content to send a message with the specified content instead of the video.
@@ -4655,7 +4655,7 @@ type InlineQueryResultVideo struct {
// Optional. Inline keyboard attached to the message
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
// Optional. Content of the message to be sent instead of the video. This field is required if InlineQueryResultVideo is used to send an HTML-page as a result (e.g., a YouTube video).
InputMessageContent *InputMessageContent `json:"input_message_content,omitempty"`
InputMessageContent InputMessageContent `json:"input_message_content,omitempty"`
}
// Represents a link to an MP3 audio file. By default, this audio file will be sent by the user. Alternatively, you can use input_message_content to send a message with the specified content instead of the audio.
@@ -4681,7 +4681,7 @@ type InlineQueryResultAudio struct {
// Optional. Inline keyboard attached to the message
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
// Optional. Content of the message to be sent instead of the audio
InputMessageContent *InputMessageContent `json:"input_message_content,omitempty"`
InputMessageContent InputMessageContent `json:"input_message_content,omitempty"`
}
// Represents a link to a voice recording in an .OGG container encoded with OPUS. By default, this voice recording will be sent by the user. Alternatively, you can use input_message_content to send a message with the specified content instead of the the voice message.
@@ -4705,7 +4705,7 @@ type InlineQueryResultVoice struct {
// Optional. Inline keyboard attached to the message
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
// Optional. Content of the message to be sent instead of the voice recording
InputMessageContent *InputMessageContent `json:"input_message_content,omitempty"`
InputMessageContent InputMessageContent `json:"input_message_content,omitempty"`
}
// Represents a link to a file. By default, this file will be sent by the user with an optional caption. Alternatively, you can use input_message_content to send a message with the specified content instead of the file. Currently, only .PDF and .ZIP files can be sent using this method.
@@ -4731,7 +4731,7 @@ type InlineQueryResultDocument struct {
// Optional. Inline keyboard attached to the message
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
// Optional. Content of the message to be sent instead of the file
InputMessageContent *InputMessageContent `json:"input_message_content,omitempty"`
InputMessageContent InputMessageContent `json:"input_message_content,omitempty"`
// Optional. URL of the thumbnail (JPEG only) for the file
ThumbnailURL string `json:"thumbnail_url,omitempty"`
// Optional. Thumbnail width
@@ -4763,7 +4763,7 @@ type InlineQueryResultLocation struct {
// Optional. Inline keyboard attached to the message
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
// Optional. Content of the message to be sent instead of the location
InputMessageContent *InputMessageContent `json:"input_message_content,omitempty"`
InputMessageContent InputMessageContent `json:"input_message_content,omitempty"`
// Optional. Url of the thumbnail for the result
ThumbnailURL string `json:"thumbnail_url,omitempty"`
// Optional. Thumbnail width
@@ -4797,7 +4797,7 @@ type InlineQueryResultVenue struct {
// Optional. Inline keyboard attached to the message
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
// Optional. Content of the message to be sent instead of the venue
InputMessageContent *InputMessageContent `json:"input_message_content,omitempty"`
InputMessageContent InputMessageContent `json:"input_message_content,omitempty"`
// Optional. Url of the thumbnail for the result
ThumbnailURL string `json:"thumbnail_url,omitempty"`
// Optional. Thumbnail width
@@ -4823,7 +4823,7 @@ type InlineQueryResultContact struct {
// Optional. Inline keyboard attached to the message
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
// Optional. Content of the message to be sent instead of the contact
InputMessageContent *InputMessageContent `json:"input_message_content,omitempty"`
InputMessageContent InputMessageContent `json:"input_message_content,omitempty"`
// Optional. Url of the thumbnail for the result
ThumbnailURL string `json:"thumbnail_url,omitempty"`
// Optional. Thumbnail width
@@ -4867,7 +4867,7 @@ type InlineQueryResultCachedPhoto struct {
// Optional. Inline keyboard attached to the message
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
// Optional. Content of the message to be sent instead of the photo
InputMessageContent *InputMessageContent `json:"input_message_content,omitempty"`
InputMessageContent InputMessageContent `json:"input_message_content,omitempty"`
}
// Represents a link to an animated GIF file stored on the Telegram servers. By default, this animated GIF file will be sent by the user with an optional caption. Alternatively, you can use input_message_content to send a message with specified content instead of the animation.
@@ -4891,7 +4891,7 @@ type InlineQueryResultCachedGif struct {
// Optional. Inline keyboard attached to the message
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
// Optional. Content of the message to be sent instead of the GIF animation
InputMessageContent *InputMessageContent `json:"input_message_content,omitempty"`
InputMessageContent InputMessageContent `json:"input_message_content,omitempty"`
}
// Represents a link to a video animation (H.264/MPEG-4 AVC video without sound) stored on the Telegram servers. By default, this animated MPEG-4 file will be sent by the user with an optional caption. Alternatively, you can use input_message_content to send a message with the specified content instead of the animation.
@@ -4915,7 +4915,7 @@ type InlineQueryResultCachedMpeg4Gif struct {
// Optional. Inline keyboard attached to the message
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
// Optional. Content of the message to be sent instead of the video animation
InputMessageContent *InputMessageContent `json:"input_message_content,omitempty"`
InputMessageContent InputMessageContent `json:"input_message_content,omitempty"`
}
// Represents a link to a sticker stored on the Telegram servers. By default, this sticker will be sent by the user. Alternatively, you can use input_message_content to send a message with the specified content instead of the sticker.
@@ -4929,7 +4929,7 @@ type InlineQueryResultCachedSticker struct {
// Optional. Inline keyboard attached to the message
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
// Optional. Content of the message to be sent instead of the sticker
InputMessageContent *InputMessageContent `json:"input_message_content,omitempty"`
InputMessageContent InputMessageContent `json:"input_message_content,omitempty"`
}
// Represents a link to a file stored on the Telegram servers. By default, this file will be sent by the user with an optional caption. Alternatively, you can use input_message_content to send a message with the specified content instead of the file.
@@ -4953,7 +4953,7 @@ type InlineQueryResultCachedDocument struct {
// Optional. Inline keyboard attached to the message
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
// Optional. Content of the message to be sent instead of the file
InputMessageContent *InputMessageContent `json:"input_message_content,omitempty"`
InputMessageContent InputMessageContent `json:"input_message_content,omitempty"`
}
// Represents a link to a video file stored on the Telegram servers. By default, this video file will be sent by the user with an optional caption. Alternatively, you can use input_message_content to send a message with the specified content instead of the video.
@@ -4979,7 +4979,7 @@ type InlineQueryResultCachedVideo struct {
// Optional. Inline keyboard attached to the message
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
// Optional. Content of the message to be sent instead of the video
InputMessageContent *InputMessageContent `json:"input_message_content,omitempty"`
InputMessageContent InputMessageContent `json:"input_message_content,omitempty"`
}
// Represents a link to a voice message stored on the Telegram servers. By default, this voice message will be sent by the user. Alternatively, you can use input_message_content to send a message with the specified content instead of the voice message.
@@ -5001,7 +5001,7 @@ type InlineQueryResultCachedVoice struct {
// Optional. Inline keyboard attached to the message
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
// Optional. Content of the message to be sent instead of the voice message
InputMessageContent *InputMessageContent `json:"input_message_content,omitempty"`
InputMessageContent InputMessageContent `json:"input_message_content,omitempty"`
}
// Represents a link to an MP3 audio file stored on the Telegram servers. By default, this audio file will be sent by the user. Alternatively, you can use input_message_content to send a message with the specified content instead of the audio.
@@ -5021,7 +5021,7 @@ type InlineQueryResultCachedAudio struct {
// Optional. Inline keyboard attached to the message
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
// Optional. Content of the message to be sent instead of the audio
InputMessageContent *InputMessageContent `json:"input_message_content,omitempty"`
InputMessageContent InputMessageContent `json:"input_message_content,omitempty"`
}
// InputMessageContent is a union type. The following concrete variants implement
+77
View File
@@ -0,0 +1,77 @@
package api
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// TestSetMyCommands_BotCommandScope_NoPointerToInterface is a regression
// test for the bug where sealed-interface union types without an
// auto-decode discriminator (BotCommandScope, InputMedia, etc.) were
// emitted as `*<Union>` (pointer-to-interface) when used as optional
// fields. Pointer-to-interface is a Go anti-pattern: the interface is
// already nil-able, and callers were forced to write
// `Scope: &someConcreteScope` instead of `Scope: someConcreteScope`.
//
// This test confirms the field is now bare-interface-typed: a concrete
// variant assigns directly, and a nil scope omits the field via
// omitempty.
func TestSetMyCommands_BotCommandScope_NoPointerToInterface(t *testing.T) {
var captured string
m := &mockDoer{}
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
if r.Body != nil {
b, _ := io.ReadAll(r.Body)
captured = string(b)
}
return true
})).Return(&http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil)
bot := client.New("test:token", client.WithHTTPClient(m))
// Direct assignment of a concrete variant — only possible when Scope
// is `BotCommandScope` (interface), not `*BotCommandScope`.
ok, err := SetMyCommands(context.Background(), bot, &SetMyCommandsParams{
Commands: []BotCommand{{Command: "start", Description: "begin"}},
Scope: &BotCommandScopeAllPrivateChats{Type: "all_private_chats"},
})
require.NoError(t, err)
require.True(t, ok)
require.Contains(t, captured, `"scope":{"type":"all_private_chats"}`)
}
// TestSetMyCommands_NilScope_OmitsField confirms omitempty works on the
// bare-interface field when the caller doesn't supply a scope.
func TestSetMyCommands_NilScope_OmitsField(t *testing.T) {
var captured string
m := &mockDoer{}
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
if r.Body != nil {
b, _ := io.ReadAll(r.Body)
captured = string(b)
}
return true
})).Return(&http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil)
bot := client.New("test:token", client.WithHTTPClient(m))
_, err := SetMyCommands(context.Background(), bot, &SetMyCommandsParams{
Commands: []BotCommand{{Command: "start", Description: "begin"}},
})
require.NoError(t, err)
require.NotContains(t, captured, `"scope"`, "nil scope must be omitted from JSON")
}