Skip to content

Commit a9e5e0e

Browse files
ernadoclaude
andcommitted
feat(errors): comprehensive tgerr -> Bot API error mapping
Replace the small switch with a ~50-entry table mirroring the official telegram-bot-api server (Client.cpp fail_query_with_error + TDLib send rewrites), plus the server's code-normalization (sub-400/404 -> 400, SCREAMING_CASE 403 -> 400) and prefix/casing fallback for unmapped errors. Add AsChatMigrated and Code helpers alongside AsFloodWait. Studied /src/telegram/telegram-bot-api for the authoritative mappings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a72f954 commit a9e5e0e

4 files changed

Lines changed: 278 additions & 31 deletions

File tree

errors.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,23 @@ func AsFloodWait(err error) (retryAfter time.Duration, ok bool) {
6363
}
6464
return tgerr.AsFloodWait(err)
6565
}
66+
67+
// AsChatMigrated reports whether err indicates that a basic group was upgraded
68+
// to a supergroup and, if so, the new supergroup chat id the caller should use.
69+
func AsChatMigrated(err error) (newChatID int64, ok bool) {
70+
var apiErr *Error
71+
if errors.As(err, &apiErr) && apiErr.Parameters != nil && apiErr.Parameters.MigrateToChatID != 0 {
72+
return apiErr.Parameters.MigrateToChatID, true
73+
}
74+
return 0, false
75+
}
76+
77+
// Code returns the Bot API error code carried by err, or 0 if err is not a
78+
// *Error. It is a convenience over errors.As for the common branch-on-code case.
79+
func Code(err error) int {
80+
var apiErr *Error
81+
if errors.As(err, &apiErr) {
82+
return apiErr.Code
83+
}
84+
return 0
85+
}

errors_map.go

Lines changed: 182 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,115 @@ package botapi
33
import (
44
"errors"
55
"net/http"
6+
"strconv"
67
"time"
78

89
"github.com/gotd/td/tgerr"
910
)
1011

