Skip to content

Commit bd641e8

Browse files
ernadoclaude
andcommitted
feat(types): add sealed-interface unions and keyboard builders
- unions.go: ChatID (ID/Username, marshals to bare int or string), InputFile (FileID/FileURL/FileFromPath/Bytes/Reader), ReactionType and MenuButton. Each is a sealed interface via an unexported marker method, so type switches over them are exhaustive and illegal states are unrepresentable. - markup.go: ReplyMarkup union (inline/reply keyboards, remove, force-reply) with the type-safe builder helpers (InlineKeyboard/InlineRow/InlineButton*, Keyboard/Row/Button*). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 38b1543 commit bd641e8

3 files changed

Lines changed: 334 additions & 0 deletions

File tree

markup.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package botapi
2+
3+
// ReplyMarkup is a sealed union of the markup objects a message can carry. The
4+
// unexported marker method makes the set closed: only the types in this package
5+
// can satisfy it, so a type switch over a ReplyMarkup is exhaustive.
6+
//
7+
// Concrete variants: *InlineKeyboardMarkup, *ReplyKeyboardMarkup,
8+
// *ReplyKeyboardRemove, *ForceReply.
9+
type ReplyMarkup interface {
10+
isReplyMarkup()
11+
}
12+
13+
func (*InlineKeyboardMarkup) isReplyMarkup() {}
14+
func (*ReplyKeyboardMarkup) isReplyMarkup() {}
15+
func (*ReplyKeyboardRemove) isReplyMarkup() {}
16+
func (*ForceReply) isReplyMarkup() {}
17+
18+
// WebAppInfo describes a Web App to be opened from a button.
19+
type WebAppInfo struct {
20+
URL string `json:"url"`
21+
}
22+
23+
// InlineKeyboardButton is one button of an inline keyboard. Exactly one of the
24+
// optional action fields should be set.
25+
type InlineKeyboardButton struct {
26+
Text string `json:"text"`
27+
URL string `json:"url,omitempty"`
28+
CallbackData string `json:"callback_data,omitempty"`
29+
WebApp *WebAppInfo `json:"web_app,omitempty"`
30+
SwitchInlineQuery *string `json:"switch_inline_query,omitempty"`
31+
SwitchInlineQueryCurrentChat *string `json:"switch_inline_query_current_chat,omitempty"`
32+
Pay bool `json:"pay,omitempty"`
33+
}
34+
35+
// InlineKeyboardMarkup is an inline keyboard that appears beneath a message.
36+
type InlineKeyboardMarkup struct {
37+
InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard"`
38+
}
39+
40+
// KeyboardButtonPollType is the poll-type constraint of a poll-request button.
41+
type KeyboardButtonPollType struct {
42+
Type PollType `json:"type,omitempty"`
43+
}
44+
45+
// KeyboardButton is one button of a reply (custom) keyboard. Text is sent as a
46+
// plain message when no request/web-app field is set.
47+
type KeyboardButton struct {
48+
Text string `json:"text"`
49+
RequestContact bool `json:"request_contact,omitempty"`
50+
RequestLocation bool `json:"request_location,omitempty"`
51+
RequestPoll *KeyboardButtonPollType `json:"request_poll,omitempty"`
52+
WebApp *WebAppInfo `json:"web_app,omitempty"`
53+
}
54+
55+
// ReplyKeyboardMarkup is a custom keyboard with reply options.
56+
type ReplyKeyboardMarkup struct {
57+
Keyboard [][]KeyboardButton `json:"keyboard"`
58+
IsPersistent bool `json:"is_persistent,omitempty"`
59+
ResizeKeyboard bool `json:"resize_keyboard,omitempty"`
60+
OneTimeKeyboard bool `json:"one_time_keyboard,omitempty"`
61+
InputFieldPlaceholder string `json:"input_field_placeholder,omitempty"`
62+
Selective bool `json:"selective,omitempty"`
63+
}
64+
65+
// ReplyKeyboardRemove removes the current custom keyboard.
66+
type ReplyKeyboardRemove struct {
67+
// RemoveKeyboard is always true; the type itself signals removal.
68+
RemoveKeyboard bool `json:"remove_keyboard"`
69+
Selective bool `json:"selective,omitempty"`
70+
}
71+
72+
// ForceReply forces the user's client to display a reply interface.
73+
type ForceReply struct {
74+
// ForceReply is always true; the type itself signals the intent.
75+
ForceReply bool `json:"force_reply"`
76+
InputFieldPlaceholder string `json:"input_field_placeholder,omitempty"`
77+
Selective bool `json:"selective,omitempty"`
78+
}
79+
80+
// --- Constructors / builders (the type-safe telegoutil equivalent) ---
81+
82+
// InlineKeyboard builds an inline keyboard from rows of buttons.
83+
func InlineKeyboard(rows ...[]InlineKeyboardButton) *InlineKeyboardMarkup {
84+
return &InlineKeyboardMarkup{InlineKeyboard: rows}
85+
}
86+
87+
// InlineRow groups inline buttons into a single keyboard row.
88+
func InlineRow(buttons ...InlineKeyboardButton) []InlineKeyboardButton { return buttons }
89+
90+
// InlineButtonURL builds an inline button that opens a URL.
91+
func InlineButtonURL(text, url string) InlineKeyboardButton {
92+
return InlineKeyboardButton{Text: text, URL: url}
93+
}
94+
95+
// InlineButtonData builds an inline button that sends callback data.
96+
func InlineButtonData(text, data string) InlineKeyboardButton {
97+
return InlineKeyboardButton{Text: text, CallbackData: data}
98+
}
99+
100+
// Keyboard builds a reply keyboard from rows of buttons.
101+
func Keyboard(rows ...[]KeyboardButton) *ReplyKeyboardMarkup {
102+
return &ReplyKeyboardMarkup{Keyboard: rows}
103+
}
104+
105+
// Row groups reply-keyboard buttons into a single row.
106+
func Row(buttons ...KeyboardButton) []KeyboardButton { return buttons }
107+
108+
// Button builds a plain-text reply-keyboard button.
109+
func Button(text string) KeyboardButton { return KeyboardButton{Text: text} }
110+
111+
// ButtonContact builds a reply-keyboard button that requests the user's contact.
112+
func ButtonContact(text string) KeyboardButton {
113+
return KeyboardButton{Text: text, RequestContact: true}
114+
}
115+
116+
// ButtonLocation builds a reply-keyboard button that requests the user's location.
117+
func ButtonLocation(text string) KeyboardButton {
118+
return KeyboardButton{Text: text, RequestLocation: true}
119+
}
120+
121+
// RemoveKeyboard builds a ReplyKeyboardRemove.
122+
func RemoveKeyboard() *ReplyKeyboardRemove { return &ReplyKeyboardRemove{RemoveKeyboard: true} }

