Files
go-telegram/docs/superpowers/plans/2026-05-08-go-telegram-core.md
T
lukaszraczylo 9072e9eafb Initial release of go-telegram
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
2026-05-09 13:09:27 +01:00

102 KiB
Raw Blame History

go-telegram Core Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the hand-written core of github.com/lukaszraczylo/go-telegram — a pluggable Bot client, long-poll + webhook transports, and a typed dispatcher — with a hand-coded api/ slice covering the ~8 methods needed for echo and webhook example bots, fully tested via mocked HTTP transport.

Architecture: Three hand-written packages (client, transport, dispatch) plus a hand-coded slice of api/ whose conventions match what the codegen pipeline (Plan 2) will later produce. Pluggable HTTP via HTTPDoer interface, pluggable JSON via Codec interface, all generated method calls funnel through a single generic call[Req,Resp] helper. Updates flow Updater → chan Update → Router → Handler[T].

Tech Stack: Go 1.23+, standard library only for production code, github.com/stretchr/testify for tests. No third-party HTTP, JSON, or logging libraries in core (users plug their own).

Reference: Design spec. Read it before starting if you are unfamiliar with the project.


Conventions for every task

  • Work from repo root (/Users/nvm/Documents/projects/private/go-telegram).
  • Run go test ./... after every implementation step that adds tests.
  • Commit at the end of every task with the message shown.
  • Use gofmt (run automatically on save). All files must go vet clean.
  • For hand-written API types and methods (Task 1112), follow the convention spec'd here so Plan 2 codegen output is byte-identical:
    • Optional fields: pointer (*int64) or slice/map with omitempty.
    • Required fields: bare type, no omitempty.
    • Doc comment on every exported symbol; verbatim Telegram doc prose preferred.
    • Field tag pattern: json:"snake_case_name,omitempty" (omit omitempty only on required scalars).
    • Method param structs named <MethodName>Params (e.g. SendMessageParams).
    • Method wrappers on *Bot, signature func (b *Bot) MethodName(ctx context.Context, p *MethodNameParams) (*ReturnType, error) (or (ReturnType, error) for non-pointer returns).

Task 1 — Repository foundation

Files:

  • Create: go.mod

  • Create: LICENSE

  • Create: .gitignore

  • Create: Makefile

  • Create: README.md

  • Create: doc.go (package-level godoc anchor at module root)

  • Step 1: Initialise Go module

go mod init github.com/lukaszraczylo/go-telegram
  • Step 2: Write LICENSE
MIT License

Copyright (c) 2026 Lukasz Raczylo

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
  • Step 3: Write .gitignore
# Binaries
/bin/
*.exe
*.dll
*.so
*.dylib

# Test artifacts
*.test
*.out
coverage.out
coverage.html

# IDE
.idea/
.vscode/
*.swp
*~

# OS
.DS_Store

# Local env
.env
.env.local
  • Step 4: Write Makefile (regen targets are stubs in Plan 1; Plan 2 fills them in)
.PHONY: test test-race lint vet integration regen snapshot regen-from-fixture test-update-golden help

GO ?= go

help:
	@echo "Targets:"
	@echo "  test                 - run unit tests"
	@echo "  test-race            - run unit tests with race detector"
	@echo "  lint                 - go vet + staticcheck"
	@echo "  integration          - run integration suite (requires TELEGRAM_BOT_TOKEN)"
	@echo "  snapshot             - capture HTML snapshot from live API (Plan 2)"
	@echo "  regen                - regenerate api/ from latest snapshot (Plan 2)"
	@echo "  regen-from-fixture   - deterministic regen from pinned fixture (Plan 2)"
	@echo "  test-update-golden   - refresh golden test fixtures (Plan 2)"

test:
	$(GO) test ./...

test-race:
	$(GO) test -race ./...

vet:
	$(GO) vet ./...

lint: vet
	@which staticcheck > /dev/null || (echo "install staticcheck: go install honnef.co/go/tools/cmd/staticcheck@latest" && exit 1)
	staticcheck ./...

integration:
	$(GO) test -tags=integration -v ./test/integration/...

snapshot:
	@echo "Plan 2 — not yet implemented"
	@exit 1

regen:
	@echo "Plan 2 — not yet implemented"
	@exit 1

regen-from-fixture:
	@echo "Plan 2 — not yet implemented"
	@exit 1

test-update-golden:
	@echo "Plan 2 — not yet implemented"
	@exit 1
  • Step 5: Write README.md (stub — full content in Task 22)
# go-telegram

A Go library for the Telegram Bot API with pluggable HTTP transport, pluggable JSON codec, long-poll and webhook delivery, and a typed dispatcher.

> Status: in active development. See `docs/superpowers/plans/` for the implementation roadmap.

## Install

```bash
go get github.com/lukaszraczylo/go-telegram

License

MIT — see LICENSE.


- [ ] **Step 6: Write `doc.go`**

```go
// Package gotelegram is the module root.
//
// The public API lives in the api, client, transport, and dispatch packages.
// See https://github.com/lukaszraczylo/go-telegram for documentation.
package gotelegram
  • Step 7: Verify build

Run: go build ./... Expected: success (no source files yet beyond doc.go, but module resolves cleanly).

  • Step 8: Commit
git add -A
git commit -m "chore: scaffold repository (module, license, makefile, README stub)"

Task 2 — CI workflow

Files:

  • Create: .github/workflows/ci.yml

  • Step 1: Write the CI workflow

name: ci

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        go-version: ['1.23.x', '1.24.x']
    steps:
      - uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: ${{ matrix.go-version }}
          check-latest: true

      - name: Cache modules
        uses: actions/cache@v4
        with:
          path: |
            ~/go/pkg/mod
            ~/.cache/go-build
          key: ${{ runner.os }}-go-${{ matrix.go-version }}-${{ hashFiles('**/go.sum') }}

      - name: Install staticcheck
        run: go install honnef.co/go/tools/cmd/staticcheck@latest

      - name: Vet
        run: go vet ./...

      - name: Staticcheck
        run: staticcheck ./...

      - name: Test (race + cover)
        run: go test -race -coverprofile=coverage.out ./...

      - name: Upload coverage
        if: matrix.go-version == '1.24.x'
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage.out
  • Step 2: Commit
git add .github/
git commit -m "ci: add Go test + lint workflow"

Task 3 — IR types (internal/spec/ir.go)

The IR is finalised now even though Plan 1 does not exercise it; defining it here means client code structures match the eventual codegen output, and Plan 2 has zero design work to do.

Files:

  • Create: internal/spec/ir.go

  • Create: internal/spec/ir_test.go

  • Step 1: Write the failing test

internal/spec/ir_test.go:

package spec

import (
	"encoding/json"
	"testing"

	"github.com/stretchr/testify/require"
)

func TestAPIRoundTripJSON(t *testing.T) {
	in := API{
		Version: "7.10",
		Types: []TypeDecl{{
			Name: "User",
			Doc:  "This object represents a Telegram user or bot.",
			Fields: []Field{
				{Name: "ID", JSONName: "id", Type: TypeRef{Kind: KindPrimitive, Name: "int64"}, Required: true, Doc: "Unique identifier."},
				{Name: "Username", JSONName: "username", Type: TypeRef{Kind: KindPrimitive, Name: "string"}, Required: false, Doc: "Optional username."},
			},
		}},
		Methods: []MethodDecl{{
			Name:    "getMe",
			Doc:     "A simple method for testing your bot's authentication token.",
			Returns: TypeRef{Kind: KindNamed, Name: "User"},
		}},
	}

	data, err := json.MarshalIndent(in, "", "  ")
	require.NoError(t, err)

	var out API
	require.NoError(t, json.Unmarshal(data, &out))
	require.Equal(t, in, out)
}

func TestTypeRefKindString(t *testing.T) {
	require.Equal(t, "primitive", KindPrimitive.String())
	require.Equal(t, "named", KindNamed.String())
	require.Equal(t, "array", KindArray.String())
	require.Equal(t, "oneOf", KindOneOf.String())
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/spec/... Expected: build failure — spec package does not exist.

  • Step 3: Implement internal/spec/ir.go
// Package spec defines the intermediate representation produced by the
// Telegram Bot API scraper (cmd/scrape) and consumed by the code generator
// (cmd/genapi). It is committed as internal/spec/api.json so PR diffs read
// as a Telegram changelog.
package spec

// API is the top-level IR document.
type API struct {
	// Version is the Telegram Bot API version parsed from the
	// "Recent changes" section of the docs page.
	Version string `json:"version"`
	// Types lists all object types in declaration order.
	Types []TypeDecl `json:"types"`
	// Methods lists all API methods in declaration order.
	Methods []MethodDecl `json:"methods"`
}

// TypeDecl describes a Telegram object type.
type TypeDecl struct {
	Name   string   `json:"name"`
	Doc    string   `json:"doc,omitempty"`
	Fields []Field  `json:"fields,omitempty"`
	// OneOf, when non-empty, indicates this type is a union and lists the
	// concrete variant type names. Variants are emitted as concrete structs
	// implementing a sealed interface.
	OneOf []string `json:"one_of,omitempty"`
}

// MethodDecl describes a Telegram API method.
type MethodDecl struct {
	Name     string  `json:"name"`
	Doc      string  `json:"doc,omitempty"`
	Params   []Field `json:"params,omitempty"`
	Returns  TypeRef `json:"returns"`
	// HasFiles is true when any parameter accepts an InputFile, requiring
	// a multipart/form-data request.
	HasFiles bool `json:"has_files,omitempty"`
}

// Field describes a single field on a type or a single parameter on a method.
type Field struct {
	// Name is the Go-style identifier (e.g. "ChatID").
	Name string `json:"name"`
	// JSONName is the wire name (e.g. "chat_id").
	JSONName string  `json:"json_name"`
	Type     TypeRef `json:"type"`
	Required bool    `json:"required,omitempty"`
	Doc      string  `json:"doc,omitempty"`
}

// Kind enumerates TypeRef shapes.
type Kind int

const (
	// KindPrimitive: int64, string, bool, float64.
	KindPrimitive Kind = iota
	// KindNamed: a TypeDecl by name.
	KindNamed
	// KindArray: ElemType is the element type.
	KindArray
	// KindOneOf: Variants lists discriminant union members.
	KindOneOf
)

// String returns a stable, lowercase representation suitable for serialisation.
func (k Kind) String() string {
	switch k {
	case KindPrimitive:
		return "primitive"
	case KindNamed:
		return "named"
	case KindArray:
		return "array"
	case KindOneOf:
		return "oneOf"
	default:
		return "unknown"
	}
}

// MarshalText / UnmarshalText keep JSON output human-readable.
func (k Kind) MarshalText() ([]byte, error) { return []byte(k.String()), nil }

func (k *Kind) UnmarshalText(b []byte) error {
	switch string(b) {
	case "primitive":
		*k = KindPrimitive
	case "named":
		*k = KindNamed
	case "array":
		*k = KindArray
	case "oneOf":
		*k = KindOneOf
	default:
		*k = -1
	}
	return nil
}

// TypeRef is a structural reference used wherever a Field type is expressed.
type TypeRef struct {
	Kind     Kind     `json:"kind"`
	Name     string   `json:"name,omitempty"`
	ElemType *TypeRef `json:"elem_type,omitempty"`
	Variants []string `json:"variants,omitempty"`
}
  • Step 4: Run test to verify it passes

Run: go test ./internal/spec/... Expected: PASS.

  • Step 5: Commit
git add internal/
git commit -m "feat(spec): define IR types for codegen pipeline"

Task 4 — Client: Codec interface + default

Files:

  • Create: client/codec.go

  • Create: client/codec_test.go

  • Step 1: Write the failing test

package client

import (
	"testing"

	"github.com/stretchr/testify/require"
)

func TestDefaultCodec_RoundTrip(t *testing.T) {
	c := DefaultCodec{}
	type payload struct {
		Name string `json:"name"`
		N    int    `json:"n"`
	}
	in := payload{Name: "x", N: 7}
	data, err := c.Marshal(in)
	require.NoError(t, err)
	require.JSONEq(t, `{"name":"x","n":7}`, string(data))

	var out payload
	require.NoError(t, c.Unmarshal(data, &out))
	require.Equal(t, in, out)
}

func TestDefaultCodec_UnmarshalError(t *testing.T) {
	var v map[string]any
	err := DefaultCodec{}.Unmarshal([]byte(`not json`), &v)
	require.Error(t, err)
}
  • Step 2: Run test — expect compile failure (Codec/DefaultCodec missing)

Run: go test ./client/... Expected: FAIL — undefined Codec, DefaultCodec.

  • Step 3: Implement client/codec.go
package client

import "encoding/json"

// Codec encodes/decodes JSON payloads exchanged with the Telegram Bot API.
// The default implementation wraps encoding/json. Users may plug in
// goccy/go-json, bytedance/sonic, or any compatible encoder by passing
// WithCodec to New.
type Codec interface {
	Marshal(v any) ([]byte, error)
	Unmarshal(data []byte, v any) error
}

// DefaultCodec wraps encoding/json. It is the zero-value safe default.
type DefaultCodec struct{}

// Marshal calls json.Marshal.
func (DefaultCodec) Marshal(v any) ([]byte, error) { return json.Marshal(v) }

// Unmarshal calls json.Unmarshal.
func (DefaultCodec) Unmarshal(data []byte, v any) error { return json.Unmarshal(data, v) }
  • Step 4: Run test — expect PASS

Run: go test ./client/... Expected: PASS.

  • Step 5: Commit
git add client/
git commit -m "feat(client): add Codec interface with encoding/json default"

Task 5 — Client: HTTPDoer interface + default

Files:

  • Create: client/httpclient.go

  • Create: client/httpclient_test.go

  • Step 1: Write the failing test

package client

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/stretchr/testify/require"
)

