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)
Build Telegram bots in Go that just work.
Type-safe. Batteries included. Always up to date with the latest Bot API.
Bot API v10.0 · 176 methods · 301 types · 1428 auto-generated tests
Website · API Reference · Examples · pkg.go.dev
Hello, Telegram 👋
bot := client.New(os.Getenv("TELEGRAM_BOT_TOKEN"))
router := dispatch.New(bot)
router.OnCommand("/start", func(c *dispatch.Context, m *api.Message) error {
_, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: "Hi " + m.From.FirstName + "! 👋",
})
return err
})
router.Run(ctx, transport.NewLongPoller(bot))
That's a working bot. No magic strings, no any, no guessing what fields exist — your editor autocompletes everything.
Why you'll like it
- 🎯 No
any, anywhere. Telegram's "Integer or String" and "one of N types" unions are real Go types you canswitchon. - 🔋 Batteries included. Long-poll, webhooks, retries on rate limits, conversation state machines, filters, handler groups — out of the box.
- 🔄 Always current. The whole API is generated from Telegram's live docs. New Bot API release?
make regenand you're done. - 🪶 Pluggable everything. Swap the HTTP client, JSON codec, or storage backend with a one-method interface. No forks.
- 🧪 Already tested. 1428 generated tests cover every method × every failure mode (success, API errors, network failures, parse errors, timeouts, missing fields, forbidden, server errors).
Install
go get github.com/lukaszraczylo/go-telegram
A complete echo bot
Long-poll, graceful shutdown, retries on Telegram's 429 retry_after:
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/lukaszraczylo/go-telegram/transport"
)
func main() {
token := os.Getenv("TELEGRAM_BOT_TOKEN")
if token == "" {
log.Fatal("TELEGRAM_BOT_TOKEN required")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
bot := client.New(token,
client.WithHTTPClient(client.NewRetryDoer(client.NewDefaultHTTPDoer())),
)
router := dispatch.New(bot)
router.OnCommand("/start", func(c *dispatch.Context, m *api.Message) error {
_, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: fmt.Sprintf("Hello %s! Send me anything.", m.From.FirstName),
})
return err
})
router.OnText(`.+`, func(c *dispatch.Context, m *api.Message) error {
_, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: m.Text,
ReplyParameters: &api.ReplyParameters{MessageID: m.MessageID},
})
return err
})
if err := router.Run(ctx, transport.NewLongPoller(bot)); err != nil && err != context.Canceled {
log.Printf("router exited: %v", err)
}
}
Examples
Run any example: TELEGRAM_BOT_TOKEN=xxx go run ./examples/<name>
| Category | Example | What it shows |
|---|---|---|
| Basics | echo |
Long-poll echo bot |
webhook |
Webhook server with secret-token verification | |
files |
Upload and download cycle | |
inline |
Inline-mode results | |
| Conversations & state | conversation |
Multi-step state machine with /cancel exit |
stateful |
Per-user state via closures | |
callback |
Inline keyboards and callback query handling | |
pagination |
Multi-page inline keyboard | |
| Group management | welcome |
Greet new chat members |
moderation |
Kick/ban/mute/warn with permission checks | |
admin |
Auth middleware allowlist | |
| Advanced | middleware |
Use chains |
polls |
sendPoll and answer tally |
|
payments |
Invoice → pre-checkout → success |
Reference docs
Full API reference is auto-generated from source comments and lives in docs/reference/ — browse package by package on GitHub, or read it rendered at go-telegram.raczylo.com and pkg.go.dev.
How it works
Bot client and pluggable transport
client.New accepts functional options:
bot := client.New(token,
client.WithHTTPClient(doer), // any HTTPDoer (one-method interface)
client.WithCodec(myCodec), // any Codec (Marshal + Unmarshal)
client.WithLogger(myLogger),
client.WithBaseURL("https://..."), // proxy or local Bot API server
)
HTTPDoer is Do(*http.Request) (*http.Response, error) — a plain *http.Client satisfies it.
Codec is Marshal(any) ([]byte, error) + Unmarshal([]byte, any) error — the default wraps goccy/go-json.
Every API call goes through client.Call[Req, Resp]; per-method generated functions are thin wrappers.
Typed unions — no any
Telegram's docs describe many fields as "Integer or String" or "one of N types". go-telegram turns every one of these into a concrete Go type.
// ChatID: construct from int64 or @username
chatID := api.ChatIDFromInt(123456789)
chatID := api.ChatIDFromString("@mychannel")
// Discriminated unions — 13 interfaces with auto-decode via generated UnmarshalJSON
for _, u := range updates {
if u.MyChatMember == nil {
continue
}
switch v := u.MyChatMember.OldChatMember.(type) {
case *api.ChatMemberOwner:
log.Println("was owner")
case *api.ChatMemberAdministrator:
log.Printf("was admin: can_post=%v", v.CanPostMessages)
}
}
Full union list: ChatMember, MessageOrigin, ReactionType, PaidMedia, BackgroundType, BackgroundFill, ChatBoostSource, RevenueWithdrawalState, TransactionPartner, MenuButton, OwnedGift, StoryAreaType, MaybeInaccessibleMessage, plus ChatID, MessageOrBool, and InputFile.
Dispatcher, filters, and conversations
The router dispatches each update in its own goroutine (semaphore-bounded, default 50):
r := dispatch.New(bot, dispatch.WithMaxConcurrency(50))
r.OnCommand("/start", handler)
r.OnText(`^hi (\w+)`, handler)
r.OnCallback(`^like:\d+`, handler)
r.OnInlineQuery(handler)
r.OnMyChatMember(handler)
// + 20 more typed On* methods
Composable filters — each update type has its own filter package:
import "github.com/lukaszraczylo/go-telegram/dispatch/filters/message"
r.OnMessageFilter(
message.Command("/admin").And(message.IsReply()),
handler,
)
Filter packages: message, callback, inline, chatmember, chatjoinrequest, precheckoutquery. Combinators: And, Or, Not, All, Any.
Conversation state machines — multi-step flows with pluggable storage:
conv := &conversation.Conversation{
EntryPoints: []conversation.Step{{
Filter: dispatch.FilterFunc(func(c *dispatch.Context, u *api.Update) bool {
return u.Message != nil && u.Message.Text == "/start"
}),
Handler: func(c *dispatch.Context, u *api.Update) error {
// send prompt, advance state
return conversation.Next("await_name")
},
}},
States: map[conversation.State][]conversation.Step{
"await_name": {{
Handler: func(c *dispatch.Context, u *api.Update) error {
return conversation.End()
},
}},
},
}
router.Use(conv.Dispatch)
Key strategies: KeyByUser, KeyByChat, KeyByUserAndChat (default). Default storage: MemoryStorage (in-process, concurrency-safe). Implement the Storage interface for Redis or any other backend.
Errors and retry middleware
Wrap the default HTTP doer with RetryDoer for production:
bot := client.New(token,
client.WithHTTPClient(
client.NewRetryDoer(
client.NewDefaultHTTPDoer(),
client.WithMaxAttempts(5),
client.WithBaseBackoff(500*time.Millisecond),
),
),
)
RetryDoer retries on 429, 5xx, and transient network errors. On a 429 it reads retry_after from Telegram's response body and waits exactly that long — overriding any backoff calculation. Request bodies are buffered and replayed across attempts.
Sentinel errors for errors.Is checks: client.ErrForbidden, client.ErrNotFound, client.ErrUnauthorized.
Handler groups and named handlers
Priority-ordered groups with flow control signals:
// Group 0 runs first — return EndGroups to stop, ContinueGroups to continue
r.Group(0).OnText(`.*`, authMiddleware)
r.Group(1).OnText(`.*`, businessHandler)
Named handlers — register and replace at runtime:
named := dispatch.NewNamedHandlers[*api.Message]()
named.Set("main", myHandler)
r.OnCommand("/cmd", named.Handler())
// later: named.Set("main", updatedHandler)
Keeping up with Telegram
When Telegram ships a new Bot API version, regenerating the whole library is one command:
make snapshot # grab the latest HTML from core.telegram.org
make regen # scrape → audit → emit Go → run tests → regenerate docs
The audit tool checks for any-typed escapes, surprise bool returns, and signature drift. CI runs it on every PR, and a weekly workflow opens an auto-PR with regenerated code so a new Bot API version never sits longer than a week.
If something in Telegram's docs trips up the scraper, add an override to internal/spec/overrides.json. The audit will tell you what to put there.
Testing
Mock the one-method HTTPDoer interface to test handlers in isolation — no test server needed:
type fakeDoer struct{ body string }
func (f fakeDoer) Do(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(f.body)),
}, nil
}
bot := client.New("token", client.WithHTTPClient(fakeDoer{
body: `{"ok":true,"result":{"message_id":1,"date":0,"chat":{"id":1,"type":"private"}}}`,
}))
The library's own generated test suite (api/methods_gen_test.go) covers 176 methods × 8 scenarios each: Success, APIError, NetworkError, ParseError, ContextCanceled, MissingRequiredFields, Forbidden, ServerError.
Contributing
See CONTRIBUTING.md.
License
MIT