12+
// Shared Bot API error descriptions reused across the mapping and the methods.
13+
const (
14+
descChatNotFound = "Bad Request: chat not found"
15+
descWrongFileID = "Bad Request: wrong file identifier/HTTP URL specified"
16+
descUnauthorized = "Unauthorized"
17+
18+
descNotModified = "Bad Request: message is not modified: specified new message content " +
19+
"and reply markup are exactly the same as a current content and reply markup of the message"
20+
descPhotoExt = "Bad Request: Photo has unsupported extension. " +
21+
"Use one of .jpg, .jpeg, .gif, .png, .tif or .bmp"
22+
)
23+
24+
// rpcMapping is the final Bot API code and description for a known MTProto RPC
25+
// error type. Descriptions are the verbatim strings the official Bot API server
26+
// returns (including their "Bad Request:"/"Forbidden:" prefix), so callers see
27+
// behavior identical to the HTTP Bot API.
28+
type rpcMapping struct {
29+
code int
30+
desc string
31+
}
32+
33+
// rpcErrorMap translates MTProto RPC error types into Bot API errors. It mirrors
34+
// the official telegram-bot-api server (Client.cpp fail_query_with_error and the
35+
// TDLib send-error rewrites). Where the official description depends on the
36+
// calling method (e.g. "message to edit not found"), a method-independent
37+
// wording is used since asAPIError has no method context.
38+
var rpcErrorMap = map[string]rpcMapping{
39+
// Chat / peer resolution.
40+
"PEER_ID_INVALID": {http.StatusBadRequest, descChatNotFound},
41+
"CHAT_ID_INVALID": {http.StatusBadRequest, descChatNotFound},
42+
"CHAT_NOT_FOUND": {http.StatusBadRequest, descChatNotFound},
43+
"CHANNEL_INVALID": {http.StatusBadRequest, descChatNotFound},
44+
"USERNAME_INVALID": {http.StatusBadRequest, descChatNotFound},
45+
"USERNAME_NOT_OCCUPIED": {http.StatusBadRequest, descChatNotFound},
46+
"USER_ID_INVALID": {http.StatusBadRequest, "Bad Request: user not found"},
47+
48+
// Messages.
49+
"MESSAGE_ID_INVALID": {http.StatusBadRequest, "Bad Request: message to be edited not found"},
50+
"MESSAGE_NOT_MODIFIED": {http.StatusBadRequest, descNotModified},
51+
"MESSAGE_EMPTY": {http.StatusBadRequest, "Bad Request: message text is empty"},
52+
"MESSAGE_TOO_LONG": {http.StatusBadRequest, "Bad Request: message is too long"},
53+
"MEDIA_CAPTION_TOO_LONG": {http.StatusBadRequest, "Bad Request: message caption is too long"},
54+
"MESSAGE_DELETE_FORBIDDEN": {http.StatusBadRequest, "Bad Request: message can't be deleted"},
55+
"MESSAGE_AUTHOR_REQUIRED": {http.StatusBadRequest, "Bad Request: message can't be edited"},
56+
57+
// Reply markup / buttons.
58+
"REPLY_MARKUP_TOO_LONG": {http.StatusBadRequest, "Bad Request: reply markup is too long"},
59+
"REPLY_MARKUP_INVALID": {http.StatusBadRequest, "Bad Request: reply markup is invalid"},
60+
"BUTTON_URL_INVALID": {http.StatusBadRequest, "Bad Request: button URL is invalid"},
61+
"BUTTON_DATA_INVALID": {http.StatusBadRequest, "Bad Request: button data is invalid"},
62+
63+
// URLs / web pages / media.
64+
"WC_CONVERT_URL_INVALID": {http.StatusBadRequest, "Bad Request: Wrong HTTP URL specified"},
65+
"EXTERNAL_URL_INVALID": {http.StatusBadRequest, "Bad Request: Wrong HTTP URL specified"},
66+
"WEBPAGE_CURL_FAILED": {http.StatusBadRequest, "Bad Request: Failed to get HTTP URL content"},
67+
"WEBPAGE_MEDIA_EMPTY": {http.StatusBadRequest, "Bad Request: Wrong type of the web page content"},
68+
"MEDIA_GROUPED_INVALID": {http.StatusBadRequest, "Bad Request: Can't use the media of the specified type in the album"},
69+
"MEDIA_EMPTY": {http.StatusBadRequest, "Bad Request: Wrong file identifier/HTTP URL specified"},
70+
"PHOTO_EXT_INVALID": {http.StatusBadRequest, descPhotoExt},
71+
"PHOTO_INVALID_DIMENSIONS": {http.StatusBadRequest, "Bad Request: PHOTO_INVALID_DIMENSIONS"},
72+
"FILE_PARTS_INVALID": {http.StatusBadRequest, "Bad Request: file is too big"},
73+
"FILE_REFERENCE_EXPIRED": {http.StatusBadRequest, descWrongFileID},
74+
75+
// Inline / callback queries.
76+
"QUERY_ID_INVALID": {http.StatusBadRequest, "Bad Request: query is too old and response timeout expired or query ID is invalid"},
77+
78+
// Stickers.
79+
"PACK_SHORT_NAME_INVALID": {http.StatusBadRequest, "Bad Request: invalid sticker set name is specified"},
80+
"PACK_SHORT_NAME_OCCUPIED": {http.StatusBadRequest, "Bad Request: sticker set name is already occupied"},
81+
"STICKER_EMOJI_INVALID": {http.StatusBadRequest, "Bad Request: invalid sticker emojis"},
82+
"STICKERSET_INVALID": {http.StatusBadRequest, "Bad Request: STICKERSET_INVALID"},
83+
84+
// Chat description / about.
85+
"CHAT_ABOUT_NOT_MODIFIED": {http.StatusBadRequest, "Bad Request: chat description is not modified"},
86+
87+
// Member management.
88+
"USER_ADMIN_INVALID": {http.StatusBadRequest, "Bad Request: user is an administrator of the chat"},
89+
"CHAT_ADMIN_REQUIRED": {http.StatusBadRequest, "Bad Request: not enough rights"},
90+
"USER_NOT_PARTICIPANT": {http.StatusBadRequest, "Bad Request: user is not a member of the chat"},
91+
"USER_NOT_MUTUAL_CONTACT": {http.StatusBadRequest, "Bad Request: user is not a mutual contact"},
92+
"ADMINS_TOO_MUCH": {http.StatusBadRequest, "Bad Request: there are too many administrators in the chat"},
93+
"USER_CHANNELS_TOO_MUCH": {http.StatusBadRequest, "Bad Request: the user is a member of too many chats"},
94+
95+
// Forbidden (403) cases.
96+
"INPUT_USER_DEACTIVATED": {http.StatusForbidden, "Forbidden: user is deactivated"},
97+
"USER_IS_BLOCKED": {http.StatusForbidden, "Forbidden: bot was blocked by the user"},
98+
"USER_IS_BOT": {http.StatusForbidden, "Forbidden: bot can't send messages to bots"},
99+
"USER_DELETED": {http.StatusForbidden, "Forbidden: user is deactivated"},
100+
"CHAT_WRITE_FORBIDDEN": {http.StatusForbidden, "Forbidden: bot can't send messages to the chat"},
101+
"CHAT_SEND_MEDIA_FORBIDDEN": {http.StatusForbidden, "Forbidden: not enough rights to send media to the chat"},
102+
"CHANNEL_PRIVATE": {http.StatusForbidden, "Forbidden: bot is not a member of the chat"},
103+
"BOT_GROUPS_BLOCKED": {http.StatusForbidden, "Forbidden: bot can't be added to groups"},
104+
105+
// Auth.
106+
"AUTH_KEY_UNREGISTERED": {http.StatusUnauthorized, descUnauthorized},
107+
"SESSION_REVOKED": {http.StatusUnauthorized, descUnauthorized},
108+
"USER_DEACTIVATED": {http.StatusUnauthorized, descUnauthorized},
109+
}
110+
11111
// asAPIError normalizes an error from the MTProto layer into a Bot-API-shaped
12112
// *Error. It is a no-op for nil, for errors that are already *Error, and for
13113
// non-RPC errors (e.g. context cancellation or network failures), which are
14114
// returned unchanged so callers can branch on them.
15-
//
16-
// The mapping here covers the common cases; the full tgerr -> Bot API table is
17-
// completed in Phase 6.
18115
func asAPIError(err error) error {
19116
if err == nil {
20117
return nil
@@ -32,38 +129,94 @@ func asAPIError(err error) error {
32129
if d, ok := tgerr.AsFloodWait(err); ok {
33130
return &Error{
34131
Code: http.StatusTooManyRequests,
35-
Description: "Too Many Requests: retry later",
132+
Description: "Too Many Requests: retry after " + strconv.Itoa(int(d/time.Second)),
36133
Parameters: &ResponseParameters{RetryAfter: int(d / time.Second)},
37134
err: err,
38135
}
39136
}
40137

41-
out := &Error{Code: rpcErr.Code, Description: rpcErr.Message, err: err}
42-
switch rpcErr.Type {
43-
case "PEER_ID_INVALID", "CHAT_ID_INVALID", "CHAT_NOT_FOUND":
44-
out.Code, out.Description = http.StatusBadRequest, "Bad Request: chat not found"
45-
case "MESSAGE_ID_INVALID", "MESSAGE_NOT_FOUND":
46-
out.Code, out.Description = http.StatusBadRequest, "Bad Request: message not found"
47-
case "MESSAGE_NOT_MODIFIED":
48-
out.Code = http.StatusBadRequest
49-
out.Description = "Bad Request: message is not modified: specified new message content " +
50-
"and reply markup are exactly the same as a current content and reply markup of the message"
51-
case "MESSAGE_EMPTY":
52-
out.Code, out.Description = http.StatusBadRequest, "Bad Request: message text is empty"
53-
case "REPLY_MARKUP_TOO_LONG":
54-
out.Code, out.Description = http.StatusBadRequest, "Bad Request: reply markup is too long"
55-
case "USER_IS_BLOCKED":
56-
out.Code, out.Description = http.StatusForbidden, "Forbidden: bot was blocked by the user"
57-
case "INPUT_USER_DEACTIVATED":
58-
out.Code, out.Description = http.StatusForbidden, "Forbidden: user is deactivated"
59-
case "CHANNEL_PRIVATE":
60-
out.Code, out.Description = http.StatusForbidden, "Forbidden: bot is not a member of the chat"
61-
case "USER_ADMIN_INVALID":
62-
out.Code, out.Description = http.StatusBadRequest, "Bad Request: user is an administrator of the chat"
138+
if m, ok := rpcErrorMap[rpcErr.Type]; ok {
139+
return &Error{Code: m.code, Description: m.desc, err: err}
140+
}
141+
142+
// Unknown error: apply the official server's normalization and prefix rules.
143+
code := normalizeErrorCode(rpcErr.Code, rpcErr.Message)
144+
return &Error{
145+
Code: code,
146+
Description: prefixDescription(code, rpcErr.Message),
147+
err: err,
148+
}
149+
}
150+
151+
// normalizeErrorCode mirrors the official server's code remapping: codes below
152+
// 400 (and 404) collapse to 400, and a 403 whose message is a bare SCREAMING_CASE
153+
// RPC constant is downgraded to 400.
154+
func normalizeErrorCode(code int, message string) int {
155+
if code < http.StatusBadRequest || code == http.StatusNotFound {
156+
return http.StatusBadRequest
157+
}
158+
if code == http.StatusForbidden && isScreamingCase(message) {
159+
return http.StatusBadRequest
160+
}
161+
return code
162+
}
163+
164+
// prefixDescription prepends the Bot API status prefix to a raw error message,
165+
// following the official casing rule: a SCREAMING_CASE constant is kept verbatim,
166+
// otherwise the first letter is lowercased.
167+
func prefixDescription(code int, message string) string {
168+
prefix := statusPrefix(code)
169+
if message == "" {
170+
return prefix
171+
}
172+
if hasPrefix(message, prefix) {
173+
return message
174+
}
175+
if len(message) >= 2 && (message[1] == '_' || isUpper(message[1])) {
176+
return prefix + ": " + message
177+
}
178+
return prefix + ": " + lowerFirst(message)
179+
}
180+
181+
// statusPrefix returns the Bot API description prefix for a status code. Unknown
182+
// codes use the 400 prefix (matching the official server's default branch).
183+
func statusPrefix(code int) string {
184+
switch code {
185+
case http.StatusUnauthorized:
186+
return "Unauthorized"
187+
case http.StatusForbidden:
188+
return "Forbidden"
189+
case http.StatusInternalServerError:
190+
return "Internal Server Error"
63191
default:
64-
if out.Description == "" {
65-
out.Description = http.StatusText(out.Code)
192+
return "Bad Request"
193+
}
194+
}
195+
196+
func isScreamingCase(s string) bool {
197+
if s == "" {
198+
return false
199+
}
200+
for i := 0; i < len(s); i++ {
201+
c := s[i]
202+
if !(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9') && c != '_' {
203+
return false
66204
}
67205
}
68-
return out
206+
return true
207+
}
208+
209+
func isUpper(c byte) bool { return c >= 'A' && c <= 'Z' }
210+
211+
func hasPrefix(s, prefix string) bool {
212+
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
213+
}
214+
215+
func lowerFirst(s string) string {
216+
if s == "" || !isUpper(s[0]) {
217+
return s
218+
}
219+
b := []byte(s)
220+
b[0] += 'a' - 'A'
221+
return string(b)
69222
}

errors_map_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,19 @@ func TestAsAPIError(t *testing.T) {
3434
if apiErr.Parameters == nil || apiErr.Parameters.RetryAfter != 12 {
3535
t.Fatalf("want retry_after=12, got %#v", apiErr.Parameters)
3636
}
37+
if apiErr.Description != "Too Many Requests: retry after 12" {
38+
t.Fatalf("description: %q", apiErr.Description)
39+
}
3740
})
3841
t.Run("Forbidden", func(t *testing.T) {
3942
got := asAPIError(&tgerr.Error{Code: 400, Type: "USER_IS_BLOCKED"})
4043
var apiErr *Error
4144
if !errors.As(got, &apiErr) || apiErr.Code != 403 {
4245
t.Fatalf("USER_IS_BLOCKED should map to 403, got %#v", got)
4346
}
47+
if apiErr.Description != "Forbidden: bot was blocked by the user" {
48+
t.Fatalf("description: %q", apiErr.Description)
49+
}
4450
})
4551
t.Run("ChatNotFound", func(t *testing.T) {
4652
got := asAPIError(&tgerr.Error{Code: 400, Type: "PEER_ID_INVALID"})
@@ -50,3 +56,71 @@ func TestAsAPIError(t *testing.T) {
5056
}
5157
})
5258
}
59+
60+
func TestAsAPIErrorFallback(t *testing.T) {
61+
t.Run("ScreamingCaseKeptVerbatim", func(t *testing.T) {
62+
got := asAPIError(&tgerr.Error{Code: 400, Type: "SOME_NEW_ERROR", Message: "SOME_NEW_ERROR"})
63+
var apiErr *Error
64+
if !errors.As(got, &apiErr) {
65+
t.Fatal("want *Error")
66+
}
67+
if apiErr.Description != "Bad Request: SOME_NEW_ERROR" {
68+
t.Fatalf("description: %q", apiErr.Description)
69+
}
70+
})
71+
t.Run("Forbidden403AllCapsDowngradesTo400", func(t *testing.T) {
72+
got := asAPIError(&tgerr.Error{Code: 403, Type: "MYSTERY_FORBIDDEN", Message: "MYSTERY_FORBIDDEN"})
73+
if Code(got) != 400 {
74+
t.Fatalf("all-caps 403 should downgrade to 400, got %d", Code(got))
75+
}
76+
})
77+
t.Run("SubFourHundredCollapsesTo400", func(t *testing.T) {
78+
got := asAPIError(&tgerr.Error{Code: 303, Type: "SEE_OTHER", Message: "Something happened"})
79+
var apiErr *Error
80+
if !errors.As(got, &apiErr) || apiErr.Code != 400 {
81+
t.Fatalf("want 400, got %#v", got)
82+
}
83+
// Non-screaming message has its first letter lowercased.
84+
if apiErr.Description != "Bad Request: something happened" {
85+
t.Fatalf("description: %q", apiErr.Description)
86+
}
87+
})
88+
t.Run("Genuine403WithProseKept", func(t *testing.T) {
89+
got := asAPIError(&tgerr.Error{Code: 403, Type: "X", Message: "Have no rights"})
90+
var apiErr *Error
91+
if !errors.As(got, &apiErr) || apiErr.Code != 403 {
92+
t.Fatalf("prose 403 should stay 403, got %#v", got)
93+
}
94+
if apiErr.Description != "Forbidden: have no rights" {
95+
t.Fatalf("description: %q", apiErr.Description)
96+
}
97+
})
98+
}
99+
100+
func TestErrorPredicates(t *testing.T) {
101+
t.Run("AsFloodWait", func(t *testing.T) {
102+
err := asAPIError(&tgerr.Error{Code: 420, Type: "FLOOD_WAIT", Argument: 7})
103+
d, ok := AsFloodWait(err)
104+
if !ok || d.Seconds() != 7 {
105+
t.Fatalf("AsFloodWait: %v %v", d, ok)
106+
}
107+
})
108+
t.Run("AsChatMigrated", func(t *testing.T) {
109+
err := &Error{Code: 400, Parameters: &ResponseParameters{MigrateToChatID: -100123}}
110+
id, ok := AsChatMigrated(err)
111+
if !ok || id != -100123 {
112+
t.Fatalf("AsChatMigrated: %v %v", id, ok)
113+
}
114+
if _, ok := AsChatMigrated(context.Canceled); ok {
115+
t.Fatal("non-migrate error should report false")
116+
}
117+
})
118+
t.Run("Code", func(t *testing.T) {
119+
if Code(&Error{Code: 403}) != 403 {
120+
t.Fatal("Code should extract 403")
121+
}
122+
if Code(context.Canceled) != 0 {
123+
t.Fatal("Code of non-*Error should be 0")
124+
}
125+
})
126+
}

media.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func (b *Bot) photoMedia(ctx context.Context, file InputFile, caption []styling.
4242
case InputFileID:
4343
fid, err := fileid.DecodeFileID(string(f))
4444
if err != nil {
45-
return nil, &Error{Code: 400, Description: "Bad Request: wrong file identifier/HTTP URL specified"}
45+
return nil, &Error{Code: 400, Description: descWrongFileID}
4646
}
4747
photo := &tg.InputMediaPhoto{ID: &tg.InputPhoto{
4848
ID: fid.ID,
@@ -71,7 +71,7 @@ func (b *Bot) documentMedia(ctx context.Context, file InputFile, caption []styli
7171
case InputFileID:
7272
fid, err := fileid.DecodeFileID(string(f))
7373
if err != nil {
74-
return nil, &Error{Code: 400, Description: "Bad Request: wrong file identifier/HTTP URL specified"}
74+
return nil, &Error{Code: 400, Description: descWrongFileID}
7575
}
7676
doc := &tg.InputMediaDocument{ID: &tg.InputDocument{
7777
ID: fid.ID,

0 commit comments

Comments
 (0)