func TestDefaultHTTPClient_Do(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusTeapot)
	}))
	t.Cleanup(srv.Close)

	doer := NewDefaultHTTPDoer()
	req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
	require.NoError(t, err)
	resp, err := doer.Do(req)
	require.NoError(t, err)
	defer resp.Body.Close()
	require.Equal(t, http.StatusTeapot, resp.StatusCode)
}
  • Step 2: Run test — expect compile failure

Run: go test ./client/... Expected: FAIL — undefined HTTPDoer, NewDefaultHTTPDoer.

  • Step 3: Implement client/httpclient.go
package client

import (
	"net"
	"net/http"
	"time"
)

// HTTPDoer abstracts the HTTP transport. The default is a net/http client
// tuned for Telegram's long-poll usage. Users may plug in valyala/fasthttp
// (via an adapter), or any custom retry/circuit-breaker client by passing
// WithHTTPClient to New.
type HTTPDoer interface {
	Do(req *http.Request) (*http.Response, error)
}

// NewDefaultHTTPDoer returns an *http.Client with sensible defaults for
// Telegram Bot API usage:
//   - 60s overall timeout (longer than typical long-poll Timeout=30s).
//   - Connection pooling sized for a small number of long-lived hosts.
//   - HTTP/2 enabled (default in net/http).
func NewDefaultHTTPDoer() *http.Client {
	t := &http.Transport{
		Proxy: http.ProxyFromEnvironment,
		DialContext: (&net.Dialer{
			Timeout:   10 * time.Second,
			KeepAlive: 30 * time.Second,
		}).DialContext,
		MaxIdleConns:          16,
		MaxIdleConnsPerHost:   8,
		IdleConnTimeout:       90 * time.Second,
		TLSHandshakeTimeout:   10 * time.Second,
		ExpectContinueTimeout: 1 * time.Second,
		ForceAttemptHTTP2:     true,
	}
	return &http.Client{
		Transport: t,
		Timeout:   60 * time.Second,
	}
}
  • Step 4: Run test — expect PASS

Run: go test ./client/... Expected: PASS.

  • Step 5: Commit
git add client/httpclient.go client/httpclient_test.go
git commit -m "feat(client): add HTTPDoer interface with tuned net/http default"

Task 6 — Client: Logger interface + nil-safe noop default

Files:

  • Create: client/logger.go

  • Create: client/logger_test.go

  • Step 1: Write the failing test

package client

import "testing"

func TestNoopLogger_DoesNotPanic(t *testing.T) {
	var l Logger = NoopLogger{}
	l.Debug("d", "k", "v")
	l.Info("i")
	l.Warn("w")
	l.Error("e")
}

func TestNoopLogger_NilSafe(t *testing.T) {
	defer func() {
		if r := recover(); r != nil {
			t.Fatalf("nil logger should be usable through helper, got panic: %v", r)
		}
	}()
	var l Logger
	logDebug(l, "x")
	logInfo(l, "y")
	logWarn(l, "z")
	logError(l, "e")
}
  • Step 2: Run test — expect compile failure

Run: go test ./client/...

  • Step 3: Implement client/logger.go
package client

// Logger is a slog-shaped logging interface. Users pass any compatible
// implementation via WithLogger. The default is NoopLogger, which discards
// everything. Internal helpers (logDebug, logInfo, logWarn, logError) are
// nil-safe: passing a nil Logger is equivalent to NoopLogger.
type Logger interface {
	Debug(msg string, attrs ...any)
	Info(msg string, attrs ...any)
	Warn(msg string, attrs ...any)
	Error(msg string, attrs ...any)
}

// NoopLogger discards all log records. It is the zero-value safe default.
type NoopLogger struct{}

func (NoopLogger) Debug(string, ...any) {}
func (NoopLogger) Info(string, ...any)  {}
func (NoopLogger) Warn(string, ...any)  {}
func (NoopLogger) Error(string, ...any) {}

func logDebug(l Logger, msg string, attrs ...any) {
	if l == nil {
		return
	}
	l.Debug(msg, attrs...)
}
func logInfo(l Logger, msg string, attrs ...any) {
	if l == nil {
		return
	}
	l.Info(msg, attrs...)
}
func logWarn(l Logger, msg string, attrs ...any) {
	if l == nil {
		return
	}
	l.Warn(msg, attrs...)
}
func logError(l Logger, msg string, attrs ...any) {
	if l == nil {
		return
	}
	l.Error(msg, attrs...)
}
  • Step 4: Run test — PASS

Run: go test ./client/...

  • Step 5: Commit
git add client/logger.go client/logger_test.go
git commit -m "feat(client): add Logger interface with nil-safe noop default"

Task 7 — Client: Bot + functional options + Result envelope

Files:

  • Create: client/client.go

  • Create: client/options.go

  • Create: client/result.go

  • Create: client/client_test.go

  • Step 1: Write the failing test

client/client_test.go:

package client

import (
	"net/http"
	"testing"

	"github.com/stretchr/testify/require"
)

func TestNew_Defaults(t *testing.T) {
	b := New("123:abc")
	require.Equal(t, "123:abc", b.token)
	require.Equal(t, defaultBaseURL, b.base)
	require.NotNil(t, b.http)
	require.NotNil(t, b.codec)
	require.NotNil(t, b.logger)
}

func TestNew_OptionsApplied(t *testing.T) {
	custom := &http.Client{}
	type fakeCodec struct{ DefaultCodec }
	c := fakeCodec{}

	b := New("t",
		WithHTTPClient(custom),
		WithCodec(c),
		WithBaseURL("https://example.test"),
		WithLogger(NoopLogger{}),
	)
	require.Same(t, custom, b.http)
	require.Equal(t, c, b.codec)
	require.Equal(t, "https://example.test", b.base)
}

func TestResultRoundTrip(t *testing.T) {
	in := Result[int64]{OK: true, Result: 42}
	data, err := DefaultCodec{}.Marshal(in)
	require.NoError(t, err)
	var out Result[int64]
	require.NoError(t, DefaultCodec{}.Unmarshal(data, &out))
	require.Equal(t, in, out)
}
  • Step 2: Run test — expect compile failure

  • Step 3: Implement client/client.go

package client

const defaultBaseURL = "https://api.telegram.org"

// Bot is the Telegram Bot API client. Construct via New. All API methods
// (declared in package api) hang off *Bot via thin wrappers around call.
type Bot struct {
	token  string
	base   string
	http   HTTPDoer
	codec  Codec
	logger Logger
}

// Token returns the bot token. Exposed for advanced use cases (custom
// transports, manual URL building); ordinary code does not need it.
func (b *Bot) Token() string { return b.token }

// BaseURL returns the configured Telegram API base URL.
func (b *Bot) BaseURL() string { return b.base }

// HTTP returns the underlying HTTPDoer. Exposed for adapters that need
// to share connection pools or for diagnostic checks.
func (b *Bot) HTTP() HTTPDoer { return b.http }

// Codec returns the configured Codec.
func (b *Bot) Codec() Codec { return b.codec }

// Logger returns the configured Logger.
func (b *Bot) Logger() Logger { return b.logger }

// New constructs a Bot with the given token and optional configuration.
// The default HTTP client is tuned for long-poll workloads (see
// NewDefaultHTTPDoer); the default codec wraps encoding/json; the default
// logger discards records.
func New(token string, opts ...Option) *Bot {
	b := &Bot{
		token:  token,
		base:   defaultBaseURL,
		http:   NewDefaultHTTPDoer(),
		codec:  DefaultCodec{},
		logger: NoopLogger{},
	}
	for _, o := range opts {
		o(b)
	}
	return b
}
  • Step 4: Implement client/options.go
package client

// Option configures a Bot at construction time. Per-call configuration is
// expressed via typed parameter structs (e.g. SendMessageParams), not options.
type Option func(*Bot)

// WithHTTPClient overrides the HTTP transport. Pass any HTTPDoer
// implementation (e.g. an *http.Client wrapping a custom RoundTripper, or
// a fasthttp adapter).
func WithHTTPClient(c HTTPDoer) Option { return func(b *Bot) { b.http = c } }

// WithCodec overrides the JSON codec. Pass goccy/go-json, sonic, or any
// type implementing Codec to swap out encoding/json.
func WithCodec(c Codec) Option { return func(b *Bot) { b.codec = c } }

// WithBaseURL overrides the API base URL. Useful for testing against a
// local httptest.Server, or for self-hosted Bot API servers.
func WithBaseURL(url string) Option { return func(b *Bot) { b.base = url } }

// WithLogger sets the logger used for diagnostic events. Passing nil
// silently disables logging.
func WithLogger(l Logger) Option { return func(b *Bot) { b.logger = l } }
  • Step 5: Implement client/result.go
package client

// Result is the universal Telegram API response envelope. Every successful
// response is shaped {"ok":true,"result":T,...}; failure responses set ok
// to false and populate ErrorCode / Description / Parameters.
//
// Result is generic over T so generated method wrappers can decode the
// strongly-typed payload directly. Users do not normally construct or
// inspect Result values; method wrappers unwrap them and return either
// the typed payload or a *APIError.
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"`
}

// ResponseParameters is the optional metadata Telegram includes on certain
// failures. The most common is RetryAfter (seconds) on 429 responses.
//
// This type is duplicated in package api for users; keeping a copy here
// avoids an import cycle (api imports client, not vice versa).
type ResponseParameters struct {
	MigrateToChatID int64 `json:"migrate_to_chat_id,omitempty"`
	RetryAfter      int   `json:"retry_after,omitempty"`
}
  • Step 6: Run tests — PASS

Run: go test ./client/...

  • Step 7: Commit
git add client/
git commit -m "feat(client): Bot constructor, options, and Result[T] envelope"

Task 8 — Client: errors

Files:

  • Create: client/errors.go

  • Create: client/errors_test.go

  • Step 1: Write the failing test

package client

import (
	"errors"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
)

func TestAPIError_FieldsAndMethods(t *testing.T) {
	e := &APIError{
		Code:        429,
		Description: "Too Many Requests: retry after 5",
		Parameters:  &ResponseParameters{RetryAfter: 5},
	}
	require.Equal(t, "telegram: 429 Too Many Requests: retry after 5", e.Error())
	require.True(t, e.IsRetryable())
	require.Equal(t, 5*time.Second, e.RetryAfter())
}

func TestAPIError_Sentinels(t *testing.T) {
	cases := []struct {
		code     int
		desc     string
		sentinel error
	}{
		{401, "Unauthorized", ErrUnauthorized},
		{400, "Bad Request: chat not found", ErrChatNotFound},
		{400, "Bad Request: message is not modified", ErrMessageNotModified},
		{429, "Too Many Requests: retry after 1", ErrTooManyRequests},
	}
	for _, c := range cases {
		t.Run(c.desc, func(t *testing.T) {
			e := mapAPIError(c.code, c.desc, nil)
			require.True(t, errors.Is(e, c.sentinel), "expected %v to wrap %v", e, c.sentinel)
		})
	}
}

func TestAPIError_IsRetryable(t *testing.T) {
	require.True(t, (&APIError{Code: 500}).IsRetryable())
	require.True(t, (&APIError{Code: 502}).IsRetryable())
	require.True(t, (&APIError{Code: 429}).IsRetryable())
	require.False(t, (&APIError{Code: 400}).IsRetryable())
	require.False(t, (&APIError{Code: 401}).IsRetryable())
}

func TestNetworkAndParseErrorWrapping(t *testing.T) {
	inner := errors.New("dial tcp: timeout")
	ne := &NetworkError{Err: inner}
	require.ErrorIs(t, ne, inner)

	pe := &ParseError{Err: errors.New("unexpected EOF"), Body: []byte("garbage")}
	require.Contains(t, pe.Error(), "garbage")
}
  • Step 2: Run test — expect compile failure

  • Step 3: Implement client/errors.go

package client

import (
	"errors"
	"fmt"
	"strings"
	"time"
)

// APIError represents a non-OK Telegram Bot API response.
// It satisfies error and unwraps to a sentinel (ErrUnauthorized, etc.)
// where the description matches a known prefix, enabling errors.Is checks.
type APIError struct {
	Code        int
	Description string
	Parameters  *ResponseParameters

	// sentinel, if non-nil, is the wrapped sentinel error returned by
	// Unwrap. It is set by mapAPIError based on Code+Description.
	sentinel error
}

// Error implements error.
func (e *APIError) Error() string {
	return fmt.Sprintf("telegram: %d %s", e.Code, e.Description)
}

// Unwrap returns the matched sentinel error, if any.
func (e *APIError) Unwrap() error { return e.sentinel }

// IsRetryable returns true for transient HTTP statuses (429, 5xx).
func (e *APIError) IsRetryable() bool {
	return e.Code == 429 || (e.Code >= 500 && e.Code < 600)
}

