# 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/telego` or `go-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 1. Cover all Telegram Bot API methods and types via codegen. 2. Support long-poll and webhook update delivery, both implementing one `Updater` interface. 3. Allow the user to swap the HTTP client (e.g. `valyala/fasthttp`) and JSON codec (e.g. `goccy/go-json`). 4. Provide a typed dispatcher with command, text-regex, callback, and inline-query matching plus generic middleware. 5. Provide unit tests using `stretchr/testify` covering happy paths and explicit edge cases. 6. 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 TestGen` is 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 ```go // 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 ```go 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 ```go 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: ```go func (b *Bot) SendMessage(ctx context.Context, p *SendMessageParams) (*Message, error) { return call[*SendMessageParams, *Message](ctx, b, "sendMessage", p) } ``` ### 5.4 Errors ```go 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 (`-input` flag). - Parser: `golang.org/x/net/html` (no goquery). - Walk strategy: traverse `

` headings sequentially. Lowercase first letter → method. Uppercase → type. - Following `

` until next heading → description. - Following `` → 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`) ```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 with `go/format`. - Templates: - `types.tmpl` → struct per `TypeDecl`. Optional fields are pointers (or `omitempty` for 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 as `interface { isFooBar() }` plus concrete impls and a `UnmarshalJSON` that switches on a discriminator field (typically `type` or `source`). - `methods.tmpl` → param struct + thin `Bot.` wrapper using `call[…]`. - `multipart.tmpl` → for methods with `HasFiles`, custom request builder using `mime/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_.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/`) ```go type Updater interface { Updates() <-chan api.Update Run(ctx context.Context) error Stop(ctx context.Context) error } ``` ### 7.1 LongPoller ```go 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 ```go 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/`) ```go 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/require` for assertions, `testify/mock` on `client.HTTPDoer`. - Edge cases explicitly covered: - API error decode for `429` with `retry_after` → `*APIError.IsRetryable()=true`, `RetryAfter()=N`. - Network error from transport → wrapped `*NetworkError`. - Malformed JSON → `*ParseError`. - Void-result methods (`setWebhook` returning `bool`). - 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. ### 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/scrape` test: parse fixture → compare to golden `api.json`. - `cmd/genapi` test: read golden `api.json` → compare emitted Go to golden `*.gen.go`. - `-update` flag (custom in `internal/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` (and `TELEGRAM_TEST_CHAT_ID` where applicable). - Covers `getMe`, `sendMessage`, `setWebhook`/`deleteWebhook`, `getUpdates` loop with short timeout. - Not part of default CI to avoid flakes. ## 10. CI `.github/workflows/ci.yml` (every push and PR): - `actions/setup-go` matrix: 1.23, 1.24 - `go vet ./...` - `staticcheck ./...` - `go test -race -coverprofile=coverage.out ./...` - Codegen-clean check: `make regen-from-fixture` + `git diff --exit-code` to 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** — `sendMessage` with only required fields set; verify omitted fields do not appear in the request body. - **Array result** — `getUpdates` (array of `Update`). - **Bool result** — `setWebhook`. - **Multipart upload** — `sendDocument` with an `InputFile` (verify content-type, boundary, field names). - **OneOf union response** — `getChatMember` (returns `ChatMember` union). - **OneOf union request** — `sendMediaGroup` (accepts `[]InputMedia` union). 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: 1. Run `make regen-from-fixture` against the current `testdata/html/snapshot_*.html` (deterministic check) — confirm zero unrelated diffs. 2. Capture a fresh HTML snapshot: `make snapshot` (writes `testdata/html/snapshot_.html`). 3. Run `make regen` against the new snapshot → IR diff appears in `internal/spec/api.json`. 4. Review the IR diff as a Telegram changelog. This is the human read-through; it is the entire point of having an IR. 5. Run `go test ./...` → expect golden codegen diffs. 6. Refresh goldens: `go test -run TestGen -update`. 7. Re-run `go test ./...` → green. 8. 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. 9. If `examples/` or integration tests reference a removed symbol, fix them. 10. Commit with a clear message: `chore(api): regenerate from Telegram Bot API vX.Y` plus 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.Y` constant in `api/version.gen.go`. ## 12. Future work (deferred) - **Auto-regen workflow** — daily cron + `workflow_dispatch` that runs `cmd/scrape` against 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 `goreleaser` pipeline 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: 1. `go test ./...` passes on a clean checkout with no env vars. 2. `make regen` produces zero diff against the committed `api.json` and `api/*.gen.go` when run against the committed HTML fixture. 3. `examples/echo` and `examples/webhook` build and the echo example runs end-to-end against a real bot when `TELEGRAM_BOT_TOKEN` is set. 4. `go vet`, `staticcheck`, and `go test -race` are clean. 5. Every exported symbol in hand-written packages has a doc comment. 6. README covers Why, Install, Quick Start, Custom HTTP/JSON, Webhooks, Dispatcher, Updating, Contributing. 7. The integration test suite (`-tags=integration`) runs cleanly when env is provided. ## 15. Open questions None at sign-off. (Auto-regen behaviour intentionally deferred.)