Files
go-telegram/README.md
T
lukaszraczylo 607c3e8ddd test(bench): cross-library benchmarks vs top 5 go telegram libraries
Adds test/benchmarks/ as a separate Go module so competitor deps
(go-telegram-bot-api/v5, telebot.v3, go-telegram/bot, telego,
echotron/v3) stay out of the root go.mod.

Hot paths covered:
  - Webhook decode  (small Update -> typed Update struct)
  - Large unmarshal (Update with entities + reply markup + photo array)
  - API round-trip  (sendMessage against httptest.Server)
  - Dispatch route  (20 handlers, last-registered matches)

Results on Apple M4 Max / go1.26.2: ours wins 3 of 4 paths and is
2nd of 5 in the round-trip path. Full report at
docs/benchmarks/2026-05-10-comparison.md, raw output committed under
test/benchmarks/results/.

Caveats called out in the report:
  - codec asymmetry (we ship goccy/go-json; competitors mostly stdlib)
  - echotron call bench skipped — built-in rate limiter not externally
    configurable; would measure throttling, not the library
  - dispatch bench limited to libs with a public sync entry point
    (ours, telebot, gobot); gotba has no dispatcher, telego/echotron
    use channel/per-chat paradigms not directly comparable

Also gitignores docs/superpowers/ (local brainstorm/spec scratch)
and regenerates docs/reference/dispatch.md after the new
Router.Process method.
2026-05-10 21:52:00 +01:00

378 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-dark.svg">
<img alt="go-telegram" src="docs/logo-light.svg" width="320">
</picture>
</p>
<p align="center">
<strong>Build Telegram bots in Go that just work.</strong><br>
Type-safe. Batteries included. Always up to date with the latest Bot API.
</p>
<p align="center">
<a href="https://github.com/lukaszraczylo/go-telegram/actions/workflows/ci.yml"><img src="https://github.com/lukaszraczylo/go-telegram/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://pkg.go.dev/github.com/lukaszraczylo/go-telegram"><img src="https://pkg.go.dev/badge/github.com/lukaszraczylo/go-telegram.svg" alt="Go Reference"></a>
<a href="go.mod"><img src="https://img.shields.io/github/go-mod/go-version/lukaszraczylo/go-telegram" alt="Go Version"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
</p>
<p align="center">
Bot API <strong>v10.0</strong> · 176 methods · 301 types · 1428 auto-generated tests
</p>
<p align="center">
<a href="https://go-telegram.raczylo.com/">Website</a> ·
<a href="docs/reference/">API Reference</a> ·
<a href="examples/">Examples</a> ·
<a href="https://pkg.go.dev/github.com/lukaszraczylo/go-telegram">pkg.go.dev</a>
</p>
---
## Hello, Telegram 👋
```go
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 can `switch` on.
- 🔋 **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 regen` and 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
```bash
go get github.com/lukaszraczylo/go-telegram
```
## A complete echo bot
Long-poll, graceful shutdown, retries on Telegram's `429 retry_after`:
```go
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`](examples/echo) | Long-poll echo bot |
| | [`webhook`](examples/webhook) | Webhook server with secret-token verification |
| | [`files`](examples/files) | Upload and download cycle |
| | [`inline`](examples/inline) | Inline-mode results |
| **Conversations & state** | [`conversation`](examples/conversation) | Multi-step state machine with `/cancel` exit |
| | [`stateful`](examples/stateful) | Per-user state via closures |
| | [`callback`](examples/callback) | Inline keyboards and callback query handling |
| | [`pagination`](examples/pagination) | Multi-page inline keyboard |
| **Group management** | [`welcome`](examples/welcome) | Greet new chat members |
| | [`moderation`](examples/moderation) | Kick/ban/mute/warn with permission checks |
| | [`admin`](examples/admin) | Auth middleware allowlist |
| **Advanced** | [`middleware`](examples/middleware) | `Use` chains |
| | [`polls`](examples/polls) | `sendPoll` and answer tally |
| | [`payments`](examples/payments) | Invoice → pre-checkout → success |
## Optional fields
Telegram marks many fields as optional. For optional **scalars** (int, bool, float) we use pointers so you can explicitly send `false` or `0` when the wire format needs to override a chat default. The `api.Ptr` helper keeps that ergonomic:
```go
api.SendMessage(ctx, bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(chatID),
Text: "hi",
DisableNotification: api.Ptr(true), // type inferred
})
api.GetUserProfilePhotos(ctx, bot, &api.GetUserProfilePhotosParams{
UserID: userID,
Limit: api.Ptr[int64](5), // explicit type for untyped literals
})
```
Optional structs and slices are already nullable in Go — no helper needed.
## Reference docs
Full API reference is auto-generated from source comments and lives in [`docs/reference/`](docs/reference/README.md) — browse package by package on GitHub, or read it rendered at [go-telegram.raczylo.com](https://go-telegram.raczylo.com/) and [pkg.go.dev](https://pkg.go.dev/github.com/lukaszraczylo/go-telegram).
## How it works
<details>
<summary>Bot client and pluggable transport</summary>
`client.New` accepts functional options:
```go
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.
</details>
<details>
<summary>Typed unions — no any</summary>
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.
```go
// 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`.
</details>
<details>
<summary>Dispatcher, filters, and conversations</summary>
The router dispatches each update in its own goroutine (semaphore-bounded, default 50):
```go
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:
```go
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:
```go
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.
</details>
<details>
<summary>Errors and retry middleware</summary>
Wrap the default HTTP doer with `RetryDoer` for production:
```go
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`.
</details>
<details>
<summary>Handler groups and named handlers</summary>
Priority-ordered groups with flow control signals:
```go
// 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:
```go
named := dispatch.NewNamedHandlers[*api.Message]()
named.Set("main", myHandler)
r.OnCommand("/cmd", named.Handler())
// later: named.Set("main", updatedHandler)
```
</details>
## Benchmarks
Apples-to-apples micro-benchmarks against the five most-starred Go Telegram libraries (`go-telegram-bot-api`, `telebot.v3`, `go-telegram/bot`, `telego`, `echotron`) live under [`test/benchmarks/`](test/benchmarks/) as a separate Go module.
<details>
<summary>Results — Apple M4 Max · darwin/arm64 · go1.26.2</summary>
| Path | Fastest | Our position |
|------|---------|--------------|
| Webhook decode (small Update) | **ours** — 1.74 µs / 11 allocs | 1st of 6 |
| Large Update unmarshal (unions + reply markup) | **ours** — 6.67 µs / 34 allocs | 1st of 6 |
| `sendMessage` round-trip (mock server) | telego — 36.3 µs / 48 allocs | 2nd of 5 |
| Dispatcher routing (20 handlers, last matches) | **ours** — 101 ns / 3 allocs | 1st of 3 |
Full tables, caveats, and reproduction steps: **[`docs/benchmarks/2026-05-10-comparison.md`](docs/benchmarks/2026-05-10-comparison.md)**.
</details>
## Keeping up with Telegram
When Telegram ships a new Bot API version, regenerating the whole library is one command:
```bash
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:
```go
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](CONTRIBUTING.md).
## License
MIT