// RetryAfter returns the recommended back-off duration. It honours the
// Telegram-supplied retry_after parameter; if absent, returns 0.
func (e *APIError) RetryAfter() time.Duration {
	if e.Parameters == nil {
		return 0
	}
	return time.Duration(e.Parameters.RetryAfter) * time.Second
}

// NetworkError wraps a transport-level failure (DNS, TCP, TLS, timeout
// short of an HTTP response).
type NetworkError struct{ Err error }

func (e *NetworkError) Error() string { return "telegram: network: " + e.Err.Error() }
func (e *NetworkError) Unwrap() error { return e.Err }

// ParseError wraps a JSON decode failure on a response body. Body is
// retained (truncated to 4 KiB) for diagnostics.
type ParseError struct {
	Err  error
	Body []byte
}

func (e *ParseError) Error() string {
	body := e.Body
	if len(body) > 256 {
		body = body[:256]
	}
	return fmt.Sprintf("telegram: parse: %s (body=%q)", e.Err, body)
}
func (e *ParseError) Unwrap() error { return e.Err }

// Sentinel errors returned via APIError.Unwrap when the description matches.
// Compare with errors.Is.
var (
	ErrUnauthorized       = errors.New("telegram: unauthorized")
	ErrChatNotFound       = errors.New("telegram: chat not found")
	ErrMessageNotModified = errors.New("telegram: message is not modified")
	ErrTooManyRequests    = errors.New("telegram: too many requests")
	ErrBadRequest         = errors.New("telegram: bad request")
	ErrForbidden          = errors.New("telegram: forbidden")
)

// mapAPIError builds an *APIError and attaches the appropriate sentinel
// based on Code+Description. It is the single point where wire-level
// failures are translated into the Go error taxonomy.
func mapAPIError(code int, description string, params *ResponseParameters) *APIError {
	e := &APIError{Code: code, Description: description, Parameters: params}
	switch {
	case code == 401:
		e.sentinel = ErrUnauthorized
	case code == 403:
		e.sentinel = ErrForbidden
	case code == 429:
		e.sentinel = ErrTooManyRequests
	case code == 400 && strings.Contains(description, "chat not found"):
		e.sentinel = ErrChatNotFound
	case code == 400 && strings.Contains(description, "message is not modified"):
		e.sentinel = ErrMessageNotModified
	case code == 400:
		e.sentinel = ErrBadRequest
	}
	return e
}
  • Step 4: Run test — PASS

Run: go test ./client/...

  • Step 5: Commit
git add client/errors.go client/errors_test.go
git commit -m "feat(client): typed errors with sentinel mapping"

Task 9 — Client: Call helper + multipart builder

Files:

  • Create: client/call.go
  • Create: client/multipart.go
  • Create: client/call_test.go
  • Create: client/multipart_test.go

This task is the heart of the library. Call is generic over request and response types and is the single point through which every API method is invoked. It is exported so the api package (Task 1011) can call it.

  • Step 1: Write the failing test for Call

client/call_test.go:

package client

import (
	"bytes"
	"context"
	"errors"
	"io"
	"net/http"
	"strings"
	"testing"

	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
)

type mockDoer struct{ mock.Mock }

func (m *mockDoer) Do(r *http.Request) (*http.Response, error) {
	args := m.Called(r)
	if v := args.Get(0); v != nil {
		return v.(*http.Response), args.Error(1)
	}
	return nil, args.Error(1)
}

func newResp(status int, body string) *http.Response {
	return &http.Response{
		StatusCode: status,
		Body:       io.NopCloser(bytes.NewBufferString(body)),
		Header:     http.Header{"Content-Type": []string{"application/json"}},
	}
}

type echoReq struct {
	ChatID int64  `json:"chat_id"`
	Text   string `json:"text"`
}
type echoResp struct {
	MessageID int64 `json:"message_id"`
}

func TestCall_Success(t *testing.T) {
	m := &mockDoer{}
	m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
		if !strings.HasSuffix(r.URL.Path, "/bot123:abc/sendEcho") {
			return false
		}
		buf := new(bytes.Buffer)
		_, _ = buf.ReadFrom(r.Body)
		return strings.Contains(buf.String(), `"chat_id":42`)
	})).Return(newResp(200, `{"ok":true,"result":{"message_id":7}}`), nil)

	b := New("123:abc", WithHTTPClient(m))
	out, err := Call[*echoReq, *echoResp](context.Background(), b, "sendEcho", &echoReq{ChatID: 42, Text: "hi"})
	require.NoError(t, err)
	require.Equal(t, int64(7), out.MessageID)
	m.AssertExpectations(t)
}

func TestCall_APIError(t *testing.T) {
	m := &mockDoer{}
	m.On("Do", mock.Anything).Return(
		newResp(200, `{"ok":false,"error_code":429,"description":"Too Many Requests: retry after 3","parameters":{"retry_after":3}}`), nil)

	b := New("t", WithHTTPClient(m))
	_, err := Call[*echoReq, *echoResp](context.Background(), b, "x", &echoReq{})
	require.Error(t, err)
	var ae *APIError
	require.ErrorAs(t, err, &ae)
	require.Equal(t, 429, ae.Code)
	require.True(t, ae.IsRetryable())
	require.True(t, errors.Is(err, ErrTooManyRequests))
}

func TestCall_NetworkError(t *testing.T) {
	m := &mockDoer{}
	m.On("Do", mock.Anything).Return(nil, errors.New("dial timeout"))

	b := New("t", WithHTTPClient(m))
	_, err := Call[*echoReq, *echoResp](context.Background(), b, "x", &echoReq{})
	require.Error(t, err)
	var ne *NetworkError
	require.ErrorAs(t, err, &ne)
}

func TestCall_ParseError(t *testing.T) {
	m := &mockDoer{}
	m.On("Do", mock.Anything).Return(newResp(200, `not json`), nil)

	b := New("t", WithHTTPClient(m))
	_, err := Call[*echoReq, *echoResp](context.Background(), b, "x", &echoReq{})
	require.Error(t, err)
	var pe *ParseError
	require.ErrorAs(t, err, &pe)
}

func TestCall_ContextCanceled(t *testing.T) {
	m := &mockDoer{}
	m.On("Do", mock.Anything).Return(nil, context.Canceled).Maybe()

	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	b := New("t", WithHTTPClient(m))
	_, err := Call[*echoReq, *echoResp](ctx, b, "x", &echoReq{})
	require.ErrorIs(t, err, context.Canceled)
}

func TestCall_NilRequest(t *testing.T) {
	// Methods with no params (e.g. getMe) may pass a nil Req value.
	m := &mockDoer{}
	m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
		buf := new(bytes.Buffer)
		_, _ = buf.ReadFrom(r.Body)
		return buf.String() == "{}"
	})).Return(newResp(200, `{"ok":true,"result":{"message_id":0}}`), nil)

	b := New("t", WithHTTPClient(m))
	_, err := Call[*echoReq, *echoResp](context.Background(), b, "x", nil)
	require.NoError(t, err)
}
  • Step 2: Run test — expect compile failure (Call is not defined yet)

  • Step 3: Implement client/call.go

package client

import (
	"bytes"
	"context"
	"io"
	"net/http"
	"reflect"
)

// Call is the single point through which every Telegram Bot API method
// invocation flows. It marshals the request, signs the URL with the bot
// token, dispatches via HTTPDoer, decodes the Result envelope, and
// translates non-OK responses into typed errors.
//
// It is generic over both request and response types. Methods with no
// parameters may pass a nil Req; the helper sends "{}" in that case so
// Telegram receives a syntactically valid empty object.
//
// Call is exported because the api package (which lives outside this one)
// invokes it from generated method wrappers. User code should not normally
// call it directly — use the typed wrappers in package api instead.
func Call[Req any, Resp any](ctx context.Context, b *Bot, method string, req Req) (Resp, error) {
	var zero Resp

	if mp, ok := any(req).(multipartRequest); ok && mp != nil && mp.HasFile() {
		return callMultipart[Resp](ctx, b, method, mp)
	}

	body, err := encodeJSONBody(b.codec, req)
	if err != nil {
		return zero, err
	}

	url := b.base + "/bot" + b.token + "/" + method
	httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
	if err != nil {
		return zero, &NetworkError{Err: err}
	}
	httpReq.Header.Set("Content-Type", "application/json")
	httpReq.Header.Set("Accept", "application/json")

	resp, err := b.http.Do(httpReq)
	if err != nil {
		// Surface ctx errors faithfully so callers can errors.Is(err, ctx.Err()).
		if ctxErr := ctx.Err(); ctxErr != nil {
			return zero, ctxErr
		}
		return zero, &NetworkError{Err: err}
	}
	defer resp.Body.Close()

	raw, err := io.ReadAll(resp.Body)
	if err != nil {
		return zero, &NetworkError{Err: err}
	}

	return decodeResult[Resp](b.codec, raw)
}

// encodeJSONBody marshals req to a JSON body. A nil interface or nil
// pointer req yields "{}" so Telegram receives a valid empty object.
func encodeJSONBody(codec Codec, req any) (io.Reader, error) {
	if req == nil || isNilPointer(req) {
		return bytes.NewBufferString("{}"), nil
	}
	data, err := codec.Marshal(req)
	if err != nil {
		return nil, &ParseError{Err: err}
	}
	return bytes.NewReader(data), nil
}

// decodeResult unmarshals raw into Result[Resp] and translates non-OK
// responses into *APIError.
func decodeResult[Resp any](codec Codec, raw []byte) (Resp, error) {
	var zero Resp
	var env Result[Resp]
	if err := codec.Unmarshal(raw, &env); err != nil {
		return zero, &ParseError{Err: err, Body: copyBody(raw)}
	}
	if !env.OK {
		return zero, mapAPIError(env.ErrorCode, env.Description, env.Parameters)
	}
	return env.Result, nil
}

// isNilPointer returns true when v is a typed nil pointer (the interface
// itself is non-nil because it carries a type, but the underlying value
// is nil). One reflect call per request; not on a hot path that demands
// allocation-freedom.
func isNilPointer(v any) bool {
	rv := reflect.ValueOf(v)
	return rv.Kind() == reflect.Ptr && rv.IsNil()
}

func copyBody(b []byte) []byte {
	const max = 4096
	if len(b) > max {
		b = b[:max]
	}
	out := make([]byte, len(b))
	copy(out, b)
	return out
}
  • Step 4: Implement client/multipart.go
package client

import (
	"context"
	"io"
	"mime/multipart"
	"net/http"
)

// multipartRequest is implemented by request structs that may carry an
// InputFile. The codegen emits this interface for any method whose IR
// MethodDecl.HasFiles is true.
//
// HasFile returns true if at least one file field is set; if false, the
// request is sent as plain JSON via the regular call path.
//
// MultipartFiles returns one entry per file field that should be uploaded.
// The accompanying scalar/object fields are returned by MultipartFields.
type multipartRequest interface {
	HasFile() bool
	MultipartFiles() []MultipartFile
	MultipartFields() map[string]string
}

// MultipartFile describes a single file part in a multipart upload.
type MultipartFile struct {
	FieldName string
	Filename  string
	Reader    io.Reader
}

// callMultipart performs a multipart/form-data POST. It is invoked by call
// when the request implements multipartRequest and HasFile() is true.
func callMultipart[Resp any](ctx context.Context, b *Bot, method string, mp multipartRequest) (Resp, error) {
	var zero Resp

	pr, pw := io.Pipe()
	mw := multipart.NewWriter(pw)

	// Stream-write the multipart body in a goroutine so we don't buffer
	// large files in memory.
	go func() {
		defer pw.Close()
		defer mw.Close()
		for k, v := range mp.MultipartFields() {
			if err := mw.WriteField(k, v); err != nil {
				_ = pw.CloseWithError(err)
				return
			}
		}
		for _, f := range mp.MultipartFiles() {
			part, err := mw.CreateFormFile(f.FieldName, f.Filename)
			if err != nil {
				_ = pw.CloseWithError(err)
				return
			}
			if _, err := io.Copy(part, f.Reader); err != nil {
				_ = pw.CloseWithError(err)
				return
			}
		}
	}()

	url := b.base + "/bot" + b.token + "/" + method
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, pr)
	if err != nil {
		return zero, &NetworkError{Err: err}
	}
	req.Header.Set("Content-Type", mw.FormDataContentType())
	req.Header.Set("Accept", "application/json")

	resp, err := b.http.Do(req)
	if err != nil {
		if ctxErr := ctx.Err(); ctxErr != nil {
			return zero, ctxErr
		}
		return zero, &NetworkError{Err: err}
	}
	defer resp.Body.Close()

	raw, err := io.ReadAll(resp.Body)
	if err != nil {
		return zero, &NetworkError{Err: err}
	}
	return decodeResult[Resp](b.codec, raw)
}

  • Step 5: Write the multipart test

client/multipart_test.go:

package client

import (
	"context"
	"io"
	"mime"
	"mime/multipart"
	"net/http"
	"strings"
	"testing"

	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
)

type fakeMultipartReq struct {
	chatID int64
	body   string
}

func (f *fakeMultipartReq) HasFile() bool { return true }
func (f *fakeMultipartReq) MultipartFields() map[string]string {
	return map[string]string{"chat_id": "42"}
}
func (f *fakeMultipartReq) MultipartFiles() []MultipartFile {
	return []MultipartFile{{
		FieldName: "document",
		Filename:  "hello.txt",
		Reader:    strings.NewReader(f.body),
	}}
}