unions.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package botapi
2+
3+
import (
4+
"encoding/json"
5+
"strconv"
6+
)
7+
8+
// ChatID identifies a target chat. The Bot API accepts either a numeric chat id
9+
// or an @username; this sealed union represents exactly those two cases, so an
10+
// illegal "both/neither" state is unrepresentable.
11+
//
12+
// Construct with ID or Username.
13+
type ChatID interface {
14+
isChatID()
15+
// json.Marshaler so a ChatID serializes to the bare int or string the wire
16+
// format expects.
17+
json.Marshaler
18+
}
19+
20+
// ChatIDInt is a numeric chat identifier.
21+
type ChatIDInt int64
22+
23+
// ChatIDUsername is an @username target (with or without the leading @).
24+
type ChatIDUsername string
25+
26+
func (ChatIDInt) isChatID() {}
27+
func (ChatIDUsername) isChatID() {}
28+
29+
// MarshalJSON encodes the id as a JSON number.
30+
func (c ChatIDInt) MarshalJSON() ([]byte, error) {
31+
return []byte(strconv.FormatInt(int64(c), 10)), nil
32+
}
33+
34+
// MarshalJSON encodes the username as a JSON string.
35+
func (c ChatIDUsername) MarshalJSON() ([]byte, error) {
36+
return json.Marshal(string(c))
37+
}
38+
39+
// ID targets a chat by numeric identifier.
40+
func ID(id int64) ChatID { return ChatIDInt(id) }
41+
42+
// Username targets a chat by @username.
43+
func Username(username string) ChatID { return ChatIDUsername(username) }
44+
45+
// InputFile is a sealed union describing a file to send: an existing Telegram
46+
// file_id, an HTTP URL Telegram fetches, or a local upload.
47+
//
48+
// Construct with FileID, FileURL, FileFromPath, FileFromBytes or FileFromReader.
49+
type InputFile interface {
50+
isInputFile()
51+
}
52+
53+
// InputFileID references a file already on Telegram's servers by file_id.
54+
type InputFileID string
55+
56+
// InputFileURL references a file by HTTP URL for Telegram to fetch.
57+
type InputFileURL string
58+
59+
// InputFileUpload is a local file to be uploaded. Exactly one source is set;
60+
// the send path (Phase 3) chooses the uploader accordingly.
61+
type InputFileUpload struct {
62+
// Name is the filename reported to Telegram.
63+
Name string
64+
// Path, when non-empty, is read from disk.
65+
Path string
66+
// Bytes, when non-nil, is the in-memory content.
67+
Bytes []byte
68+
// Reader, when non-nil, streams the content.
69+
Reader interface{ Read([]byte) (int, error) }
70+
}
71+
72+
func (InputFileID) isInputFile() {}
73+
func (InputFileURL) isInputFile() {}
74+
func (*InputFileUpload) isInputFile() {}
75+
76+
// FileID references an existing Telegram file by its file_id.
77+
func FileID(id string) InputFile { return InputFileID(id) }
78+
79+
// FileURL references a remote file by URL for Telegram to fetch.
80+
func FileURL(url string) InputFile { return InputFileURL(url) }
81+
82+
// FileFromPath uploads a local file from disk.
83+
func FileFromPath(path string) InputFile { return &InputFileUpload{Path: path} }
84+
85+
// FileFromBytes uploads in-memory content under the given filename.
86+
func FileFromBytes(name string, data []byte) InputFile {
87+
return &InputFileUpload{Name: name, Bytes: data}
88+
}
89+
90+
// FileFromReader uploads streamed content under the given filename.
91+
func FileFromReader(name string, r interface{ Read([]byte) (int, error) }) InputFile {
92+
return &InputFileUpload{Name: name, Reader: r}
93+
}
94+
95+
// ReactionType is a sealed union of reaction kinds.
96+
//
97+
// Concrete variants: ReactionTypeEmoji, ReactionTypeCustomEmoji, ReactionTypePaid.
98+
type ReactionType interface {
99+
isReactionType()
100+
}
101+
102+
// ReactionTypeEmoji is a reaction with a standard emoji.
103+
type ReactionTypeEmoji struct {
104+
Type ReactionTypeKind `json:"type"`
105+
Emoji string `json:"emoji"`
106+
}
107+
108+
// ReactionTypeCustomEmoji is a reaction with a custom emoji.
109+
type ReactionTypeCustomEmoji struct {
110+
Type ReactionTypeKind `json:"type"`
111+
CustomEmojiID string `json:"custom_emoji_id"`
112+
}
113+
114+
// ReactionTypePaid is a paid (star) reaction.
115+
type ReactionTypePaid struct {
116+
Type ReactionTypeKind `json:"type"`
117+
}
118+
119+
func (ReactionTypeEmoji) isReactionType() {}
120+
func (ReactionTypeCustomEmoji) isReactionType() {}
121+
func (ReactionTypePaid) isReactionType() {}
122+
123+
// Emoji builds a standard-emoji reaction.
124+
func Emoji(emoji string) ReactionType {
125+
return ReactionTypeEmoji{Type: ReactionEmoji, Emoji: emoji}
126+
}
127+
128+
// CustomEmoji builds a custom-emoji reaction.
129+
func CustomEmoji(id string) ReactionType {
130+
return ReactionTypeCustomEmoji{Type: ReactionCustomEmoji, CustomEmojiID: id}
131+
}
132+
133+
// MenuButton is a sealed union describing the bot's menu button.
134+
//
135+
// Concrete variants: MenuButtonCommands, MenuButtonWebApp, MenuButtonDefault.
136+
type MenuButton interface {
137+
isMenuButton()
138+
}
139+
140+
// MenuButtonCommands opens the bot's command list.
141+
type MenuButtonCommands struct {
142+
Type MenuButtonType `json:"type"`
143+
}
144+
145+
// MenuButtonWebApp opens a Web App.
146+
type MenuButtonWebApp struct {
147+
Type MenuButtonType `json:"type"`
148+
Text string `json:"text"`
149+
WebApp WebAppInfo `json:"web_app"`
150+
}
151+
152+
// MenuButtonDefault is the default menu button.
153+
type MenuButtonDefault struct {
154+
Type MenuButtonType `json:"type"`
155+
}
156+
157+
func (MenuButtonCommands) isMenuButton() {}
158+
func (MenuButtonWebApp) isMenuButton() {}
159+
func (MenuButtonDefault) isMenuButton() {}

