mirror of
https://github.com/lukaszraczylo/go-telegram.git
synced 2026-06-05 22:43:59 +00:00
feat(api): typed enums for all string-enum fields
The Telegram docs describe many string fields and parameters with
phrases like "can be ..., or ...", "must be one of ...", or "always X",
yet the generated Go API surface used raw `string` for every one of
them. Callers had to write magic strings or `string(api.ChatTypePrivate)`
to satisfy the field type. This change makes those fields typed Go
string enums emitted from the IR, so the IDE autocompletes valid values
and breaking-value drift surfaces at compile time.
Pipeline changes:
- internal/spec/ir.go: Field gains EnumValues []string. Empty for non-
enum fields; otherwise the wire-level values in doc order, deduped.
- cmd/scrape/enums.go: extractEnumValues recognises the curly-quoted
patterns Telegram uses ("can be either", "currently can be", "one
of", "must be", "always X") and rejects free-text quoted refs (e.g.
"Can be available only for X") via a tight gap check between the
trigger phrase and the first quoted value. parse_mode parameters
get the canonical Markdown / MarkdownV2 / HTML triple injected
because Telegram links to a separate formatting-options section
instead of listing values inline.
- cmd/genapi/enums.go: planEnums groups fields by sorted value-tuple,
picks a canonical Go enum name (most-common candidate, parent-
prefixed beats plain, shortest beats longer, alphabetical for
determinism), resolves cross-group name collisions by parent prefix.
- cmd/genapi/emitter.go + templates: goField rewrites the field type
to the planned enum name; multipartFieldEntry casts typed enum
values back to string when composing the wire map; enums.tmpl now
iterates the planned enums instead of hardcoding four hand-curated
ones; sentinelForField produces typed-constant test fixtures.
- api/enums.gen.go: regenerated from the live IR. 66 enum types, 155
constants. ParseMode, ChatType, MessageEntityType, ChatMember /
MessageOrigin / PaidMedia / Background / StoryAreaType / Reaction /
TransactionPartner / PassportElement variant Status & Type fields
are now typed.
- api/enums.go: hand-coded UpdateType (used by transport.LongPoller).
The Telegram docs do not enumerate Update payload kinds inline, so
the codegen pipeline cannot synthesise this enum.
- api/types.gen.go, api/methods.gen.go, api/methods_gen_test.go: 137
field declarations rewritten string -> typed enum.
- dispatch/, examples/: dropped every string(api.<Const>) cast. The
HasEntity filter now takes api.MessageEntityType; ChatType filter
compares typed values directly. ChatMember discriminator filter
casts variant.Status (typed per variant) to string for comparison.
- internal/spec/api.json, testdata/golden/*: regenerated and
refreshed. make regen-from-fixture is byte-deterministic across
runs.
Renames (no compat shims; v1 pre-public):
- EntityX -> MessageEntityTypeX (e.g. EntityBotCommand -> MessageEntityTypeBotCommand)
- EntityStrike -> MessageEntityTypeStrikethrough (full wire name)
This commit is contained in:
@@ -15,17 +15,17 @@ func NewStatus(s string) dispatch.Filter[*api.ChatMemberUpdated] {
|
||||
}
|
||||
switch m := u.NewChatMember.(type) {
|
||||
case *api.ChatMemberOwner:
|
||||
return m.Status == s
|
||||
return string(m.Status) == s
|
||||
case *api.ChatMemberAdministrator:
|
||||
return m.Status == s
|
||||
return string(m.Status) == s
|
||||
case *api.ChatMemberMember:
|
||||
return m.Status == s
|
||||
return string(m.Status) == s
|
||||
case *api.ChatMemberRestricted:
|
||||
return m.Status == s
|
||||
return string(m.Status) == s
|
||||
case *api.ChatMemberLeft:
|
||||
return m.Status == s
|
||||
return string(m.Status) == s
|
||||
case *api.ChatMemberBanned:
|
||||
return m.Status == s
|
||||
return string(m.Status) == s
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -12,15 +12,15 @@ func memberUpdate(status string, fromID int64) *api.ChatMemberUpdated {
|
||||
var newMember api.ChatMember
|
||||
switch status {
|
||||
case "member":
|
||||
newMember = &api.ChatMemberMember{Status: status}
|
||||
newMember = &api.ChatMemberMember{Status: api.ChatMemberMemberStatusMember}
|
||||
case "administrator":
|
||||
newMember = &api.ChatMemberAdministrator{Status: status}
|
||||
newMember = &api.ChatMemberAdministrator{Status: api.ChatMemberAdministratorStatusAdministrator}
|
||||
case "kicked":
|
||||
newMember = &api.ChatMemberBanned{Status: status}
|
||||
newMember = &api.ChatMemberBanned{Status: api.ChatMemberBannedStatusKicked}
|
||||
case "left":
|
||||
newMember = &api.ChatMemberLeft{Status: status}
|
||||
newMember = &api.ChatMemberLeft{Status: api.ChatMemberLeftStatusLeft}
|
||||
default:
|
||||
newMember = &api.ChatMemberMember{Status: status}
|
||||
newMember = &api.ChatMemberMember{Status: api.ChatMemberMemberStatusMember}
|
||||
}
|
||||
return &api.ChatMemberUpdated{
|
||||
From: api.User{ID: fromID},
|
||||
@@ -70,7 +70,7 @@ func TestComposedFilters(t *testing.T) {
|
||||
func TestNewStatus_Owner(t *testing.T) {
|
||||
u := &api.ChatMemberUpdated{
|
||||
From: api.User{ID: 1},
|
||||
NewChatMember: &api.ChatMemberOwner{Status: "creator"},
|
||||
NewChatMember: &api.ChatMemberOwner{Status: api.ChatMemberOwnerStatusCreator},
|
||||
}
|
||||
require.True(t, cmfilter.NewStatus("creator")(u))
|
||||
require.False(t, cmfilter.NewStatus("member")(u))
|
||||
@@ -79,7 +79,7 @@ func TestNewStatus_Owner(t *testing.T) {
|
||||
func TestNewStatus_Restricted(t *testing.T) {
|
||||
u := &api.ChatMemberUpdated{
|
||||
From: api.User{ID: 1},
|
||||
NewChatMember: &api.ChatMemberRestricted{Status: "restricted"},
|
||||
NewChatMember: &api.ChatMemberRestricted{Status: api.ChatMemberRestrictedStatusRestricted},
|
||||
}
|
||||
require.True(t, cmfilter.NewStatus("restricted")(u))
|
||||
require.False(t, cmfilter.NewStatus("member")(u))
|
||||
|
||||
@@ -48,7 +48,7 @@ func Command(name string) dispatch.Filter[*api.Message] {
|
||||
return false
|
||||
}
|
||||
first := m.Entities[0]
|
||||
if first.Type != string(api.EntityBotCommand) || first.Offset != 0 {
|
||||
if first.Type != api.MessageEntityTypeBotCommand || first.Offset != 0 {
|
||||
return false
|
||||
}
|
||||
end := int(first.Length)
|
||||
@@ -72,7 +72,7 @@ func AnyCommand() dispatch.Filter[*api.Message] {
|
||||
return false
|
||||
}
|
||||
first := m.Entities[0]
|
||||
return first.Type == string(api.EntityBotCommand) && first.Offset == 0
|
||||
return first.Type == api.MessageEntityTypeBotCommand && first.Offset == 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,8 +105,8 @@ func HasDocument() dispatch.Filter[*api.Message] {
|
||||
}
|
||||
|
||||
// HasEntity returns a Filter that matches messages whose Entities contain at
|
||||
// least one entity of type t (e.g. string(api.EntityBotCommand)).
|
||||
func HasEntity(t string) dispatch.Filter[*api.Message] {
|
||||
// least one entity of type t (e.g. api.MessageEntityTypeBotCommand).
|
||||
func HasEntity(t api.MessageEntityType) dispatch.Filter[*api.Message] {
|
||||
return func(m *api.Message) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
@@ -123,7 +123,7 @@ func HasEntity(t string) dispatch.Filter[*api.Message] {
|
||||
// ChatType returns a Filter that matches messages whose Chat.Type equals t.
|
||||
func ChatType(t api.ChatType) dispatch.Filter[*api.Message] {
|
||||
return func(m *api.Message) bool {
|
||||
return m != nil && m.Chat.Type == string(t)
|
||||
return m != nil && m.Chat.Type == t
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func msg(text string) *api.Message {
|
||||
return &api.Message{
|
||||
MessageID: 1,
|
||||
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
|
||||
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
|
||||
Text: text,
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,10 @@ func cmdMsg(cmd string) *api.Message {
|
||||
text := cmd
|
||||
return &api.Message{
|
||||
MessageID: 1,
|
||||
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
|
||||
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
|
||||
Text: text,
|
||||
Entities: []api.MessageEntity{
|
||||
{Type: string(api.EntityBotCommand), Offset: 0, Length: int64(len([]rune(text)))},
|
||||
{Type: api.MessageEntityTypeBotCommand, Offset: 0, Length: int64(len([]rune(text)))},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func TestCommand(t *testing.T) {
|
||||
t.Run("strips BotName suffix", func(t *testing.T) {
|
||||
m := &api.Message{
|
||||
Text: "/start@MyBot",
|
||||
Entities: []api.MessageEntity{{Type: string(api.EntityBotCommand), Offset: 0, Length: 12}},
|
||||
Entities: []api.MessageEntity{{Type: api.MessageEntityTypeBotCommand, Offset: 0, Length: 12}},
|
||||
}
|
||||
f := msgfilter.Command("/start")
|
||||
require.True(t, f(m))
|
||||
@@ -134,9 +134,9 @@ func TestHasDocument(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHasEntity(t *testing.T) {
|
||||
f := msgfilter.HasEntity(string(api.EntityURL))
|
||||
f := msgfilter.HasEntity(api.MessageEntityTypeURL)
|
||||
m := msg("check https://example.com")
|
||||
m.Entities = []api.MessageEntity{{Type: string(api.EntityURL), Offset: 6, Length: 19}}
|
||||
m.Entities = []api.MessageEntity{{Type: api.MessageEntityTypeURL, Offset: 6, Length: 19}}
|
||||
require.True(t, f(m))
|
||||
require.False(t, f(msg("plain")))
|
||||
require.False(t, f(nil))
|
||||
@@ -148,7 +148,7 @@ func TestChatType(t *testing.T) {
|
||||
require.True(t, f(private))
|
||||
|
||||
group := msg("hi")
|
||||
group.Chat.Type = string(api.ChatTypeGroup)
|
||||
group.Chat.Type = api.ChatTypeGroup
|
||||
require.False(t, f(group))
|
||||
require.False(t, f(nil))
|
||||
}
|
||||
@@ -183,6 +183,6 @@ func TestComposedMessageFilters(t *testing.T) {
|
||||
require.True(t, f(m))
|
||||
|
||||
m2 := msg("say hello")
|
||||
m2.Chat.Type = string(api.ChatTypeGroup)
|
||||
m2.Chat.Type = api.ChatTypeGroup
|
||||
require.False(t, f(m2))
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ func msgUpdate(id int64, text string) api.Update {
|
||||
UpdateID: id,
|
||||
Message: &api.Message{
|
||||
MessageID: id,
|
||||
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
|
||||
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
|
||||
Text: text,
|
||||
},
|
||||
}
|
||||
@@ -29,10 +29,10 @@ func cmdUpdate(id int64, cmd string) api.Update {
|
||||
UpdateID: id,
|
||||
Message: &api.Message{
|
||||
MessageID: id,
|
||||
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
|
||||
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
|
||||
Text: cmd,
|
||||
Entities: []api.MessageEntity{
|
||||
{Type: string(api.EntityBotCommand), Offset: 0, Length: int64(len(cmd))},
|
||||
{Type: api.MessageEntityTypeBotCommand, Offset: 0, Length: int64(len(cmd))},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+1
-1
@@ -516,7 +516,7 @@ func extractCommand(m *api.Message) (cmd, args string, ok bool) {
|
||||
return "", "", false
|
||||
}
|
||||
first := m.Entities[0]
|
||||
if first.Type != string(api.EntityBotCommand) || first.Offset != 0 {
|
||||
if first.Type != api.MessageEntityTypeBotCommand || first.Offset != 0 {
|
||||
return "", "", false
|
||||
}
|
||||
cmd, sliceOk := utf16Slice(m.Text, int(first.Offset), int(first.Length))
|
||||
|
||||
+15
-15
@@ -31,9 +31,9 @@ func cmdMessage(text string) api.Update {
|
||||
return api.Update{
|
||||
UpdateID: 1,
|
||||
Message: &api.Message{
|
||||
MessageID: 1, Date: 0, Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
|
||||
MessageID: 1, Date: 0, Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
|
||||
Text: text,
|
||||
Entities: []api.MessageEntity{{Type: string(api.EntityBotCommand), Offset: 0, Length: int64(indexEnd(text))}},
|
||||
Entities: []api.MessageEntity{{Type: api.MessageEntityTypeBotCommand, Offset: 0, Length: int64(indexEnd(text))}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -153,10 +153,10 @@ func TestRouter_NonASCIICommand(t *testing.T) {
|
||||
UpdateID: 1,
|
||||
Message: &api.Message{
|
||||
MessageID: 1,
|
||||
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
|
||||
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
|
||||
Text: text,
|
||||
Entities: []api.MessageEntity{
|
||||
{Type: string(api.EntityBotCommand), Offset: 0, Length: cmdU16Len},
|
||||
{Type: api.MessageEntityTypeBotCommand, Offset: 0, Length: cmdU16Len},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -196,7 +196,7 @@ func TestRouter_CommandValuesNotLeakedOnNoMatch(t *testing.T) {
|
||||
u := api.Update{UpdateID: 1, Message: &api.Message{
|
||||
MessageID: 1, Chat: api.Chat{ID: 1, Type: "private"},
|
||||
Text: "/unknown",
|
||||
Entities: []api.MessageEntity{{Type: string(api.EntityBotCommand), Offset: 0, Length: 8}},
|
||||
Entities: []api.MessageEntity{{Type: api.MessageEntityTypeBotCommand, Offset: 0, Length: 8}},
|
||||
}}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
@@ -247,7 +247,7 @@ func TestRouter_OnChannelPost(t *testing.T) {
|
||||
})
|
||||
|
||||
u := api.Update{UpdateID: 1, ChannelPost: &api.Message{
|
||||
MessageID: 99, Chat: api.Chat{ID: -100, Type: string(api.ChatTypeChannel)},
|
||||
MessageID: 99, Chat: api.Chat{ID: -100, Type: api.ChatTypeChannel},
|
||||
}}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
@@ -393,7 +393,7 @@ func TestRouter_OnInlineQueryFilter_Matches(t *testing.T) {
|
||||
func TestRouter_FilterChain_Composition(t *testing.T) {
|
||||
// Filter: private chat AND text contains "hello"
|
||||
privateChat := Filter[*api.Message](func(m *api.Message) bool {
|
||||
return m != nil && m.Chat.Type == string(api.ChatTypePrivate)
|
||||
return m != nil && m.Chat.Type == api.ChatTypePrivate
|
||||
})
|
||||
hasHello := Filter[*api.Message](func(m *api.Message) bool {
|
||||
return m != nil && len(m.Text) > 0 && containsStr(m.Text, "hello")
|
||||
@@ -405,10 +405,10 @@ func TestRouter_FilterChain_Composition(t *testing.T) {
|
||||
r.OnMessageFilter(combined, func(c *Context, m *api.Message) error { hit <- m.Text; return nil })
|
||||
|
||||
match := api.Update{UpdateID: 1, Message: &api.Message{
|
||||
MessageID: 1, Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)}, Text: "say hello",
|
||||
MessageID: 1, Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate}, Text: "say hello",
|
||||
}}
|
||||
noMatch := api.Update{UpdateID: 2, Message: &api.Message{
|
||||
MessageID: 2, Chat: api.Chat{ID: 2, Type: string(api.ChatTypeGroup)}, Text: "say hello",
|
||||
MessageID: 2, Chat: api.Chat{ID: 2, Type: api.ChatTypeGroup}, Text: "say hello",
|
||||
}}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
@@ -462,7 +462,7 @@ func TestRouter_ConcurrentDispatch_AllHandlersFire(t *testing.T) {
|
||||
for i := range ups {
|
||||
ups[i] = api.Update{UpdateID: int64(i + 1), Message: &api.Message{
|
||||
MessageID: int64(i + 1),
|
||||
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
|
||||
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
|
||||
Text: "hi",
|
||||
}}
|
||||
}
|
||||
@@ -493,7 +493,7 @@ func TestRouter_ConcurrentDispatch_SemaphoreBoundsConcurrency(t *testing.T) {
|
||||
for i := range ups {
|
||||
ups[i] = api.Update{UpdateID: int64(i + 1), Message: &api.Message{
|
||||
MessageID: int64(i + 1),
|
||||
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
|
||||
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
|
||||
Text: "hi",
|
||||
}}
|
||||
}
|
||||
@@ -555,7 +555,7 @@ func TestRouter_ConcurrentDispatch_WaitsForInFlight(t *testing.T) {
|
||||
)
|
||||
|
||||
u := api.Update{UpdateID: 1, Message: &api.Message{
|
||||
MessageID: 1, Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)}, Text: "hi",
|
||||
MessageID: 1, Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate}, Text: "hi",
|
||||
}}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
@@ -594,7 +594,7 @@ func TestRouter_SerialMode_NoRace(t *testing.T) {
|
||||
for i := range ups {
|
||||
ups[i] = api.Update{UpdateID: int64(i + 1), Message: &api.Message{
|
||||
MessageID: int64(i + 1),
|
||||
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
|
||||
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
|
||||
Text: "hi",
|
||||
}}
|
||||
}
|
||||
@@ -908,7 +908,7 @@ func TestRouter_ContextCancel_UnblocksWaitingAcquire(t *testing.T) {
|
||||
for i := range limit {
|
||||
lu.Send(api.Update{UpdateID: int64(i + 1), Message: &api.Message{
|
||||
MessageID: int64(i + 1),
|
||||
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
|
||||
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
|
||||
Text: "hi",
|
||||
}})
|
||||
}
|
||||
@@ -919,7 +919,7 @@ func TestRouter_ContextCancel_UnblocksWaitingAcquire(t *testing.T) {
|
||||
// Send one more update — Run will block trying to acquire the full semaphore.
|
||||
lu.Send(api.Update{UpdateID: int64(limit + 1), Message: &api.Message{
|
||||
MessageID: int64(limit + 1),
|
||||
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
|
||||
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
|
||||
Text: "extra",
|
||||
}})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user