type fileResp struct {
	MessageID int64 `json:"message_id"`
}

func TestCallMultipart_Success(t *testing.T) {
	m := &mockDoer{}
	m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
		ct := r.Header.Get("Content-Type")
		if !strings.HasPrefix(ct, "multipart/form-data") {
			return false
		}
		// Parse and verify content
		_, params, err := mime.ParseMediaType(ct)
		if err != nil {
			return false
		}
		mr := multipart.NewReader(r.Body, params["boundary"])
		seenChat := false
		seenFile := false
		for {
			p, err := mr.NextPart()
			if err == io.EOF {
				break
			}
			if err != nil {
				return false
			}
			switch p.FormName() {
			case "chat_id":
				body, _ := io.ReadAll(p)
				seenChat = string(body) == "42"
			case "document":
				body, _ := io.ReadAll(p)
				seenFile = string(body) == "hello world"
			}
		}
		return seenChat && seenFile
	})).Return(newResp(200, `{"ok":true,"result":{"message_id":99}}`), nil)

	b := New("t", WithHTTPClient(m))
	out, err := call[*fakeMultipartReq, *fileResp](context.Background(), b, "sendDocument", &fakeMultipartReq{chatID: 42, body: "hello world"})
	require.NoError(t, err)
	require.Equal(t, int64(99), out.MessageID)
}
  • Step 6: Run all client tests — PASS

Run: go test -race ./client/... Expected: PASS.

  • Step 7: Commit
git add client/
git commit -m "feat(client): add generic call helper, multipart builder, and tests"

Task 10 — api/ hand-coded core types

The convention here is critical: Plan 2 codegen will produce structs in this exact shape. The structs we emit by hand must match what the scraper would emit, so the eventual swap-over is byte-identical (or near-identical) Go.

Files:

  • Create: api/types.go

  • Create: api/types_test.go

  • Step 1: Implement api/types.go

// Package api contains the Telegram Bot API object types and method
// wrappers. In Plan 1 these are hand-coded for a small subset of the API;
// Plan 2 replaces them with code generated from the live documentation.
//
// The hand-coded subset covers what is needed for the echo and webhook
// example bots: Update plumbing, Message + sender objects, command parsing,
// and basic callback queries.
package api

// Update represents an incoming update from Telegram. Exactly one of the
// optional payload fields is populated per Update.
//
// https://core.telegram.org/bots/api#update
type Update struct {
	UpdateID          int64          `json:"update_id"`
	Message           *Message       `json:"message,omitempty"`
	EditedMessage     *Message       `json:"edited_message,omitempty"`
	ChannelPost       *Message       `json:"channel_post,omitempty"`
	EditedChannelPost *Message       `json:"edited_channel_post,omitempty"`
	CallbackQuery     *CallbackQuery `json:"callback_query,omitempty"`
	InlineQuery       *InlineQuery   `json:"inline_query,omitempty"`
}

// UpdateType identifies an Update payload variant. Used by allowed_updates
// in getUpdates / setWebhook.
type UpdateType string

const (
	UpdateMessage           UpdateType = "message"
	UpdateEditedMessage     UpdateType = "edited_message"
	UpdateChannelPost       UpdateType = "channel_post"
	UpdateEditedChannelPost UpdateType = "edited_channel_post"
	UpdateCallbackQuery     UpdateType = "callback_query"
	UpdateInlineQuery       UpdateType = "inline_query"
)

// User represents a Telegram user or bot.
//
// https://core.telegram.org/bots/api#user
type User struct {
	ID           int64  `json:"id"`
	IsBot        bool   `json:"is_bot"`
	FirstName    string `json:"first_name"`
	LastName     string `json:"last_name,omitempty"`
	Username     string `json:"username,omitempty"`
	LanguageCode string `json:"language_code,omitempty"`
}

// ChatType is the type of a Telegram chat.
type ChatType string

const (
	ChatTypePrivate    ChatType = "private"
	ChatTypeGroup      ChatType = "group"
	ChatTypeSupergroup ChatType = "supergroup"
	ChatTypeChannel    ChatType = "channel"
)

// Chat represents a chat.
//
// https://core.telegram.org/bots/api#chat
type Chat struct {
	ID       int64    `json:"id"`
	Type     ChatType `json:"type"`
	Title    string   `json:"title,omitempty"`
	Username string   `json:"username,omitempty"`
	FirstName string  `json:"first_name,omitempty"`
	LastName  string  `json:"last_name,omitempty"`
}

// MessageEntityType is the kind of an entity (mention, hashtag, command, ...).
type MessageEntityType string

const (
	EntityMention      MessageEntityType = "mention"
	EntityHashtag      MessageEntityType = "hashtag"
	EntityCashtag      MessageEntityType = "cashtag"
	EntityBotCommand   MessageEntityType = "bot_command"
	EntityURL          MessageEntityType = "url"
	EntityEmail        MessageEntityType = "email"
	EntityPhoneNumber  MessageEntityType = "phone_number"
	EntityBold         MessageEntityType = "bold"
	EntityItalic       MessageEntityType = "italic"
	EntityUnderline    MessageEntityType = "underline"
	EntityStrike       MessageEntityType = "strikethrough"
	EntitySpoiler      MessageEntityType = "spoiler"
	EntityCode         MessageEntityType = "code"
	EntityPre          MessageEntityType = "pre"
	EntityTextLink     MessageEntityType = "text_link"
	EntityTextMention  MessageEntityType = "text_mention"
	EntityCustomEmoji  MessageEntityType = "custom_emoji"
)

// MessageEntity describes one special entity in a text message.
type MessageEntity struct {
	Type     MessageEntityType `json:"type"`
	Offset   int               `json:"offset"`
	Length   int               `json:"length"`
	URL      string            `json:"url,omitempty"`
	User     *User             `json:"user,omitempty"`
	Language string            `json:"language,omitempty"`
}

// Message represents a message.
//
// https://core.telegram.org/bots/api#message
type Message struct {
	MessageID int64           `json:"message_id"`
	From      *User           `json:"from,omitempty"`
	Date      int64           `json:"date"`
	Chat      Chat            `json:"chat"`
	Text      string          `json:"text,omitempty"`
	Caption   string          `json:"caption,omitempty"`
	Entities  []MessageEntity `json:"entities,omitempty"`
	ReplyToMessage *Message   `json:"reply_to_message,omitempty"`
}

// CallbackQuery represents an incoming callback from an inline keyboard.
//
// https://core.telegram.org/bots/api#callbackquery
type CallbackQuery struct {
	ID              string   `json:"id"`
	From            User     `json:"from"`
	Message         *Message `json:"message,omitempty"`
	InlineMessageID string   `json:"inline_message_id,omitempty"`
	ChatInstance    string   `json:"chat_instance"`
	Data            string   `json:"data,omitempty"`
}

// InlineQuery represents an incoming inline query.
//
// https://core.telegram.org/bots/api#inlinequery
type InlineQuery struct {
	ID     string `json:"id"`
	From   User   `json:"from"`
	Query  string `json:"query"`
	Offset string `json:"offset"`
}

// ResponseParameters duplicates client.ResponseParameters so users
// importing only api can reference it without importing client.
//
// https://core.telegram.org/bots/api#responseparameters
type ResponseParameters struct {
	MigrateToChatID int64 `json:"migrate_to_chat_id,omitempty"`
	RetryAfter      int   `json:"retry_after,omitempty"`
}

// ParseMode controls how Telegram interprets formatting in message text.
type ParseMode string

const (
	ParseModeMarkdown   ParseMode = "Markdown"   // legacy
	ParseModeMarkdownV2 ParseMode = "MarkdownV2"
	ParseModeHTML       ParseMode = "HTML"
)

// InputFile carries either a file path (for upload) or a Telegram file_id
// / URL string (for reuse). When PathOrID names a local file, the request
// is sent as multipart/form-data; otherwise the value is sent inline.
type InputFile struct {
	// PathOrID is one of: an absolute or relative filesystem path, a
	// previously-uploaded Telegram file_id, or an HTTPS URL Telegram
	// can fetch.
	PathOrID string
	// Reader, when non-nil, is used as the file content (Filename names it).
	Reader io.Reader
	// Filename is the upload filename used when Reader is set.
	Filename string
}

// IsLocalUpload reports whether this InputFile triggers a multipart upload.
func (f *InputFile) IsLocalUpload() bool {
	if f == nil {
		return false
	}
	return f.Reader != nil
}

Add import "io" at the top of the file.

  • Step 2: Implement api/types_test.go — round-trip JSON for the types
package api

import (
	"encoding/json"
	"testing"

	"github.com/stretchr/testify/require"
)

func TestUpdate_RoundTrip(t *testing.T) {
	in := Update{
		UpdateID: 1,
		Message: &Message{
			MessageID: 7,
			Date:      1234567890,
			Chat:      Chat{ID: 42, Type: ChatTypePrivate},
			Text:      "/start",
			Entities: []MessageEntity{{
				Type: EntityBotCommand, Offset: 0, Length: 6,
			}},
		},
	}
	data, err := json.Marshal(in)
	require.NoError(t, err)

	var out Update
	require.NoError(t, json.Unmarshal(data, &out))
	require.Equal(t, in, out)
}

func TestMessage_OmitsOptionalFields(t *testing.T) {
	m := Message{MessageID: 1, Date: 2, Chat: Chat{ID: 3, Type: ChatTypePrivate}}
	data, err := json.Marshal(m)
	require.NoError(t, err)
	require.NotContains(t, string(data), "from")
	require.NotContains(t, string(data), "text")
	require.NotContains(t, string(data), "entities")
}

func TestInputFile_IsLocalUpload(t *testing.T) {
	require.False(t, (*InputFile)(nil).IsLocalUpload())
	require.False(t, (&InputFile{PathOrID: "AgADAgADu7gxG..."}).IsLocalUpload())
	require.True(t, (&InputFile{Reader: nopReader{}}).IsLocalUpload())
}

type nopReader struct{}

func (nopReader) Read(p []byte) (int, error) { return 0, nil }
  • Step 3: Run tests — PASS

Run: go test ./api/...

  • Step 4: Commit
git add api/types.go api/types_test.go
git commit -m "feat(api): hand-coded core object types (Update, Message, User, ...)"

Task 11 — api/ hand-coded methods

Files:

  • Create: api/methods.go

  • Create: api/methods_test.go

  • Step 1: Implement api/methods.go — wrappers for getMe, getUpdates, sendMessage, setWebhook, deleteWebhook, answerCallbackQuery, sendDocument (multipart example).

package api

import (
	"context"
	"strconv"

	"github.com/lukaszraczylo/go-telegram/client"
)

// callJSON is the in-package re-export of client.Call so generated code
// (and the hand-coded methods in this file) need not import the unexported
// helper directly.
//
// We re-export here as a thin wrapper rather than exposing client.Call
// publicly because callers should only invoke methods through the
// generated wrappers; the call shape itself is an implementation detail.
type bot = client.Bot

// --- getMe ---------------------------------------------------------------

// GetMeParams is the parameter set for getMe. The method takes no parameters,
// but a typed struct is exposed for symmetry and so users can wire it into
// generic helpers (e.g. middleware that records method+params).
type GetMeParams struct{}

// GetMe returns basic information about the bot in the form of a User.
//
// https://core.telegram.org/bots/api#getme
func GetMe(ctx context.Context, b *bot) (*User, error) {
	return client.Call[*GetMeParams, *User](ctx, b, "getMe", &GetMeParams{})
}

// --- getUpdates ----------------------------------------------------------

// GetUpdatesParams is the parameter set for getUpdates.
//
// https://core.telegram.org/bots/api#getupdates
type GetUpdatesParams struct {
	Offset         int64        `json:"offset,omitempty"`
	Limit          int          `json:"limit,omitempty"`
	Timeout        int          `json:"timeout,omitempty"`
	AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"`
}

// GetUpdates returns an array of incoming updates.
func GetUpdates(ctx context.Context, b *bot, p *GetUpdatesParams) ([]Update, error) {
	return client.Call[*GetUpdatesParams, []Update](ctx, b, "getUpdates", p)
}

// --- sendMessage ---------------------------------------------------------

// SendMessageParams is the parameter set for sendMessage.
//
// https://core.telegram.org/bots/api#sendmessage
type SendMessageParams struct {
	ChatID                int64       `json:"chat_id"`
	Text                  string      `json:"text"`
	ParseMode             ParseMode   `json:"parse_mode,omitempty"`
	Entities              []MessageEntity `json:"entities,omitempty"`
	DisableWebPagePreview *bool       `json:"disable_web_page_preview,omitempty"`
	DisableNotification   *bool       `json:"disable_notification,omitempty"`
	ProtectContent        *bool       `json:"protect_content,omitempty"`
	ReplyToMessageID      int64       `json:"reply_to_message_id,omitempty"`
	AllowSendingWithoutReply *bool    `json:"allow_sending_without_reply,omitempty"`
}

// SendMessage sends a text message and returns the sent Message.
func SendMessage(ctx context.Context, b *bot, p *SendMessageParams) (*Message, error) {
	return client.Call[*SendMessageParams, *Message](ctx, b, "sendMessage", p)
}

// --- setWebhook / deleteWebhook ----------------------------------------

