Skip to content

Commit 38b1543

Browse files
ernadoclaude
andcommitted
feat(types): add typed enums and structured error hierarchy
Phase 2 foundation: - enums.go: named string enums (ParseMode, ChatType, ChatAction, MessageEntityType, ChatMemberStatus, PollType, StickerType, and the discriminators for the incoming unions) so callers cannot pass bare strings. - errors.go: Bot-API-shaped *Error (Code/Description/Parameters), ResponseParameters, ErrNotImplemented, and AsFloodWait bridging both *Error and tgerr flood waits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c3930f8 commit 38b1543

3 files changed

Lines changed: 271 additions & 0 deletions

File tree

enums.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package botapi
2+
3+
// This file defines the typed string enums of the Bot API surface. Each is a
4+
// distinct named type over string so callers cannot pass an arbitrary string
5+
// where an enum is expected, and the valid values are exported constants.
6+
7+
// ParseMode is the formatting mode for message text and captions.
8+
//
9+
// See https://core.telegram.org/bots/api#formatting-options.
10+
type ParseMode string
11+
12+
const (
13+
// ParseModeNone sends text without any entity parsing.
14+
ParseModeNone ParseMode = ""
15+
// ParseModeHTML parses a subset of HTML tags.
16+
ParseModeHTML ParseMode = "HTML"
17+
// ParseModeMarkdownV2 parses MarkdownV2-style formatting.
18+
ParseModeMarkdownV2 ParseMode = "MarkdownV2"
19+
// ParseModeMarkdown is the legacy Markdown mode; prefer ParseModeMarkdownV2.
20+
ParseModeMarkdown ParseMode = "Markdown"
21+
)
22+
23+
// ChatType is the kind of a chat.
24+
type ChatType string
25+
26+
const (
27+
ChatTypePrivate ChatType = "private"
28+
ChatTypeGroup ChatType = "group"
29+
ChatTypeSupergroup ChatType = "supergroup"
30+
ChatTypeChannel ChatType = "channel"
31+
// ChatTypeSender is used for inline queries sent from the inline mode of a
32+
// private chat with the bot.
33+
ChatTypeSender ChatType = "sender"
34+
)
35+
36+
// ChatAction is a status reported to a chat via SendChatAction.
37+
type ChatAction string
38+
39+
const (
40+
ChatActionTyping ChatAction = "typing"
41+
ChatActionUploadPhoto ChatAction = "upload_photo"
42+
ChatActionRecordVideo ChatAction = "record_video"
43+
ChatActionUploadVideo ChatAction = "upload_video"
44+
ChatActionRecordVoice ChatAction = "record_voice"
45+
ChatActionUploadVoice ChatAction = "upload_voice"
46+
ChatActionUploadDocument ChatAction = "upload_document"
47+
ChatActionChooseSticker ChatAction = "choose_sticker"
48+
ChatActionFindLocation ChatAction = "find_location"
49+
ChatActionRecordVideoNote ChatAction = "record_video_note"
50+
ChatActionUploadVideoNote ChatAction = "upload_video_note"
51+
)
52+
53+
// MessageEntityType is the kind of a MessageEntity.
54+
type MessageEntityType string
55+
56+
const (
57+
EntityMention MessageEntityType = "mention"
58+
EntityHashtag MessageEntityType = "hashtag"
59+
EntityCashtag MessageEntityType = "cashtag"
60+
EntityBotCommand MessageEntityType = "bot_command"
61+
EntityURL MessageEntityType = "url"
62+
EntityEmail MessageEntityType = "email"
63+
EntityPhoneNumber MessageEntityType = "phone_number"
64+
EntityBold MessageEntityType = "bold"
65+
EntityItalic MessageEntityType = "italic"
66+
EntityUnderline MessageEntityType = "underline"
67+
EntityStrikethrough MessageEntityType = "strikethrough"
68+
EntitySpoiler MessageEntityType = "spoiler"
69+
EntityBlockquote MessageEntityType = "blockquote"
70+
EntityExpandableBlockquote MessageEntityType = "expandable_blockquote"
71+
EntityCode MessageEntityType = "code"
72+
EntityPre MessageEntityType = "pre"
73+
EntityTextLink MessageEntityType = "text_link"
74+
EntityTextMention MessageEntityType = "text_mention"
75+
EntityCustomEmoji MessageEntityType = "custom_emoji"
76+
)
77+
78+
// ChatMemberStatus is a member's status in a chat.
79+
type ChatMemberStatus string
80+
81+
const (
82+
StatusCreator ChatMemberStatus = "creator"
83+
StatusAdministrator ChatMemberStatus = "administrator"
84+
StatusMember ChatMemberStatus = "member"
85+
StatusRestricted ChatMemberStatus = "restricted"
86+
StatusLeft ChatMemberStatus = "left"
87+
StatusBanned ChatMemberStatus = "kicked"
88+
)
89+
90+
// PollType is the kind of a poll.
91+
type PollType string
92+
93+
const (
94+
PollRegular PollType = "regular"
95+
PollQuiz PollType = "quiz"
96+
)
97+
98+
// StickerType is the kind of a sticker (or sticker set).
99+
type StickerType string
100+
101+
const (
102+
StickerRegular StickerType = "regular"
103+
StickerMask StickerType = "mask"
104+
StickerCustomEmoji StickerType = "custom_emoji"
105+
)
106+
107+
// MessageOriginType discriminates a MessageOrigin variant.
108+
type MessageOriginType string
109+
110+
const (
111+
OriginUser MessageOriginType = "user"
112+
OriginHiddenUser MessageOriginType = "hidden_user"
113+
OriginChat MessageOriginType = "chat"
114+
OriginChannel MessageOriginType = "channel"
115+
)
116+
117+
// ReactionTypeKind discriminates a ReactionType variant.
118+
type ReactionTypeKind string
119+
120+
const (
121+
ReactionEmoji ReactionTypeKind = "emoji"
122+
ReactionCustomEmoji ReactionTypeKind = "custom_emoji"
123+
ReactionPaid ReactionTypeKind = "paid"
124+
)
125+
126+
// MenuButtonType discriminates a MenuButton variant.
127+
type MenuButtonType string
128+
129+
const (
130+
MenuButtonCommandsType MenuButtonType = "commands"
131+
MenuButtonWebAppType MenuButtonType = "web_app"
132+
MenuButtonDefaultType MenuButtonType = "default"
133+
)
134+
135+
// InputMediaType discriminates an InputMedia variant.
136+
type InputMediaType string
137+
138+
const (
139+
InputMediaPhotoType InputMediaType = "photo"
140+
InputMediaVideoType InputMediaType = "video"
141+
InputMediaAnimationType InputMediaType = "animation"
142+
InputMediaAudioType InputMediaType = "audio"
143+
InputMediaDocumentType InputMediaType = "document"
144+
)
145+
146+
// DiceEmoji is the emoji on which a dice-style value is based.
147+
type DiceEmoji string
148+
149+
const (
150+
DiceDie DiceEmoji = "🎲"
151+
DiceDart DiceEmoji = "🎯"
152+
DiceBasketball DiceEmoji = "🏀"
153+
DiceFootball DiceEmoji = "⚽"
154+
DiceBowling DiceEmoji = "🎳"
155+
DiceSlot DiceEmoji = "🎰"
156+
)