unions_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package botapi
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
)
7+
8+
func TestChatIDMarshal(t *testing.T) {
9+
got, err := json.Marshal(struct {
10+
ChatID ChatID `json:"chat_id"`
11+
}{ChatID: ID(-100123)})
12+
if err != nil {
13+
t.Fatal(err)
14+
}
15+
if string(got) != `{"chat_id":-100123}` {
16+
t.Fatalf("numeric id: got %s", got)
17+
}
18+
19+
got, err = json.Marshal(struct {
20+
ChatID ChatID `json:"chat_id"`
21+
}{ChatID: Username("@durov")})
22+
if err != nil {
23+
t.Fatal(err)
24+
}
25+
if string(got) != `{"chat_id":"@durov"}` {
26+
t.Fatalf("username: got %s", got)
27+
}
28+
}
29+
30+
func TestInlineKeyboardBuilder(t *testing.T) {
31+
kb := InlineKeyboard(
32+
InlineRow(InlineButtonURL("site", "https://example.com"), InlineButtonData("ok", "ok:1")),
33+
)
34+
if len(kb.InlineKeyboard) != 1 || len(kb.InlineKeyboard[0]) != 2 {
35+
t.Fatalf("unexpected shape: %+v", kb.InlineKeyboard)
36+
}
37+
if kb.InlineKeyboard[0][1].CallbackData != "ok:1" {
38+
t.Fatalf("callback data not set: %+v", kb.InlineKeyboard[0][1])
39+
}
40+
41+
// The builder result must satisfy the sealed ReplyMarkup union.
42+
var _ ReplyMarkup = kb
43+
}
44+
45+
func TestInputFileConstructors(t *testing.T) {
46+
if _, ok := FileID("abc").(InputFileID); !ok {
47+
t.Fatal("FileID should yield InputFileID")
48+
}
49+
up, ok := FileFromBytes("a.txt", []byte("hi")).(*InputFileUpload)
50+
if !ok || up.Name != "a.txt" || string(up.Bytes) != "hi" {
51+
t.Fatalf("FileFromBytes: %+v ok=%v", up, ok)
52+
}
53+
}

0 commit comments

Comments
 (0)