// SetWebhookParams is the parameter set for setWebhook.
//
// https://core.telegram.org/bots/api#setwebhook
type SetWebhookParams struct {
	URL                string       `json:"url"`
	Certificate        *InputFile   `json:"certificate,omitempty"`
	IPAddress          string       `json:"ip_address,omitempty"`
	MaxConnections     int          `json:"max_connections,omitempty"`
	AllowedUpdates     []UpdateType `json:"allowed_updates,omitempty"`
	DropPendingUpdates *bool        `json:"drop_pending_updates,omitempty"`
	SecretToken        string       `json:"secret_token,omitempty"`
}

// SetWebhook configures a webhook URL for incoming updates.
func SetWebhook(ctx context.Context, b *bot, p *SetWebhookParams) (bool, error) {
	return client.Call[*SetWebhookParams, bool](ctx, b, "setWebhook", p)
}

// DeleteWebhookParams is the parameter set for deleteWebhook.
type DeleteWebhookParams struct {
	DropPendingUpdates *bool `json:"drop_pending_updates,omitempty"`
}

// DeleteWebhook removes the webhook configuration.
func DeleteWebhook(ctx context.Context, b *bot, p *DeleteWebhookParams) (bool, error) {
	return client.Call[*DeleteWebhookParams, bool](ctx, b, "deleteWebhook", p)
}

// --- answerCallbackQuery ----------------------------------------------

// AnswerCallbackQueryParams is the parameter set for answerCallbackQuery.
//
// https://core.telegram.org/bots/api#answercallbackquery
type AnswerCallbackQueryParams struct {
	CallbackQueryID string `json:"callback_query_id"`
	Text            string `json:"text,omitempty"`
	ShowAlert       *bool  `json:"show_alert,omitempty"`
	URL             string `json:"url,omitempty"`
	CacheTime       int    `json:"cache_time,omitempty"`
}

// AnswerCallbackQuery acknowledges a CallbackQuery from an inline keyboard.
func AnswerCallbackQuery(ctx context.Context, b *bot, p *AnswerCallbackQueryParams) (bool, error) {
	return client.Call[*AnswerCallbackQueryParams, bool](ctx, b, "answerCallbackQuery", p)
}

// --- sendDocument (multipart sample) -----------------------------------

// SendDocumentParams is the parameter set for sendDocument.
//
// https://core.telegram.org/bots/api#senddocument
type SendDocumentParams struct {
	ChatID    int64      `json:"chat_id"`
	Document  *InputFile `json:"document"`
	Caption   string     `json:"caption,omitempty"`
	ParseMode ParseMode  `json:"parse_mode,omitempty"`
}

// HasFile reports whether a multipart upload is required.
func (p *SendDocumentParams) HasFile() bool { return p.Document.IsLocalUpload() }

// MultipartFields returns the non-file fields used in the multipart body.
func (p *SendDocumentParams) MultipartFields() map[string]string {
	out := map[string]string{
		"chat_id": strconv.FormatInt(p.ChatID, 10),
	}
	if p.Caption != "" {
		out["caption"] = p.Caption
	}
	if p.ParseMode != "" {
		out["parse_mode"] = string(p.ParseMode)
	}
	return out
}

// MultipartFiles returns the file parts.
func (p *SendDocumentParams) MultipartFiles() []client.MultipartFile {
	if !p.HasFile() {
		return nil
	}
	name := p.Document.Filename
	if name == "" {
		name = "file"
	}
	return []client.MultipartFile{{
		FieldName: "document",
		Filename:  name,
		Reader:    p.Document.Reader,
	}}
}

// SendDocument sends a generic document and returns the sent Message.
func SendDocument(ctx context.Context, b *bot, p *SendDocumentParams) (*Message, error) {
	return client.Call[*SendDocumentParams, *Message](ctx, b, "sendDocument", p)
}

// methodNames is a debugging helper exposing the wired method names; useful
// in tests to assert that nothing has been forgotten when extending coverage.
func methodNames() []string {
	return []string{"getMe", "getUpdates", "sendMessage", "setWebhook", "deleteWebhook", "answerCallbackQuery", "sendDocument"}
}
  • Step 2: Implement api/methods_test.go
package api

import (
	"bytes"
	"context"
	"io"
	"net/http"
	"strings"
	"testing"

	"github.com/lukaszraczylo/go-telegram/client"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
)

type mockDoer struct{ mock.Mock }

func (m *mockDoer) Do(r *http.Request) (*http.Response, error) {
	args := m.Called(r)
	if v := args.Get(0); v != nil {
		return v.(*http.Response), args.Error(1)
	}
	return nil, args.Error(1)
}

func newJSONResp(status int, body string) *http.Response {
	return &http.Response{
		StatusCode: status,
		Body:       io.NopCloser(bytes.NewBufferString(body)),
		Header:     http.Header{"Content-Type": []string{"application/json"}},
	}
}