errors.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package botapi
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"time"
7+
8+
"github.com/gotd/td/tgerr"
9+
)
10+
11+
// ResponseParameters describes why a request failed and how to recover, mirroring
12+
// the Bot API "ResponseParameters" object.
13+
type ResponseParameters struct {
14+
// MigrateToChatID is the new chat identifier when the group was migrated to a
15+
// supergroup. Zero when not applicable.
16+
MigrateToChatID int64 `json:"migrate_to_chat_id,omitempty"`
17+
// RetryAfter is the number of seconds to wait before repeating the request
18+
// when a flood-wait limit was hit. Zero when not applicable.
19+
RetryAfter int `json:"retry_after,omitempty"`
20+
}
21+
22+
// Error is a Bot-API-shaped error. Methods return it (wrapped) so callers can
23+
// branch on a stable Code/Description regardless of the underlying MTProto error.
24+
//
25+
// Use errors.As to extract it:
26+
//
27+
// var apiErr *botapi.Error
28+
// if errors.As(err, &apiErr) && apiErr.Code == 403 { ... }
29+
type Error struct {
30+
// Code is the Bot-API-compatible error code (e.g. 400, 403, 429).
31+
Code int
32+
// Description is a human-readable message.
33+
Description string
34+
// Parameters carries optional recovery hints (retry_after, migrate_to_chat_id).
35+
Parameters *ResponseParameters
36+
// err is the wrapped underlying error (typically *tgerr.Error), if any.
37+
err error
38+
}
39+
40+
// Error implements the error interface.
41+
func (e *Error) Error() string {
42+
if e.Description == "" {
43+
return fmt.Sprintf("botapi: error %d", e.Code)
44+
}
45+
return fmt.Sprintf("botapi: %d: %s", e.Code, e.Description)
46+
}
47+
48+
// Unwrap returns the underlying error, enabling errors.Is/errors.As to reach it.
49+
func (e *Error) Unwrap() error { return e.err }
50+
51+
// ErrNotImplemented is returned by methods whose translation is not yet wired.
52+
var ErrNotImplemented = errors.New("botapi: not implemented")
53+
54+
// AsFloodWait reports whether err (or anything it wraps) is a flood-wait error
55+
// and, if so, how long the caller should wait before retrying.
56+
//
57+
// It understands both this package's *Error (via Parameters.RetryAfter) and the
58+
// underlying github.com/gotd/td/tgerr flood-wait representation.
59+
func AsFloodWait(err error) (retryAfter time.Duration, ok bool) {
60+
var apiErr *Error
61+
if errors.As(err, &apiErr) && apiErr.Parameters != nil && apiErr.Parameters.RetryAfter > 0 {
62+
return time.Duration(apiErr.Parameters.RetryAfter) * time.Second, true
63+
}
64+
return tgerr.AsFloodWait(err)
65+
}

