A fully-generated, strongly-typed Go client for the Telegram Bot API. * 176 methods + 301 types generated from Bot API v10.0 * 1408 auto-generated tests (8 scenarios per method) * Typed unions throughout — no 'any' in the public surface * Pluggable HTTP transport and JSON codec (default goccy/go-json) * Built-in retry middleware honouring Telegram's retry_after * Generic dispatcher with filters and conversation handlers * Self-verifying codegen pipeline (regen → audit → emit → run tests) * 14 example bots covering common patterns
21 KiB
go-telegram — Design Spec
- Date: 2026-05-08
- Module:
github.com/lukaszraczylo/go-telegram - License: MIT
- Status: Approved for planning
- Author: Lukasz Raczylo (with Claude assistance)
1. Purpose & scope
A Go library for the Telegram Bot API, primarily a portfolio piece showcasing:
- Codegen-driven full API coverage (parsed from
https://core.telegram.org/bots/api) - Pragmatic Go generics
- Pluggable HTTP transport and JSON codec for resource-conscious deployments
- Long-poll and webhook update delivery behind a unified interface
- A typed dispatcher/router for handlers, commands, and callbacks
- Comprehensive testify-based unit tests with golden fixtures for codegen
Out of scope for v1: the daily auto-regen GitHub Action (deferred — see §11).
2. Non-goals
- Competing on completeness/maturity with
mymmrac/telegoorgo-telegram-bot-api/telegram-bot-api. We optimise for clarity and design. - Bot-framework features beyond the dispatcher (no plugin marketplace, no FSM, no scenes).
- A documentation website.
pkg.go.dev+ README is sufficient.
3. Requirements
Functional
- Cover all Telegram Bot API methods and types via codegen.
- Support long-poll and webhook update delivery, both implementing one
Updaterinterface. - Allow the user to swap the HTTP client (e.g.
valyala/fasthttp) and JSON codec (e.g.goccy/go-json). - Provide a typed dispatcher with command, text-regex, callback, and inline-query matching plus generic middleware.
- Provide unit tests using
stretchr/testifycovering happy paths and explicit edge cases. - Provide an optional integration test suite gated by build tag and env vars.
Non-functional
- Lean dependency footprint (stdlib +
golang.org/x/net/html+ testify). - Deterministic, reproducible codegen (
go test ./... -run TestGenis hermetic). - Generated files committed to the repo so consumers do not need to run codegen.
- Doc comments on every exported symbol; generated types carry Telegram's verbatim prose.
4. Architecture
Two-stage codegen with a JSON intermediate representation:
HTML page cmd/scrape api.json (IR) cmd/genapi api/*.gen.go
The IR is committed; PRs from a future regen workflow show the diff against the previous IR, providing a readable changelog of Telegram-side changes.
4.1 Repository layout
go-telegram/
├── api/ GENERATED types + method wrappers (typed param structs)
│ ├── types.gen.go
│ ├── methods.gen.go
│ └── enums.gen.go
├── client/ HAND Bot client, request building, error handling
│ ├── client.go
│ ├── codec.go Codec interface + encoding/json default
│ ├── httpclient.go HTTPDoer interface + net/http default
│ ├── errors.go Typed APIError, NetworkError, ParseError
│ └── result.go generic Result[T] decode
├── transport/ HAND Updater abstraction
│ ├── updater.go Updater interface
│ ├── longpoll.go LongPoller
│ └── webhook.go WebhookServer
├── dispatch/ HAND Handler router
│ ├── router.go Router, OnCommand/OnCallback/OnText
│ ├── middleware.go Generic Middleware[T]
│ └── context.go Per-update context
├── internal/
│ └── spec/ Shared IR types
│ ├── ir.go Types describing parsed Telegram API
│ └── api.json Committed golden IR (regenerated by scraper)
├── cmd/
│ ├── scrape/ HTML → api.json
│ └── genapi/ api.json → api/*.gen.go
├── examples/
│ ├── echo/ Long-poll echo bot
│ └── webhook/ Webhook bot with command router
├── testdata/
│ ├── html/ Golden HTML snapshots for scraper
│ ├── golden/ Expected api.json + emitted Go for codegen tests
│ └── responses/ Canned Telegram JSON responses
├── .github/workflows/
│ └── ci.yml lint + test + codegen-clean check
├── Makefile regen, test, lint targets
├── go.mod module github.com/lukaszraczylo/go-telegram
├── LICENSE MIT
└── README.md
5. Core types and client (client/)
5.1 Pluggability interfaces
// Codec is the JSON encoder/decoder. Default impl wraps encoding/json.
type Codec interface {
Marshal(v any) ([]byte, error)
Unmarshal(data []byte, v any) error
}
// HTTPDoer is the HTTP transport. Default is *http.Client.
type HTTPDoer interface {
Do(req *http.Request) (*http.Response, error)
}
// Logger is a slog-shaped interface; nil-safe default writes nowhere.
type Logger interface {
Debug(msg string, attrs ...any)
Info(msg string, attrs ...any)
Warn(msg string, attrs ...any)
Error(msg string, attrs ...any)
}
5.2 Bot client
type Bot struct {
token string
base string // https://api.telegram.org
http HTTPDoer
codec Codec
logger Logger
}
type Option func(*Bot)
func WithHTTPClient(c HTTPDoer) Option
func WithCodec(c Codec) Option
func WithBaseURL(url string) Option
func WithLogger(l Logger) Option
func New(token string, opts ...Option) *Bot
Constructor-level functional options only; per-call params are typed structs (codegen-friendly).
5.3 Result envelope and call helper
type Result[T any] struct {
OK bool `json:"ok"`
Result T `json:"result,omitempty"`
ErrorCode int `json:"error_code,omitempty"`
Description string `json:"description,omitempty"`
Parameters *ResponseParameters `json:"parameters,omitempty"`
}
// Single point for marshalling, URL signing, decoding, error mapping.
// Used by every generated method wrapper.
func call[Req, Resp any](ctx context.Context, b *Bot, method string, req Req) (Resp, error)
Generated wrappers stay thin:
func (b *Bot) SendMessage(ctx context.Context, p *SendMessageParams) (*Message, error) {
return call[*SendMessageParams, *Message](ctx, b, "sendMessage", p)
}
5.4 Errors
type APIError struct {
Code int
Description string
Parameters *ResponseParameters // retry_after, migrate_to_chat_id
}
func (e *APIError) Error() string
func (e *APIError) IsRetryable() bool // 429, 5xx
func (e *APIError) RetryAfter() time.Duration
type NetworkError struct{ Err error }
type ParseError struct{ Err error; Body []byte }
Sentinel errors via errors.Is: ErrUnauthorized, ErrChatNotFound, ErrMessageNotModified, ErrTooManyRequests. Mapped from error_code plus description-prefix matching.
6. Codegen pipeline
6.1 Stage 1 — cmd/scrape/
- Input: live URL
https://core.telegram.org/bots/api(default) or a local HTML fixture (-inputflag). - Parser:
golang.org/x/net/html(no goquery). - Walk strategy: traverse
<h4>headings sequentially. Lowercase first letter → method. Uppercase → type. - Following
<p>until next heading → description. - Following
<table>→ fields/params (columns: Field|Type|Required|Description for types; Parameter|Type|Required|Description for methods). - Return type extracted by regex on description:
Returns *X* on success,Returns an Array of X,Returns True on success. - Italic markers in the type column denote optional, array depth, and union-member candidates.
- "Recent changes" section parsed for current API version.
6.2 Intermediate representation (internal/spec/ir.go)
type API struct {
Version string
Types []TypeDecl
Methods []MethodDecl
}
type TypeDecl struct {
Name string
Doc string
Fields []Field
OneOf []string // unions (InputMedia, ChatMember, …)
}
type MethodDecl struct {
Name string // sendMessage
Doc string
Params []Field
Returns TypeRef
HasFiles bool // forces multipart
}
type Field struct {
Name string
JSONName string
Type TypeRef
Required bool
Doc string
}
type Kind int
const (
KindPrimitive Kind = iota
KindNamed
KindArray
KindOneOf
)
type TypeRef struct {
Kind Kind
Name string
ElemType *TypeRef
Variants []string
}
internal/spec/api.json is committed. Marshalling is stable (sorted fields, deterministic JSON output) so diffs read as a Telegram changelog.
6.3 Stage 2 — cmd/genapi/
- Reads
api.json. - Emits Go via
text/template, finalised withgo/format. - Templates:
types.tmpl→ struct perTypeDecl. Optional fields are pointers (oromitemptyfor slices/maps). Doc comments verbatim from API.enums.tmpl→ string consts for known enumerations (parse modes, chat types, etc., extracted from doc prose).oneof.tmpl→ union types asinterface { isFooBar() }plus concrete impls and aUnmarshalJSONthat switches on a discriminator field (typicallytypeorsource).methods.tmpl→ param struct + thinBot.<MethodName>wrapper usingcall[…].multipart.tmpl→ for methods withHasFiles, custom request builder usingmime/multipart.
- Header on every emitted file:
// Code generated by cmd/genapi. DO NOT EDIT.+//go:build !ignore_autogenerated.
6.4 Makefile contract
The Makefile owns the codegen entry points; tools and CI call make, never raw go run:
| Target | What it does |
|---|---|
make snapshot |
curl -fsSL https://core.telegram.org/bots/api > testdata/html/snapshot_<date>.html and update a latest.html symlink. |
make regen |
Run scraper against testdata/html/latest.html, then run emitter. Writes internal/spec/api.json and api/*.gen.go. |
make regen-from-fixture |
Same as make regen but pinned to testdata/html/snapshot_2026-05-08.html for deterministic CI checks. |
make test |
go test -race ./... |
make test-update-golden |
go test -run TestGen -update ./... to refresh golden fixtures. |
make lint |
go vet + staticcheck. |
make integration |
go test -tags=integration ./test/integration/... (requires env). |
7. Transport (transport/)
type Updater interface {
Updates() <-chan api.Update
Run(ctx context.Context) error
Stop(ctx context.Context) error
}
7.1 LongPoller
type LongPoller struct {
Bot *client.Bot
Timeout int // seconds, default 30
Limit int // 1..100, default 100
AllowedTypes []api.UpdateType
Backoff BackoffStrategy
}
Calls getUpdates in a loop, tracks offset, applies exponential backoff on transient errors via BackoffStrategy.
7.2 WebhookServer
type WebhookServer struct {
Bot *client.Bot
SecretToken string // verify X-Telegram-Bot-Api-Secret-Token
BufferSize int
}
func (w *WebhookServer) ServeHTTP(rw http.ResponseWriter, r *http.Request)
func (w *WebhookServer) ListenAndServe(ctx context.Context, addr string) error
ServeHTTP lets users mount on their own router. ListenAndServe is a convenience for standalone use.
8. Dispatcher (dispatch/)
type Context struct {
Ctx context.Context
Bot *client.Bot
Update *api.Update
Values map[string]any // matched groups, command args
}
type Handler[T any] func(ctx *Context, payload T) error
type Middleware[T any] func(Handler[T]) Handler[T]
type Router struct{ /* … */ }
func New(bot *client.Bot) *Router
func (r *Router) OnCommand(cmd string, h Handler[*api.Message])
func (r *Router) OnText(pattern string, h Handler[*api.Message])
func (r *Router) OnCallback(pattern string, h Handler[*api.CallbackQuery])
func (r *Router) OnInlineQuery(h Handler[*api.InlineQuery])
func (r *Router) OnEditedMessage(h Handler[*api.Message])
func (r *Router) Use(mw Middleware[*api.Update])
func (r *Router) Run(ctx context.Context, u transport.Updater) error
Matchers run in registration order; first match wins. A panic-recovery middleware is registered automatically. Generic Handler[T] keeps payloads precisely typed in user code.
9. Testing strategy
9.1 Unit tests (every package, fast, no network)
testify/requirefor assertions,testify/mockonclient.HTTPDoer.- Edge cases explicitly covered:
- API error decode for
429withretry_after→*APIError.IsRetryable()=true,RetryAfter()=N. - Network error from transport → wrapped
*NetworkError. - Malformed JSON →
*ParseError. - Void-result methods (
setWebhookreturningbool). - Optional pointer fields nil round-trip.
- OneOf union types unmarshal via discriminator.
- Multipart upload path for methods with
InputFile. - Context cancellation mid-call returns
ctx.Err(). - Long-poller backoff after transient error.
- Webhook secret-token mismatch → 401.
- Webhook handles oversized body, malformed JSON, wrong content-type.
- Router: command match with/without bot mention (
/start@MyBot), regex matchers, panic recovery, middleware ordering, no-match update.
- API error decode for
9.2 Codegen golden tests
testdata/html/
├── snapshot_2026-05-08.html full Bots API page snapshot
└── small_fixture.html hand-crafted minimal page (1 type, 1 method)
testdata/golden/
├── api.json expected IR from snapshot
└── *.gen.go expected emitted Go
cmd/scrapetest: parse fixture → compare to goldenapi.json.cmd/genapitest: read goldenapi.json→ compare emitted Go to golden*.gen.go.-updateflag (custom ininternal/testutil) regenerates goldens deliberately.
9.3 Optional integration suite
- Build tag
//go:build integration. - Skipped by default
go test ./.... - Activated by
go test -tags=integration ./test/integration/.... - Requires
TELEGRAM_BOT_TOKEN(andTELEGRAM_TEST_CHAT_IDwhere applicable). - Covers
getMe,sendMessage,setWebhook/deleteWebhook,getUpdatesloop with short timeout. - Not part of default CI to avoid flakes.
10. CI
.github/workflows/ci.yml (every push and PR):
actions/setup-gomatrix: 1.23, 1.24go vet ./...staticcheck ./...go test -race -coverprofile=coverage.out ./...- Codegen-clean check:
make regen-from-fixture+git diff --exit-codeto assert generated files match the committed IR for the snapshot fixture (deterministic). - Upload coverage artifact.
11. Handling API changes & test maintenance
Telegram ships changes to the Bot API roughly every 1–3 months: new methods, new types, added optional fields, occasional removals or renames, occasional union-variant additions. The design must absorb these with minimum manual work.
11.1 Test-suite invariants under change
Tests are layered so that the cost of an API change is bounded.
| Test layer | Affected by API change? | Cost |
|---|---|---|
client/ unit tests (call helper, error mapping, codec, multipart builder) |
No — they target call[Req,Resp], not specific methods. |
Zero. |
transport/ unit tests (long-poll loop, webhook server) |
No — they target update plumbing, not payload fields. | Zero. |
dispatch/ unit tests (matchers, middleware, router) |
No — generic over *api.Update. |
Zero. |
Codegen golden tests (cmd/scrape, cmd/genapi) |
Yes — golden api.json and *.gen.go will diff. |
Refresh goldens deliberately (go test -run TestGen -update). |
| Codegen "shape" smoke tests | Only if a code-generation pattern changes. | One test per pattern, not per method. |
Examples (examples/echo, examples/webhook) |
Only if they reference a removed/renamed symbol. | Hand-fix; rare. |
Integration suite (build tag integration) |
Only for the ~5 methods it touches. | Hand-fix on removal/rename; rare. |
The deliberate invariant: we do not write a test per generated method. All ~100+ generated wrappers funnel through call[Req,Resp] and the multipart builder; coverage of those two paths covers them all. Each generated method is then sanity-checked by being type-checked at compile time against the IR.
11.2 Shape smoke tests
In api/api_test.go, one test per code-generation pattern, hitting a representative method through a mocked HTTPDoer:
- Simple —
getMe(no params, scalar response). - Typed-struct param —
sendMessage(struct in, object out). - Optional fields —
sendMessagewith only required fields set; verify omitted fields do not appear in the request body. - Array result —
getUpdates(array ofUpdate). - Bool result —
setWebhook. - Multipart upload —
sendDocumentwith anInputFile(verify content-type, boundary, field names). - OneOf union response —
getChatMember(returnsChatMemberunion). - OneOf union request —
sendMediaGroup(accepts[]InputMediaunion).
If new code-generation patterns appear (Telegram introducing a new shape we have not seen), one new shape test is added — not one per affected method.
11.3 Categories of change and how each is absorbed
| Change | Pipeline effect | Test effect |
|---|---|---|
| New type | Appears in api.json, emitted into types.gen.go. |
Golden diff only. Refresh. |
| New optional field | Same. | Golden diff only. |
| New required field | Same. Breaking for users who construct that struct literally. | Golden diff only; example code may need update. |
| Removed type/method/field | Disappears from emitted Go. Breaking for users referring to it. | Golden diff. Integration test or example may break — fix or skip. |
| Renamed field | Old name disappears, new appears. | Same as above; no automatic rename (we treat as remove + add). |
| New method | Wrapper generated; no new test required (shape tests cover the call path). | Golden diff. |
| Return type changed for an existing method | Wrapper signature changes. Breaking. | Golden diff; integration test for that method may break. |
| New OneOf variant | UnmarshalJSON switch grows a case. |
Golden diff. If a brand-new variant style appears, may require scraper work and a new shape test. |
| Telegram doc layout change | Scraper may misparse. | Scraper unit test against the new HTML fixture should be added before regenerating. |
11.4 The change procedure
When the scraper output changes:
- Run
make regen-from-fixtureagainst the currenttestdata/html/snapshot_*.html(deterministic check) — confirm zero unrelated diffs. - Capture a fresh HTML snapshot:
make snapshot(writestestdata/html/snapshot_<date>.html). - Run
make regenagainst the new snapshot → IR diff appears ininternal/spec/api.json. - Review the IR diff as a Telegram changelog. This is the human read-through; it is the entire point of having an IR.
- Run
go test ./...→ expect golden codegen diffs. - Refresh goldens:
go test -run TestGen -update. - Re-run
go test ./...→ green. - If shape tests reveal a new code-generation pattern (e.g. a never-before-seen union shape), extend templates and add a shape test before refreshing goldens.
- If
examples/or integration tests reference a removed symbol, fix them. - Commit with a clear message:
chore(api): regenerate from Telegram Bot API vX.Yplus a bullet list extracted from the IR diff (added/changed/removed types and methods).
This is the same procedure the future auto-regen workflow will run; doing it by hand first ensures the workflow has nothing surprising to do.
11.5 Versioning policy
- Library SemVer is decoupled from Telegram's API version.
- Telegram-side additions → minor bump.
- Telegram-side removals or signature changes → major bump (we do not preserve removed symbols as deprecated stubs; the breaking change ships).
- Bug fixes in hand-written code → patch bump.
- Each release records the Telegram API version it was generated against in the release notes and in a
// Generated from Bot API vX.Yconstant inapi/version.gen.go.
12. Future work (deferred)
- Auto-regen workflow — daily cron +
workflow_dispatchthat runscmd/scrapeagainst the live URL, regenerates code, opens a PR with a diff summary, and auto-merges on green CI. Implementation sketch retained in design discussion; not part of v1 acceptance. - Release workflow — tag-triggered
goreleaserpipeline producing GH Releases. SemVer for the library; Telegram's API version recorded in release notes. - Additional codecs/HTTP adapters as separate sub-packages or contrib modules so users can opt in without bringing transitive deps into the core.
13. Dependency policy
Production:
- Go standard library
golang.org/x/net/html(scraper only)
Test-only:
github.com/stretchr/testify
Explicit non-deps: goquery, cobra, third-party logging, third-party HTTP clients, third-party JSON codecs (these are user-supplied via HTTPDoer and Codec).
14. Acceptance criteria
v1 is done when:
go test ./...passes on a clean checkout with no env vars.make regenproduces zero diff against the committedapi.jsonandapi/*.gen.gowhen run against the committed HTML fixture.examples/echoandexamples/webhookbuild and the echo example runs end-to-end against a real bot whenTELEGRAM_BOT_TOKENis set.go vet,staticcheck, andgo test -raceare clean.- Every exported symbol in hand-written packages has a doc comment.
- README covers Why, Install, Quick Start, Custom HTTP/JSON, Webhooks, Dispatcher, Updating, Contributing.
- The integration test suite (
-tags=integration) runs cleanly when env is provided.
15. Open questions
None at sign-off. (Auto-regen behaviour intentionally deferred.)