func TestGetMe_Wraps(t *testing.T) {
	m := &mockDoer{}
	m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
		return strings.HasSuffix(r.URL.Path, "/getMe")
	})).Return(newJSONResp(200, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"echo"}}`), nil)

	b := client.New("123:abc", client.WithHTTPClient(m))
	u, err := GetMe(context.Background(), b)
	require.NoError(t, err)
	require.Equal(t, int64(1), u.ID)
}

func TestSendMessage_Wraps(t *testing.T) {
	m := &mockDoer{}
	m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
		buf := new(bytes.Buffer)
		_, _ = buf.ReadFrom(r.Body)
		return strings.HasSuffix(r.URL.Path, "/sendMessage") &&
			strings.Contains(buf.String(), `"chat_id":42`) &&
			strings.Contains(buf.String(), `"text":"hi"`)
	})).Return(newJSONResp(200, `{"ok":true,"result":{"message_id":7,"date":0,"chat":{"id":42,"type":"private"},"text":"hi"}}`), nil)

	b := client.New("t", client.WithHTTPClient(m))
	msg, err := SendMessage(context.Background(), b, &SendMessageParams{ChatID: 42, Text: "hi"})
	require.NoError(t, err)
	require.Equal(t, int64(7), msg.MessageID)
}

func TestGetUpdates_Empty(t *testing.T) {
	m := &mockDoer{}
	m.On("Do", mock.Anything).Return(newJSONResp(200, `{"ok":true,"result":[]}`), nil)

	b := client.New("t", client.WithHTTPClient(m))
	ups, err := GetUpdates(context.Background(), b, &GetUpdatesParams{Limit: 10, Timeout: 0})
	require.NoError(t, err)
	require.Empty(t, ups)
}

func TestMethodNames_Coverage(t *testing.T) {
	require.ElementsMatch(t,
		[]string{"getMe", "getUpdates", "sendMessage", "setWebhook", "deleteWebhook", "answerCallbackQuery", "sendDocument"},
		methodNames(),
	)
}
  • Step 3: Run tests — PASS

Run: go test -race ./...

  • Step 4: Commit
git add api/ client/
git commit -m "feat(api): hand-coded method wrappers (getMe, sendMessage, getUpdates, ...)"

Task 12 — Transport: Updater interface

Files:

  • Create: transport/updater.go

  • Step 1: Implement

// Package transport provides update delivery mechanisms (long-poll and
// webhook) that feed updates into the dispatch package's Router.
//
// All implementations satisfy the Updater interface so user code can
// swap one for the other without touching handler logic.
package transport

import (
	"context"

	"github.com/lukaszraczylo/go-telegram/api"
)

// Updater is the abstraction over update sources. Implementations must:
//   - return a channel from Updates() that receives every Update they read.
//   - close the channel after Run returns.
//   - honour ctx cancellation in Run.
type Updater interface {
	// Updates returns the channel updates flow into. Multiple readers
	// is implementation-defined; users should treat it as single-reader.
	Updates() <-chan api.Update
	// Run blocks until ctx is cancelled or a fatal error occurs. It is
	// the user's responsibility to call Run in a goroutine if needed.
	Run(ctx context.Context) error
	// Stop signals Run to exit and waits for the channel to drain.
	// Implementations must be idempotent.
	Stop(ctx context.Context) error
}
  • Step 2: Commit
git add transport/updater.go
git commit -m "feat(transport): define Updater interface"

Task 13 — Transport: LongPoller

Files:

  • Create: transport/longpoll.go

  • Create: transport/longpoll_test.go

  • Create: transport/backoff.go

  • Step 1: Implement transport/backoff.go

package transport

import (
	"math"
	"math/rand/v2"
	"time"
)

// BackoffStrategy returns the duration to wait before the next attempt
// after `attempt` consecutive failures (1-based). Implementations must
// be safe to call from a single goroutine.
type BackoffStrategy interface {
	NextDelay(attempt int) time.Duration
}

// ExponentialBackoff implements capped exponential back-off with jitter.
// Defaults: Base=500ms, Max=30s, Factor=2.0, Jitter=0.2.
type ExponentialBackoff struct {
	Base   time.Duration
	Max    time.Duration
	Factor float64
	Jitter float64 // 0..1; fraction of computed delay added/subtracted at random
}

// DefaultBackoff returns an ExponentialBackoff with library defaults.
func DefaultBackoff() *ExponentialBackoff {
	return &ExponentialBackoff{
		Base:   500 * time.Millisecond,
		Max:    30 * time.Second,
		Factor: 2.0,
		Jitter: 0.2,
	}
}

// NextDelay implements BackoffStrategy.
func (b *ExponentialBackoff) NextDelay(attempt int) time.Duration {
	if attempt < 1 {
		attempt = 1
	}
	d := float64(b.Base) * math.Pow(b.Factor, float64(attempt-1))
	if d > float64(b.Max) {
		d = float64(b.Max)
	}
	if b.Jitter > 0 {
		d *= 1 + (rand.Float64()*2-1)*b.Jitter
	}
	if d < 0 {
		d = 0
	}
	return time.Duration(d)
}
  • Step 2: Implement transport/longpoll.go
package transport

import (
	"context"
	"errors"
	"sync"
	"time"

	"github.com/lukaszraczylo/go-telegram/api"
	"github.com/lukaszraczylo/go-telegram/client"
)

// LongPoller pulls updates via Bot.GetUpdates in a loop, advancing the
// offset cursor after each batch. It applies BackoffStrategy on transient
// errors (network failures, 5xx, 429).
type LongPoller struct {
	Bot          *client.Bot
	Timeout      int            // seconds, default 30
	Limit        int            // 1..100, default 100
	AllowedTypes []api.UpdateType
	Backoff      BackoffStrategy

	out  chan api.Update
	once sync.Once
	stop chan struct{}
}

// NewLongPoller constructs a LongPoller with sensible defaults.
func NewLongPoller(b *client.Bot) *LongPoller {
	return &LongPoller{
		Bot:     b,
		Timeout: 30,
		Limit:   100,
		Backoff: DefaultBackoff(),
		out:     make(chan api.Update, 64),
		stop:    make(chan struct{}),
	}
}

// Updates implements Updater.
func (p *LongPoller) Updates() <-chan api.Update { return p.out }

// Run implements Updater. It blocks until ctx is cancelled, Stop is
// called, or a fatal error occurs (e.g. unauthorized).
func (p *LongPoller) Run(ctx context.Context) error {
	defer close(p.out)

	var offset int64
	failures := 0
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-p.stop:
			return nil
		default:
		}

		ups, err := api.GetUpdates(ctx, p.Bot, &api.GetUpdatesParams{
			Offset:         offset,
			Limit:          p.Limit,
			Timeout:        p.Timeout,
			AllowedUpdates: p.AllowedTypes,
		})
		if err != nil {
			if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
				return err
			}
			// Fatal: unauthorized -> bail.
			if errors.Is(err, client.ErrUnauthorized) {
				return err
			}
			failures++
			delay := p.Backoff.NextDelay(failures)
			select {
			case <-time.After(delay):
				continue
			case <-ctx.Done():
				return ctx.Err()
			case <-p.stop:
				return nil
			}
		}
		failures = 0

		for _, u := range ups {
			select {
			case p.out <- u:
				if u.UpdateID >= offset {
					offset = u.UpdateID + 1
				}
			case <-ctx.Done():
				return ctx.Err()
			case <-p.stop:
				return nil
			}
		}
	}
}

// Stop implements Updater.
func (p *LongPoller) Stop(ctx context.Context) error {
	p.once.Do(func() { close(p.stop) })
	return nil
}
  • Step 3: Write transport/longpoll_test.go
package transport

import (
	"bytes"
	"context"
	"io"
	"net/http"
	"sync/atomic"
	"testing"
	"time"

	"github.com/lukaszraczylo/go-telegram/api"
	"github.com/lukaszraczylo/go-telegram/client"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
)

type mockDoer struct{ mock.Mock }

func (m *mockDoer) Do(r *http.Request) (*http.Response, error) {
	args := m.Called(r)
	if v := args.Get(0); v != nil {
		return v.(*http.Response), args.Error(1)
	}
	return nil, args.Error(1)
}

func resp(body string) *http.Response {
	return &http.Response{
		StatusCode: 200,
		Body:       io.NopCloser(bytes.NewBufferString(body)),
		Header:     http.Header{"Content-Type": []string{"application/json"}},
	}
}

func TestLongPoller_DeliversUpdatesAndAdvancesOffset(t *testing.T) {
	m := &mockDoer{}
	var calls atomic.Int32
	m.On("Do", mock.Anything).Return(func(r *http.Request) *http.Response {
		switch calls.Add(1) {
		case 1:
			return resp(`{"ok":true,"result":[{"update_id":10,"message":{"message_id":1,"date":0,"chat":{"id":1,"type":"private"},"text":"hi"}}]}`)
		case 2:
			return resp(`{"ok":true,"result":[{"update_id":11,"message":{"message_id":2,"date":0,"chat":{"id":1,"type":"private"},"text":"there"}}]}`)
		default:
			return resp(`{"ok":true,"result":[]}`)
		}
	}, nil)

	b := client.New("t", client.WithHTTPClient(m))
	p := NewLongPoller(b)
	p.Timeout = 0

	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()

	go func() { _ = p.Run(ctx) }()

	u1 := <-p.Updates()
	require.Equal(t, int64(10), u1.UpdateID)
	u2 := <-p.Updates()
	require.Equal(t, int64(11), u2.UpdateID)
}

func TestLongPoller_BackoffOnNetworkError(t *testing.T) {
	m := &mockDoer{}
	var attempts atomic.Int32
	m.On("Do", mock.Anything).Return(func(r *http.Request) *http.Response {
		attempts.Add(1)
		return nil
	}, error(io.ErrUnexpectedEOF)).Maybe()

	b := client.New("t", client.WithHTTPClient(m))
	p := NewLongPoller(b)
	p.Timeout = 0
	p.Backoff = &ExponentialBackoff{Base: 5 * time.Millisecond, Max: 5 * time.Millisecond}

	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()

	_ = p.Run(ctx)
	require.GreaterOrEqual(t, attempts.Load(), int32(2), "should retry at least once")
}

func TestLongPoller_StopCloses(t *testing.T) {
	m := &mockDoer{}
	m.On("Do", mock.Anything).Return(resp(`{"ok":true,"result":[]}`), nil).Maybe()

	b := client.New("t", client.WithHTTPClient(m))
	p := NewLongPoller(b)
	p.Timeout = 0

	ctx := context.Background()
	done := make(chan struct{})
	go func() { _ = p.Run(ctx); close(done) }()

	require.NoError(t, p.Stop(ctx))
	select {
	case <-done:
	case <-time.After(time.Second):
		t.Fatal("Run did not exit after Stop")
	}

	// Channel must be closed.
	_, ok := <-p.Updates()
	require.False(t, ok, "expected closed channel after Stop")
}
  • Step 4: Run tests — PASS

Run: go test -race ./transport/...

  • Step 5: Commit
git add transport/
git commit -m "feat(transport): LongPoller with exponential backoff"

Task 14 — Transport: WebhookServer

Files:

  • Create: transport/webhook.go

  • Create: transport/webhook_test.go

  • Step 1: Implement transport/webhook.go

package transport

import (
	"context"
	"crypto/subtle"
	"errors"
	"net"
	"net/http"
	"sync"

	"github.com/lukaszraczylo/go-telegram/api"
	"github.com/lukaszraczylo/go-telegram/client"
)

// WebhookServer implements Updater by exposing an http.Handler that
// receives updates from Telegram. It can be mounted on the user's own
// HTTP server (via ServeHTTP) or run standalone (via ListenAndServe).
type WebhookServer struct {
	Bot         *client.Bot
	SecretToken string // verify X-Telegram-Bot-Api-Secret-Token; empty disables
	BufferSize  int    // updates channel buffer; default 64

	out  chan api.Update
	once sync.Once
	stop chan struct{}

	srv *http.Server
}

// NewWebhookServer constructs a WebhookServer with default buffer size.
func NewWebhookServer(b *client.Bot) *WebhookServer {
	return &WebhookServer{
		Bot:        b,
		BufferSize: 64,
		out:        make(chan api.Update, 64),
		stop:       make(chan struct{}),
	}
}

// Updates implements Updater.
func (w *WebhookServer) Updates() <-chan api.Update { return w.out }

// Run implements Updater. It blocks until Stop is called or ctx is
// cancelled. If the server has not been started via ListenAndServe, Run
// only watches for shutdown — the user is expected to mount ServeHTTP
// on their own router.
func (w *WebhookServer) Run(ctx context.Context) error {
	defer close(w.out)
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-w.stop:
		return nil
	}
}

// Stop implements Updater.
func (w *WebhookServer) Stop(ctx context.Context) error {
	w.once.Do(func() { close(w.stop) })
	if w.srv != nil {
		return w.srv.Shutdown(ctx)
	}
	return nil
}

// ServeHTTP implements http.Handler. Telegram POSTs each update as JSON
// to this endpoint. Non-POST requests get 405; bad bodies get 400; secret
// token mismatches get 401.
func (w *WebhookServer) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		rw.WriteHeader(http.StatusMethodNotAllowed)
		return
	}
	if w.SecretToken != "" {
		got := r.Header.Get("X-Telegram-Bot-Api-Secret-Token")
		if subtle.ConstantTimeCompare([]byte(got), []byte(w.SecretToken)) != 1 {
			rw.WriteHeader(http.StatusUnauthorized)
			return
		}
	}
	defer r.Body.Close()

	var u api.Update
	codec := w.Bot.Codec()
	const max = 1 << 20 // 1 MiB cap on body
	buf := make([]byte, 0, 1024)
	tmp := make([]byte, 4096)
	for {
		n, err := r.Body.Read(tmp)
		if n > 0 {
			buf = append(buf, tmp[:n]...)
			if len(buf) > max {
				rw.WriteHeader(http.StatusRequestEntityTooLarge)
				return
			}
		}
		if errors.Is(err, http.ErrBodyReadAfterClose) || err != nil {
			break
		}
	}
	if err := codec.Unmarshal(buf, &u); err != nil {
		rw.WriteHeader(http.StatusBadRequest)
		return
	}

	select {
	case w.out <- u:
	case <-w.stop:
	}

	rw.WriteHeader(http.StatusOK)
}

// ListenAndServe starts an HTTP server on addr and blocks until ctx is
// cancelled, Stop is called, or the server returns an error other than
// http.ErrServerClosed.
func (w *WebhookServer) ListenAndServe(ctx context.Context, addr string) error {
	mux := http.NewServeMux()
	mux.Handle("/", w)
	w.srv = &http.Server{
		Addr:    addr,
		Handler: mux,
		BaseContext: func(net.Listener) context.Context { return ctx },
	}
	go func() {
		<-ctx.Done()
		_ = w.srv.Shutdown(context.Background())
	}()
	err := w.srv.ListenAndServe()
	if errors.Is(err, http.ErrServerClosed) {
		return nil
	}
	return err
}
  • Step 2: Write transport/webhook_test.go
package transport

import (
	"bytes"
	"context"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/lukaszraczylo/go-telegram/client"
	"github.com/stretchr/testify/require"
)

func TestWebhook_DeliversUpdate(t *testing.T) {
	b := client.New("t")
	w := NewWebhookServer(b)
	w.SecretToken = "secret"

	srv := httptest.NewServer(w)
	t.Cleanup(srv.Close)

	body := `{"update_id":1,"message":{"message_id":1,"date":0,"chat":{"id":1,"type":"private"},"text":"hi"}}`
	req, _ := http.NewRequest(http.MethodPost, srv.URL, strings.NewReader(body))
	req.Header.Set("X-Telegram-Bot-Api-Secret-Token", "secret")
	resp, err := http.DefaultClient.Do(req)
	require.NoError(t, err)
	resp.Body.Close()
	require.Equal(t, http.StatusOK, resp.StatusCode)

	select {
	case u := <-w.Updates():
		require.Equal(t, int64(1), u.UpdateID)
	case <-time.After(time.Second):
		t.Fatal("update not delivered")
	}
}

func TestWebhook_RejectsBadSecret(t *testing.T) {
	b := client.New("t")
	w := NewWebhookServer(b)
	w.SecretToken = "secret"

	srv := httptest.NewServer(w)
	t.Cleanup(srv.Close)

	req, _ := http.NewRequest(http.MethodPost, srv.URL, strings.NewReader(`{}`))
	req.Header.Set("X-Telegram-Bot-Api-Secret-Token", "wrong")
	resp, err := http.DefaultClient.Do(req)
	require.NoError(t, err)
	resp.Body.Close()
	require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}

func TestWebhook_RejectsNonPOST(t *testing.T) {
	w := NewWebhookServer(client.New("t"))
	srv := httptest.NewServer(w)
	t.Cleanup(srv.Close)

	resp, err := http.Get(srv.URL)
	require.NoError(t, err)
	resp.Body.Close()
	require.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
}

func TestWebhook_RejectsBadJSON(t *testing.T) {
	w := NewWebhookServer(client.New("t"))
	srv := httptest.NewServer(w)
	t.Cleanup(srv.Close)

	resp, err := http.Post(srv.URL, "application/json", bytes.NewBufferString("not json"))
	require.NoError(t, err)
	resp.Body.Close()
	require.Equal(t, http.StatusBadRequest, resp.StatusCode)
}

func TestWebhook_StopExitsRun(t *testing.T) {
	w := NewWebhookServer(client.New("t"))

	done := make(chan struct{})
	go func() { _ = w.Run(context.Background()); close(done) }()

	require.NoError(t, w.Stop(context.Background()))
	select {
	case <-done:
	case <-time.After(time.Second):
		t.Fatal("Run did not exit after Stop")
	}
}
  • Step 3: Run tests — PASS

Run: go test -race ./transport/...

  • Step 4: Commit
git add transport/webhook.go transport/webhook_test.go
git commit -m "feat(transport): WebhookServer with secret-token verification"

Task 15 — Dispatcher: Context, Handler, Middleware

Files:

  • Create: dispatch/context.go

  • Create: dispatch/handler.go

  • Step 1: Implement dispatch/context.go

// Package dispatch provides a typed router for Telegram updates. It
// consumes any transport.Updater and dispatches updates to handlers
// registered by command, regex, or update-payload kind.
package dispatch

import (
	"context"

	"github.com/lukaszraczylo/go-telegram/api"
	"github.com/lukaszraczylo/go-telegram/client"
)

// Context bundles the per-update state every handler receives.
//
// Ctx is the request context propagated from Router.Run; cancelling the
// run cancels every handler.
//
// Bot is the API client. Handlers reply by calling api.SendMessage(c.Ctx,
// c.Bot, ...) etc.
//
// Update is the raw update; payload-typed handlers also receive a
// narrowed pointer to one of its sub-fields.
//
// Values is a per-update bag matchers populate. Conventional keys:
//   "command":      string, the matched bot command (e.g. "/start")
//   "command_args": string, everything after the command
//   "regex_match":  []string, regex sub-matches when OnText matches
type Context struct {
	Ctx    context.Context
	Bot    *client.Bot
	Update *api.Update
	Values map[string]any
}

// NewContext constructs a Context. Used by Router internally; exposed for
// custom test harnesses.
func NewContext(ctx context.Context, b *client.Bot, u *api.Update) *Context {
	return &Context{Ctx: ctx, Bot: b, Update: u, Values: map[string]any{}}
}
  • Step 2: Implement dispatch/handler.go
package dispatch

// Handler is a generic handler over update payload type T. T is typically
// *api.Message, *api.CallbackQuery, *api.InlineQuery, or *api.Update for
// global middleware.
type Handler[T any] func(ctx *Context, payload T) error

// Middleware wraps a Handler[T] with cross-cutting behaviour (logging,
// recovery, auth). Middleware composition is left-to-right: Use(a,b,c)
// runs as a(b(c(handler))).
type Middleware[T any] func(Handler[T]) Handler[T]

// Chain composes a slice of middleware into a single Middleware[T].
func Chain[T any](mws ...Middleware[T]) Middleware[T] {
	return func(h Handler[T]) Handler[T] {
		for i := len(mws) - 1; i >= 0; i-- {
			h = mws[i](h)
		}
		return h
	}
}
  • Step 3: Commit
git add dispatch/context.go dispatch/handler.go
git commit -m "feat(dispatch): generic Handler[T] + Middleware[T] + Context"

Task 16 — Dispatcher: Router with command/text/callback/inline matchers

Files:

  • Create: dispatch/router.go

  • Create: dispatch/middleware.go (panic recovery)

  • Create: dispatch/router_test.go

  • Step 1: Implement dispatch/middleware.go

package dispatch

import (
	"fmt"
	"runtime/debug"

	"github.com/lukaszraczylo/go-telegram/api"
)

// Recovery returns middleware that recovers from panics in downstream
// handlers, converting them into a returned error and logging via the
// bot's configured logger. Registered automatically by NewRouter.
func Recovery() Middleware[*api.Update] {
	return func(next Handler[*api.Update]) Handler[*api.Update] {
		return func(c *Context, u *api.Update) (err error) {
			defer func() {
				if r := recover(); r != nil {
					err = fmt.Errorf("panic in handler: %v\n%s", r, debug.Stack())
					if c.Bot != nil {
						c.Bot.Logger().Error("dispatch recovered panic", "err", err)
					}
				}
			}()
			return next(c, u)
		}
	}
}
  • Step 2: Implement dispatch/router.go
package dispatch

import (
	"context"
	"regexp"
	"strings"

	"github.com/lukaszraczylo/go-telegram/api"
	"github.com/lukaszraczylo/go-telegram/client"
	"github.com/lukaszraczylo/go-telegram/transport"
)

// Router dispatches updates from any Updater to typed handlers.
//
// Matchers run in registration order; first match wins. A panic-recovery
// middleware is attached automatically and runs around every dispatch.
type Router struct {
	bot *client.Bot

	commands  []commandRoute
	texts     []textRoute
	callbacks []callbackRoute
	inlines   []Handler[*api.InlineQuery]
	editedMsg []Handler[*api.Message]

	globalMW []Middleware[*api.Update]
}

type commandRoute struct {
	cmd     string
	handler Handler[*api.Message]
}

type textRoute struct {
	re      *regexp.Regexp
	handler Handler[*api.Message]
}

type callbackRoute struct {
	re      *regexp.Regexp
	handler Handler[*api.CallbackQuery]
}

// New constructs a Router. Recovery middleware is added by default; users
// can disable it by passing WithoutRecovery (not implemented here, but
// the hook is in place via Use).
func New(b *client.Bot) *Router {
	r := &Router{bot: b}
	r.Use(Recovery())
	return r
}

// Use registers a global middleware applied to every Update dispatch.
func (r *Router) Use(mw Middleware[*api.Update]) { r.globalMW = append(r.globalMW, mw) }

// OnCommand registers a handler for a slash command. The command string
// includes the leading slash (e.g. "/start"). Matching strips an optional
// "@BotName" suffix.
func (r *Router) OnCommand(cmd string, h Handler[*api.Message]) {
	r.commands = append(r.commands, commandRoute{cmd: cmd, handler: h})
}

// OnText registers a handler for messages whose Text matches the regex.
func (r *Router) OnText(pattern string, h Handler[*api.Message]) {
	r.texts = append(r.texts, textRoute{re: regexp.MustCompile(pattern), handler: h})
}

// OnCallback registers a handler for callback queries whose Data matches
// the regex.
func (r *Router) OnCallback(pattern string, h Handler[*api.CallbackQuery]) {
	r.callbacks = append(r.callbacks, callbackRoute{re: regexp.MustCompile(pattern), handler: h})
}

// OnInlineQuery registers a handler for inline queries (one matcher only;
// inline queries are not partitioned by content here).
func (r *Router) OnInlineQuery(h Handler[*api.InlineQuery]) {
	r.inlines = append(r.inlines, h)
}

// OnEditedMessage registers a handler for edited message updates.
func (r *Router) OnEditedMessage(h Handler[*api.Message]) {
	r.editedMsg = append(r.editedMsg, h)
}

// Run consumes the Updater and dispatches each update. It blocks until
// the Updater's channel is closed (i.e. the underlying Run returned).
func (r *Router) Run(ctx context.Context, u transport.Updater) error {
	go func() { _ = u.Run(ctx) }()

	root := r.dispatch
	for i := len(r.globalMW) - 1; i >= 0; i-- {
		root = r.globalMW[i](root)
	}

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case up, ok := <-u.Updates():
			if !ok {
				return nil
			}
			c := NewContext(ctx, r.bot, &up)
			_ = root(c, &up)
		}
	}
}

func (r *Router) dispatch(c *Context, u *api.Update) error {
	switch {
	case u.Message != nil:
		return r.handleMessage(c, u.Message)
	case u.EditedMessage != nil:
		for _, h := range r.editedMsg {
			if err := h(c, u.EditedMessage); err != nil {
				return err
			}
			break
		}
	case u.CallbackQuery != nil:
		return r.handleCallback(c, u.CallbackQuery)
	case u.InlineQuery != nil:
		for _, h := range r.inlines {
			if err := h(c, u.InlineQuery); err != nil {
				return err
			}
			break
		}
	}
	return nil
}

func (r *Router) handleMessage(c *Context, m *api.Message) error {
	// Try command first (entity-aware).
	if cmd, args, ok := extractCommand(m); ok {
		c.Values["command"] = cmd
		c.Values["command_args"] = args
		for _, route := range r.commands {
			if route.cmd == cmd {
				return route.handler(c, m)
			}
		}
	}
	// Then text regex matchers.
	if m.Text != "" {
		for _, route := range r.texts {
			if subs := route.re.FindStringSubmatch(m.Text); subs != nil {
				c.Values["regex_match"] = subs
				return route.handler(c, m)
			}
		}
	}
	return nil
}

func (r *Router) handleCallback(c *Context, q *api.CallbackQuery) error {
	for _, route := range r.callbacks {
		if subs := route.re.FindStringSubmatch(q.Data); subs != nil {
			c.Values["regex_match"] = subs
			return route.handler(c, q)
		}
	}
	return nil
}

// extractCommand returns the command (e.g. "/start") and the remaining
// argument string, when m carries a leading bot_command entity. It strips
// optional "@BotName" suffix on the command itself.
func extractCommand(m *api.Message) (cmd, args string, ok bool) {
	if len(m.Entities) == 0 || m.Text == "" {
		return "", "", false
	}
	first := m.Entities[0]
	if first.Type != api.EntityBotCommand || first.Offset != 0 {
		return "", "", false
	}
	end := first.Offset + first.Length
	cmd = m.Text[first.Offset:end]
	if i := strings.Index(cmd, "@"); i >= 0 {
		cmd = cmd[:i]
	}
	args = strings.TrimSpace(m.Text[end:])
	return cmd, args, true
}
  • Step 3: Implement dispatch/router_test.go
package dispatch

import (
	"context"
	"testing"
	"time"

	"github.com/lukaszraczylo/go-telegram/api"
	"github.com/lukaszraczylo/go-telegram/client"
	"github.com/stretchr/testify/require"
)

// fakeUpdater feeds a fixed slice of updates then closes.
type fakeUpdater struct{ ch chan api.Update }

func newFake(ups ...api.Update) *fakeUpdater {
	ch := make(chan api.Update, len(ups))
	for _, u := range ups {
		ch <- u
	}
	close(ch)
	return &fakeUpdater{ch: ch}
}

func (f *fakeUpdater) Updates() <-chan api.Update           { return f.ch }
func (f *fakeUpdater) Run(ctx context.Context) error        { <-ctx.Done(); return ctx.Err() }
func (f *fakeUpdater) Stop(ctx context.Context) error       { return nil }

func cmdMessage(text string) api.Update {
	return api.Update{
		UpdateID: 1,
		Message: &api.Message{
			MessageID: 1, Date: 0, Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
			Text:     text,
			Entities: []api.MessageEntity{{Type: api.EntityBotCommand, Offset: 0, Length: indexEnd(text)}},
		},
	}
}

func indexEnd(text string) int {
	for i, r := range text {
		if r == ' ' {
			return i
		}
	}
	return len(text)
}

func TestRouter_OnCommandMatches(t *testing.T) {
	b := client.New("t")
	r := New(b)
	hit := make(chan string, 1)
	r.OnCommand("/start", func(c *Context, m *api.Message) error {
		hit <- c.Values["command"].(string)
		return nil
	})

	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
	defer cancel()
	go func() { _ = r.Run(ctx, newFake(cmdMessage("/start"))) }()

	require.Equal(t, "/start", <-hit)
}

func TestRouter_OnCommandStripsBotName(t *testing.T) {
	r := New(client.New("t"))
	hit := make(chan string, 1)
	r.OnCommand("/start", func(c *Context, m *api.Message) error {
		hit <- "matched"
		return nil
	})

	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
	defer cancel()
	go func() { _ = r.Run(ctx, newFake(cmdMessage("/start@MyBot hello"))) }()

	require.Equal(t, "matched", <-hit)
}

func TestRouter_OnText(t *testing.T) {
	r := New(client.New("t"))
	hit := make(chan []string, 1)
	r.OnText(`^hello (\w+)$`, func(c *Context, m *api.Message) error {
		hit <- c.Values["regex_match"].([]string)
		return nil
	})

	u := api.Update{UpdateID: 1, Message: &api.Message{
		MessageID: 1, Chat: api.Chat{ID: 1, Type: "private"}, Text: "hello world",
	}}
	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
	defer cancel()
	go func() { _ = r.Run(ctx, newFake(u)) }()

	subs := <-hit
	require.Equal(t, "world", subs[1])
}

func TestRouter_OnCallback(t *testing.T) {
	r := New(client.New("t"))
	hit := make(chan string, 1)
	r.OnCallback(`^like:(\d+)$`, func(c *Context, q *api.CallbackQuery) error {
		hit <- q.Data
		return nil
	})

	u := api.Update{UpdateID: 1, CallbackQuery: &api.CallbackQuery{
		ID: "x", From: api.User{ID: 1}, ChatInstance: "y", Data: "like:42",
	}}
	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
	defer cancel()
	go func() { _ = r.Run(ctx, newFake(u)) }()

	require.Equal(t, "like:42", <-hit)
}

func TestRouter_NoMatch(t *testing.T) {
	r := New(client.New("t"))
	called := false
	r.OnCommand("/start", func(c *Context, m *api.Message) error {
		called = true
		return nil
	})
	u := api.Update{UpdateID: 1, Message: &api.Message{Text: "no command"}}

	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()
	_ = r.Run(ctx, newFake(u))
	require.False(t, called)
}

func TestRouter_PanicRecovery(t *testing.T) {
	r := New(client.New("t"))
	r.OnCommand("/boom", func(c *Context, m *api.Message) error {
		panic("kaboom")
	})

	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
	defer cancel()
	// Should not propagate panic to Run.
	require.NotPanics(t, func() { _ = r.Run(ctx, newFake(cmdMessage("/boom"))) })
}

func TestRouter_MiddlewareOrder(t *testing.T) {
	r := New(client.New("t"))
	var order []string
	r.Use(func(next Handler[*api.Update]) Handler[*api.Update] {
		return func(c *Context, u *api.Update) error {
			order = append(order, "before-1")
			err := next(c, u)
			order = append(order, "after-1")
			return err
		}
	})
	r.Use(func(next Handler[*api.Update]) Handler[*api.Update] {
		return func(c *Context, u *api.Update) error {
			order = append(order, "before-2")
			err := next(c, u)
			order = append(order, "after-2")
			return err
		}
	})
	r.OnCommand("/x", func(c *Context, m *api.Message) error {
		order = append(order, "handler")
		return nil
	})

	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
	defer cancel()
	_ = r.Run(ctx, newFake(cmdMessage("/x")))

	require.Equal(t,
		[]string{"before-1", "before-2", "handler", "after-2", "after-1"},
		order)
}
  • Step 4: Run tests — PASS

Run: go test -race ./dispatch/...

  • Step 5: Commit
git add dispatch/
git commit -m "feat(dispatch): Router with command/text/callback/inline matchers"

Task 17 — Echo example

Files:

  • Create: examples/echo/main.go

  • Create: examples/echo/README.md

  • Step 1: Implement examples/echo/main.go

// Package main is a long-poll echo bot. Run with:
//
//	TELEGRAM_BOT_TOKEN=xxx go run ./examples/echo
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)
	me, err := api.GetMe(ctx, bot)
	if err != nil {
		log.Fatalf("getMe: %v", err)
	}
	log.Printf("running as @%s", me.Username)

	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: m.Chat.ID,
			Text:   fmt.Sprintf("hello %s, send me anything to echo", 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: m.Chat.ID,
			Text:   m.Text,
			ReplyToMessageID: m.MessageID,
		})
		return err
	})

	poller := transport.NewLongPoller(bot)
	if err := router.Run(ctx, poller); err != nil && err != context.Canceled {
		log.Printf("router exited: %v", err)
	}
}
  • Step 2: Add example README

examples/echo/README.md:

# echo

Long-poll echo bot. Replies to `/start` with a greeting and echoes any other text.

## Run

```bash
export TELEGRAM_BOT_TOKEN=123456:ABC...
go run ./examples/echo