errors_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package botapi
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"testing"
7+
"time"
8+
9+
"github.com/gotd/td/tgerr"
10+
)
11+
12+
func TestAsFloodWait(t *testing.T) {
13+
t.Run("FromError", func(t *testing.T) {
14+
err := &Error{Code: 429, Description: "Too Many Requests", Parameters: &ResponseParameters{RetryAfter: 5}}
15+
d, ok := AsFloodWait(err)
16+
if !ok || d != 5*time.Second {
17+
t.Fatalf("got (%v, %v), want (5s, true)", d, ok)
18+
}
19+
})
20+
t.Run("FromWrappedError", func(t *testing.T) {
21+
err := fmt.Errorf("send: %w", &Error{Code: 429, Parameters: &ResponseParameters{RetryAfter: 3}})
22+
if d, ok := AsFloodWait(err); !ok || d != 3*time.Second {
23+
t.Fatalf("got (%v, %v), want (3s, true)", d, ok)
24+
}
25+
})
26+
t.Run("FromTGErr", func(t *testing.T) {
27+
err := &tgerr.Error{Code: 420, Type: "FLOOD_WAIT", Argument: 7}
28+
if d, ok := AsFloodWait(err); !ok || d != 7*time.Second {
29+
t.Fatalf("got (%v, %v), want (7s, true)", d, ok)
30+
}
31+
})
32+
t.Run("NotFloodWait", func(t *testing.T) {
33+
if _, ok := AsFloodWait(errors.New("boom")); ok {
34+
t.Fatal("plain error should not be a flood wait")
35+
}
36+
})
37+
}
38+
39+
func TestErrorUnwrap(t *testing.T) {
40+
sentinel := errors.New("underlying")
41+
err := &Error{Code: 400, Description: "Bad Request", err: sentinel}
42+
if !errors.Is(err, sentinel) {
43+
t.Fatal("errors.Is should reach the wrapped error")
44+
}
45+
46+
var apiErr *Error
47+
if !errors.As(fmt.Errorf("ctx: %w", err), &apiErr) || apiErr.Code != 400 {
48+
t.Fatal("errors.As should extract *Error through a wrap")
49+
}
50+
}

0 commit comments

Comments
 (0)