- [ ] **Step 3: Verify build**

Run: `go build ./examples/echo/...`
Expected: success.

- [ ] **Step 4: Commit**

```bash
git add examples/echo/
git commit -m "docs(examples): add long-poll echo bot"

Task 18 — Webhook example

Files:

  • Create: examples/webhook/main.go

  • Create: examples/webhook/README.md

  • Step 1: Implement examples/webhook/main.go

// Package main is a webhook bot. Run with:
//
//	TELEGRAM_BOT_TOKEN=xxx \
//	WEBHOOK_URL=https://example.com/bot \
//	WEBHOOK_SECRET=somethingrandom \
//	go run ./examples/webhook
//
// The bot sets its webhook to WEBHOOK_URL on startup, listens on :8080,
// and clears the webhook on shutdown.
package main

import (
	"context"
	"log"
	"net/http"
	"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")
	url := os.Getenv("WEBHOOK_URL")
	secret := os.Getenv("WEBHOOK_SECRET")
	if token == "" || url == "" {
		log.Fatal("TELEGRAM_BOT_TOKEN and WEBHOOK_URL required")
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	bot := client.New(token)

	if _, err := api.SetWebhook(ctx, bot, &api.SetWebhookParams{
		URL:         url,
		SecretToken: secret,
	}); err != nil {
		log.Fatalf("setWebhook: %v", err)
	}
	defer func() {
		_, _ = api.DeleteWebhook(context.Background(), bot, &api.DeleteWebhookParams{})
	}()

	wh := transport.NewWebhookServer(bot)
	wh.SecretToken = secret

	router := dispatch.New(bot)
	router.OnCommand("/ping", func(c *dispatch.Context, m *api.Message) error {
		_, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
			ChatID: m.Chat.ID,
			Text:   "pong",
		})
		return err
	})

	mux := http.NewServeMux()
	mux.Handle("/bot", wh)
	srv := &http.Server{Addr: ":8080", Handler: mux}
	go func() {
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Printf("http server exited: %v", err)
			stop()
		}
	}()

	if err := router.Run(ctx, wh); err != nil && err != context.Canceled {
		log.Printf("router exited: %v", err)
	}
	_ = srv.Shutdown(context.Background())
}
  • Step 2: Add example README

examples/webhook/README.md:

# webhook

Bot using HTTPS webhooks. Replies to `/ping` with `pong`.

## Run

You need a public HTTPS endpoint pointed at port 8080. For local development use a tunnel like Cloudflare Tunnel or ngrok.

```bash
export TELEGRAM_BOT_TOKEN=123456:ABC...
export WEBHOOK_URL=https://your.tunnel.example/bot
export WEBHOOK_SECRET=randomsecret123
go run ./examples/webhook

- [ ] **Step 3: Verify build**

Run: `go build ./examples/webhook/...`

- [ ] **Step 4: Commit**

```bash
git add examples/webhook/
git commit -m "docs(examples): add webhook bot"

Task 19 — Integration test suite (gated)

Files:

  • Create: test/integration/integration_test.go

  • Create: test/integration/README.md

  • Step 1: Implement test/integration/integration_test.go

//go:build integration

// Package integration_test contains tests that hit the live Telegram Bot
// API. These tests are gated behind the "integration" build tag and the
// TELEGRAM_BOT_TOKEN environment variable; they do not run on default
// `go test ./...`.
package integration_test

import (
	"context"
	"os"
	"strconv"
	"testing"
	"time"

	"github.com/lukaszraczylo/go-telegram/api"
	"github.com/lukaszraczylo/go-telegram/client"
	"github.com/stretchr/testify/require"
)

func botFromEnv(t *testing.T) *client.Bot {
	tok := os.Getenv("TELEGRAM_BOT_TOKEN")
	if tok == "" {
		t.Skip("TELEGRAM_BOT_TOKEN not set")
	}
	return client.New(tok)
}

func TestGetMe(t *testing.T) {
	b := botFromEnv(t)
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	u, err := api.GetMe(ctx, b)
	require.NoError(t, err)
	require.True(t, u.IsBot)
}

func TestSendMessage(t *testing.T) {
	b := botFromEnv(t)
	chatRaw := os.Getenv("TELEGRAM_TEST_CHAT_ID")
	if chatRaw == "" {
		t.Skip("TELEGRAM_TEST_CHAT_ID not set")
	}
	chatID, err := strconv.ParseInt(chatRaw, 10, 64)
	require.NoError(t, err, "TELEGRAM_TEST_CHAT_ID must be an integer")

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	msg, err := api.SendMessage(ctx, b, &api.SendMessageParams{
		ChatID: chatID,
		Text:   "integration test " + time.Now().UTC().Format(time.RFC3339),
	})
	require.NoError(t, err)
	require.NotZero(t, msg.MessageID)
}

func TestWebhookCycle(t *testing.T) {
	b := botFromEnv(t)
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	// Make sure no webhook is set first (long-poll mode).
	_, _ = api.DeleteWebhook(ctx, b, &api.DeleteWebhookParams{})

	ok, err := api.SetWebhook(ctx, b, &api.SetWebhookParams{URL: "https://example.invalid/no-receive"})
	require.NoError(t, err)
	require.True(t, ok)

	ok, err = api.DeleteWebhook(ctx, b, &api.DeleteWebhookParams{})
	require.NoError(t, err)
	require.True(t, ok)
}
  • Step 2: Add test/integration/README.md
# Integration tests

Live tests against the real Telegram Bot API. Skipped by default.

## Run

```bash
export TELEGRAM_BOT_TOKEN=test_bot_token_here
export TELEGRAM_TEST_CHAT_ID=123456789  # a chat the bot can post in
make integration

The suite covers getMe, sendMessage, and the setWebhook/deleteWebhook cycle.


- [ ] **Step 3: Verify build with tag**

Run: `go vet -tags=integration ./test/integration/...` and `go build -tags=integration ./test/integration/...`
Expected: success.

- [ ] **Step 4: Verify default suite still passes (without tag) and skips this file**

Run: `go test ./...`
Expected: PASS, no integration tests run.

- [ ] **Step 5: Commit**

```bash
git add test/
git commit -m "test(integration): add gated suite for live API smoke checks"

Task 20 — README expansion

Files:

  • Modify: README.md

  • Step 1: Replace stub README with full content

Overwrite README.md with:

# go-telegram

A Go library for the Telegram Bot API with pluggable HTTP transport, pluggable JSON codec, long-poll and webhook delivery, and a typed dispatcher.

[![CI](https://github.com/lukaszraczylo/go-telegram/actions/workflows/ci.yml/badge.svg)](https://github.com/lukaszraczylo/go-telegram/actions/workflows/ci.yml)
[![Go Reference](https://pkg.go.dev/badge/github.com/lukaszraczylo/go-telegram.svg)](https://pkg.go.dev/github.com/lukaszraczylo/go-telegram)

## Why

Most existing Go Telegram libraries either bring large transitive dependency trees or hard-wire `encoding/json` and `net/http`. This library:

- Uses the standard library only in production code paths.
- Treats the HTTP transport (`HTTPDoer`) and JSON codec (`Codec`) as plug points so you can swap in `valyala/fasthttp`, `goccy/go-json`, `bytedance/sonic`, etc., without forking the library.
- Funnels every API call through one generic helper (`client.Call[Req, Resp]`), keeping the per-method surface tiny and consistent.
- Ships a typed dispatcher (`dispatch.Router`) with command, regex, callback, and inline-query matchers.
- Plans to regenerate its API surface from the live Telegram docs (see `docs/superpowers/specs/`); in v1 a curated subset is hand-coded.

## Install

```bash
go get github.com/lukaszraczylo/go-telegram

Quick start (long-poll echo bot)

package main

import (
    "context"
    "log"
    "os"

    "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() {
    bot := client.New(os.Getenv("TELEGRAM_BOT_TOKEN"))

    r := dispatch.New(bot)
    r.OnCommand("/start", func(c *dispatch.Context, m *api.Message) error {
        _, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
            ChatID: m.Chat.ID, Text: "hello",
        })
        return err
    })

    if err := r.Run(context.Background(), transport.NewLongPoller(bot)); err != nil {
        log.Fatal(err)
    }
}

See examples/echo and examples/webhook for full programs.

Custom HTTP and JSON

import (
    "github.com/goccy/go-json"
    "github.com/lukaszraczylo/go-telegram/client"
)

type goccyCodec struct{}
func (goccyCodec) Marshal(v any) ([]byte, error)        { return json.Marshal(v) }
func (goccyCodec) Unmarshal(data []byte, v any) error    { return json.Unmarshal(data, v) }

bot := client.New(token,
    client.WithCodec(goccyCodec{}),
    client.WithHTTPClient(myFasthttpAdapter{}),
)

WithLogger accepts any Loggerslog.Logger satisfies it via a thin shim.

Webhooks

wh := transport.NewWebhookServer(bot)
wh.SecretToken = "the-secret-from-setWebhook"

mux := http.NewServeMux()
mux.Handle("/bot", wh)
go http.ListenAndServe(":8080", mux)

router := dispatch.New(bot)
// ... register handlers ...
router.Run(ctx, wh)

Dispatcher

  • OnCommand("/start", h) — matches messages whose first entity is a bot_command. Strips @BotName suffix.
  • OnText("^hello (\\w+)$", h) — regex on Message.Text. Captures available via c.Values["regex_match"].
  • OnCallback("^like:(\\d+)$", h) — regex on CallbackQuery.Data.
  • OnInlineQuery(h), OnEditedMessage(h).
  • Use(mw) — typed Middleware[*api.Update] chain. Panic-recovery middleware is registered automatically.

Handlers receive a *dispatch.Context (carrying Ctx, Bot, Update, and a Values bag) and a typed payload.

Error handling

msg, err := api.SendMessage(ctx, bot, ...)
if err != nil {
    var ae *client.APIError
    if errors.As(err, &ae) {
        if ae.IsRetryable() {
            time.Sleep(ae.RetryAfter())
        }
    }
    if errors.Is(err, client.ErrChatNotFound) { /* ... */ }
}

Sentinels: ErrUnauthorized, ErrForbidden, ErrTooManyRequests, ErrChatNotFound, ErrMessageNotModified, ErrBadRequest. Network failures wrap as *client.NetworkError; JSON decode failures wrap as *client.ParseError.

Testing your bot

client.HTTPDoer is the only thing you need to mock:

type fakeDoer struct{ ... }
func (f *fakeDoer) Do(*http.Request) (*http.Response, error) { ... }

bot := client.New("token", client.WithHTTPClient(&fakeDoer{}))

This library's own tests use testify/mock on this exact interface; see client/call_test.go.

Updating

The hand-coded API slice in api/ covers the ~8 methods needed for the example bots. Codegen (Plan 2) will replace it with the full Telegram surface generated from https://core.telegram.org/bots/api. Until then, contributions adding methods follow the conventions documented in docs/superpowers/plans/2026-05-08-go-telegram-core.md.

Contributing

make test          # unit tests
make test-race     # with race detector
make lint          # vet + staticcheck
make integration   # live tests (TELEGRAM_BOT_TOKEN required)

License

MIT — see LICENSE.


- [ ] **Step 2: Commit**

```bash
git add README.md
git commit -m "docs(readme): full README with quick start, errors, dispatcher, testing"

Task 21 — godoc audit + final lint pass

Files:

  • Modify: any package with missing doc comments.

  • Step 1: Run lint and audit doc comments

Run: go vet ./... && staticcheck ./... && go test -race ./... Expected: all clean.

  • Step 2: Verify every exported symbol has a doc comment

Run:

go doc -all ./client | grep -B1 '^func\|^type' | grep -v '^--$' || true

Visually inspect for any exported symbol whose preceding line is empty. Add a doc comment for any that are missing one. Common omissions to check:

  • client.Logger methods — already documented (interface).

  • transport.Updater — documented.

  • dispatch.Handler[T] / Middleware[T] — documented.

  • Step 3: Add CHANGELOG.md

Create CHANGELOG.md:

# Changelog

All notable changes to this project will be documented in this file.

## [Unreleased]

### Added
- Pluggable HTTP transport (`client.HTTPDoer`) and JSON codec (`client.Codec`).
- Generic call helper `client.Call[Req, Resp]` with multipart support.
- Typed errors (`*APIError` with sentinel unwrapping, `*NetworkError`, `*ParseError`).
- Long-poll (`transport.LongPoller`) and webhook (`transport.WebhookServer`) updaters.
- Generic dispatcher (`dispatch.Router`) with command, regex, callback, inline-query matchers, panic recovery.
- Hand-coded `api/` slice covering `getMe`, `getUpdates`, `sendMessage`, `setWebhook`, `deleteWebhook`, `answerCallbackQuery`, `sendDocument`.
- Echo and webhook example bots.
- Integration test suite gated by `integration` build tag.
  • Step 4: Commit
git add CHANGELOG.md
git commit -m "docs: add CHANGELOG (unreleased section)"

Task 22 — Final verification + tag

Files: none.

  • Step 1: Full clean build + test pass
go mod tidy
go vet ./...
staticcheck ./...
go test -race -coverprofile=coverage.out ./...
go tool cover -func=coverage.out | tail -n 1

Expected: green. Coverage of hand-written packages (client, transport, dispatch, api) should be ≥ 80%.

  • Step 2: Build all examples
go build ./examples/echo
go build ./examples/webhook

Expected: success.

  • Step 3: Commit any final tidy changes
git add -A
git diff --cached --stat
git commit -m "chore: go mod tidy" || true
  • Step 4: Tag v0.1.0-core
git tag -a v0.1.0-core -m "Plan 1 complete: hand-written client + transport + dispatcher + curated api slice"

Self-review notes

This plan covers spec §§110 and §§1314 (acceptance criteria for v1 minus codegen). Plan 2 covers spec §§6 (codegen pipeline) and §11 (handling API changes via shape tests + golden refresh).

Spec coverage check:

  • §4 layout — Tasks 1, 3 (foundation), 410 (client/), 1214 (transport/), 1516 (dispatch/).
  • §5 client — Tasks 49.
  • §6 codegen — deferred to Plan 2; internal/spec/ir.go defined now (Task 3) so Plan 2 has nothing to design.
  • §7 transport — Tasks 1214.
  • §8 dispatcher — Tasks 1516.
  • §9 testing — Tasks 416 (per-package), Task 19 (integration), shape tests deferred to Plan 2 (§9.2 in spec is about codegen).
  • §10 CI — Task 2.
  • §11 handling API changes — deferred to Plan 2.
  • §12 future work — out of scope.
  • §13 dependency policy — enforced by Tasks 49 (stdlib only) and Task 22 (go mod tidy).
  • §14 acceptance — covered by Tasks 1722.

Plan 2 entry point: with internal/spec/ir.go already defined, Plan 2 begins by writing cmd/scrape against a tiny HTML fixture (TDD), then expands to the live snapshot, then writes cmd/genapi templates, then re-runs against live to replace the hand-coded api/.