22 Commits

Author SHA1 Message Date
lukaszraczylo 60eb0a89b5 refactor(api): typed enums for emoji-list fields (DiceEmoji, ReactionEmoji)
Two API fields carry restricted emoji-value sets that the scraper's
curly-quote regex strips during IR extraction (multi-byte boundary
issue): ReactionTypeEmoji.Emoji and sendDice.Emoji. They previously
typed as plain string with no compile-time guarantee on values.

Add hand-curated typed-string enums in api/enums.go (the manual file,
not enums.gen.go):

  - DiceEmoji: 6 constants (Dice, Dart, Basketball, Football, Bowling,
    SlotMachine) covering Telegram's full set for sendDice.
  - ReactionEmoji: 73 constants covering the canonical reaction set
    from https://core.telegram.org/bots/api#reactiontypeemoji. Names
    follow Unicode CLDR short names where one exists, otherwise stable
    common-English labels (e.g. ThumbsUp, Heart, Clown, ManTechnologist).

Wire the field-type override via cmd/genapi/emitter.go:

  - fieldTypeOverrides map keyed "<TypeOrParamsName>.<FieldName>".
  - goField/multipartFieldEntry consult the override after the enum-plan
    lookup; falls through to the default goType when nothing matches.
  - methods.tmpl gains goFieldP/multipartFieldEntryP helpers that pass
    the params type name as override-parent (the params struct doesn't
    share a Go type with the field, so the existing parent="" enum-key
    convention is preserved).

Regenerated api/types.gen.go and api/methods.gen.go now type the two
fields as ReactionEmoji and DiceEmoji respectively. No other Emoji
field is affected (override is scoped per parent type). regen-from-
fixture is byte-deterministic across runs.

Add api/emoji_enums_test.go covering const wire values, reflection
checks on field types, and a marshal/unmarshal round-trip for
ReactionTypeEmoji.
2026-05-09 20:47:16 +01:00
lukaszraczylo fecef22f48 refactor(scrape): detect prose-style "must be X" discriminator values on variants
Sealed-interface union variants whose Type/Source field is declared as
bare prose (e.g. "Type of the result, must be article" or "Scope type,
must be all_private_chats") were skipped by extractEnumValues because
the existing patterns require curly-quoted values. The genapi emitter
already extracted these values via discBareRE for marshal-side
discriminator injection; lifting the same detection into the scraper
populates Field.EnumValues so planUnifiedUnionEnums folds them into
shared union-level enums automatically.

Unions newly unified (10): BotCommandScope, MenuButton, InputMedia,
InputPaidMedia, InputPollMedia, InputPollOptionMedia, InputProfilePhoto,
InputStoryContent, InlineQueryResult, PassportElementError.

InputMessageContent stays excluded — its variants dispatch
structurally on field presence and have no Type/Source field, so
planUnifiedUnionEnums correctly skips it.

Constants added: 60 typed enum constants across the 10 unions; the
corresponding variant struct fields are retyped from string to the
shared enum.

Internal call-site cleanups: 0 — no internal package referenced these
discriminator values via magic strings.

False positives the prose detector explicitly rejects: terminal
prose-word continuations like "must be sent", "must be shown above",
"must be specified", "must be paid", "must be active", "must be one
of 3, 6, or 12", "must be between 5 and 100000", "must be a Pay
button", "must be repainted". Guarded via terminal-position regex
anchor + closed-list isProseWord filter.

Determinism verified across two consecutive make regen-from-fixture
runs. go test -race ./..., go vet ./..., staticcheck ./... all clean.
2026-05-09 20:37:07 +01:00
lukaszraczylo 5523ed2b06 refactor(api): unify per-variant single-value enums into shared union-level enums
Each sealed-interface union with N variants used to emit N typed-string
enums, one per variant, each holding exactly one wire value. Codegen now
detects this pattern and emits ONE unified enum at the union level,
retyping every variant's discriminator field to point at it.

Unified enums (11 unions, 44 constants total):
  - ChatMemberStatus           (6)
  - MessageOriginType          (4)
  - BackgroundFillType         (3)
  - BackgroundTypeKind         (4)
  - ChatBoostSourceKind        (3)
  - OwnedGiftType              (2)
  - PaidMediaType              (4)
  - ReactionTypeKind           (3)
  - RevenueWithdrawalStateKind (3)
  - StoryAreaTypeKind          (5)
  - TransactionPartnerType     (7)

Naming-collision cases (union name already ends in a discriminator
concept noun, so the natural concat would stutter): BackgroundType,
ReactionType, StoryAreaType, ChatBoostSource, RevenueWithdrawalState.
The unified name ends with 'Kind' instead.

44 obsolete per-variant single-value enum types removed (e.g.
ChatMemberOwnerStatus, MessageOriginUserType). The variant struct types
themselves (ChatMemberOwner etc.) are unchanged; only their per-variant
single-value enum aliases go away.

Auto-inject MarshalJSON (commit 370c9c0) is unaffected — variant wire
values still come from the discriminator-extractor pass.

Call-site cleanup:
  - dispatch/filters/chatmember/chatmember_test.go
  - api/marshaljson_variants_test.go

New test: api/unifiedenum_test.go covers ChatMemberStatus and
MessageOriginType: variant-field retype, direct comparison without
conversion, marshal discriminator preservation, full round-trip,
stutter-suffix Kind sanity check.
2026-05-09 20:26:08 +01:00
lukaszraczylo 6f9b29ea0c fix(release): stop tagging bot-api version
Debug log from run 25609309601 confirmed semver-generator picks the
chronologically most-recent tag as the version base. Whenever the
bot-api-vX.Y tag was created after the library tag (or recreated
during a partially-failing release), semver-generator picked
bot-api-v10.0 as the base, couldn't parse 'v10.0' as full SemVer
(no patch number), and restarted numbering from v0.0.x.

Per direct user instruction: drop the bot-api tag entirely. The Bot
API version still appears in the library tag message and release
notes. Also delete the existing bot-api-v10.0 tag from local + remote
so the next release sees only v* tags.
2026-05-09 20:06:02 +01:00
lukaszraczylo bd80af240d chore(release): add tag-fetch step + debugmode to diagnose semver
Add an explicit git fetch --tags --force origin before semver-generator
runs, plus log visible tags. Enable semver-generator's debugmode so the
failure mode is visible. Diagnostic only — once we understand why
semver-generator computes v0.0.2 despite v0.1.2 being the latest tag,
the debug step can be removed.
2026-05-09 20:02:10 +01:00
lukaszraczylo af180b75c5 fix(release): tag bot-api marker before library version
semver-generator picks the chronologically most-recent tag as the
version base. The previous order tagged the lib version first, then
bot-api-vX.Y, leaving the bot-api tag as the most recent. semver-
generator then treated 'vX.Y' as the base, couldn't parse it as full
SemVer (no patch number), and silently restarted numbering from
v0.0.x — causing surprise version downgrades like v1.1.1 -> v0.0.4
and v0.1.1 -> v0.0.2.

Tagging bot-api-vX.Y first and the library version last keeps the
library tag as the chronologically last one, so subsequent runs see
it as the version base and bump correctly.
2026-05-09 19:58:14 +01:00
lukaszraczylo 29bf575cfd chore(release): bump to v0.1.2
Empty commit to advance the auto-release pipeline. Latest tag was
v0.1.1; semver-generator with the patch-prefix in the subject computes
the next version as v0.1.2.
2026-05-09 19:51:34 +01:00
lukaszraczylo 370c9c0802 refactor(api): auto-inject discriminator value via generated MarshalJSON
Sealed-interface union variants now hardcode their wire discriminator
inside a generated MarshalJSON method instead of forcing callers to set
the field on every struct literal. Drops a class of silent-rejection
bugs where a typo in the discriminator slipped past the type checker
and through to Telegram, which then rejected the request with no
Go-side signal.

The discriminator field stays exported so incoming-message decoding,
type switches and debugging still see it. MarshalJSON wraps via a
function-local type alias and emits an outer field with the same json
tag; encoding/json (and goccy/go-json) resolve the outer field as the
shallower one and override whatever the caller wrote.

99 variants get MarshalJSON. 7 are skipped because their unions
dispatch structurally rather than by a string field: Message and
InaccessibleMessage (MaybeInaccessibleMessage, dispatched on date),
and the InputMessageContent family (InputTextMessageContent,
InputLocationMessageContent, InputVenueMessageContent,
InputContactMessageContent, InputInvoiceMessageContent — Telegram
identifies these by the presence of message_text / latitude /
phone_number / title etc.).

Discriminator extraction lives in the emitter (cmd/genapi/emitter.go).
Resolution: knownDiscriminators reverse-lookup for the 13 auto-decode
unions, then doc-string analysis ("must be X" / "always “X”")
of the variant's first required string field for marker-only unions
(BotCommandScope, InputMedia, InputPaidMedia, InputProfilePhoto,
InputStoryContent, InputPollMedia, InputPollOptionMedia,
InlineQueryResult, PassportElementError). Variants the emitter cannot
resolve a discriminator for are skipped silently rather than emitting
broken code.

Internal call-site cleanups: 4 manual discriminator assignments
removed (api/unionparam_test.go,
dispatch/filters/message/message_test.go, examples/inline/main.go ×2).
Regression tests added in api/marshaljson_variants_test.go covering
type-keyed variants, source-keyed variants, the override-user-typo
guarantee, round-trip preservation through UnmarshalChatMember, the
no-discriminator InputMessageContent path, and ride-along of
non-discriminator fields.

regen-from-fixture is deterministic across two consecutive runs;
go test -race / go vet / staticcheck all clean.
2026-05-09 19:27:33 +01:00
lukaszraczylo 6ab80c27e1 fix(api): emit bare interface for all sealed-interface unions
Optional fields whose type was a sealed-interface union without an
auto-decode discriminator (BotCommandScope, InputMedia, InputPaidMedia,
InputProfilePhoto, InputStoryContent, InputMessageContent,
InputPollMedia, InputPollOptionMedia, InlineQueryResult,
PassportElementError) were emitted as *<Union> — pointer to interface,
which is a Go anti-pattern: interfaces are already nil-able, and
callers were forced to take addresses of concrete variants.

The codegen path goType() guarded against pointer-wrapping using
knownDiscriminators (only 13 unions with auto-decode dispatch), missing
the 10 marker-only sealed interfaces. New package var
knownInterfaceTypes is built from buildUnionTypeSet at emitter
construction and covers both kinds. goType() now consults that.

Net effect:
- api/methods.gen.go: 3 *BotCommandScope and 2 *InputPollMedia fields
  become bare interface
- api/types.gen.go: 14 *InputMessageContent and 1 *InputPollOptionMedia
  fields become bare interface

Regression tests in api/unionparam_test.go cover both shapes:
direct concrete-variant assignment, and nil omitempty on the bare
interface field.
2026-05-09 19:06:59 +01:00
lukaszraczylo 13ea7097e1 fix(docs): pin gomarkdoc repository URL flags for deterministic output
CI run 25607949051 failed on the codegen-clean diff check. gomarkdoc
auto-detects the GitHub repository URL from local git config — locally
on a full clone with origin set, it generates source-line links
([func Foo](https://github.com/.../blob/main/...#L4590)); on the CI
runner's fresh checkout, the remote info isn't available the same way
and gomarkdoc emits unlinked headings (## func Foo). The committed
docs (with links) and CI-regenerated docs (without links) diverged,
breaking the gate.

Pin the URL, branch, and path explicitly via gomarkdoc flags so output
is identical regardless of where it runs.
2026-05-09 19:04:23 +01:00
lukaszraczylo f899cc2663 fix(api): generate CallRaw + per-element decode for []<union> returns
GetChatAdministrators returns []ChatMember, where ChatMember is a
sealed-interface union. The codegen template emitted the generic
client.Call[..., []ChatMember] for it — encoding/json cannot unmarshal
a slice of an interface (no discriminator-aware path), so every real
response from Telegram failed at the parse step:

  telegram: parse: json: cannot unmarshal api.ChatMember into
  Go struct field Result[[]ChatMember].Result of type api.ChatMember

Fix is in cmd/genapi/methods.tmpl: add a third branch alongside the
existing single-union branch. When a method returns []<union>,
emit CallRaw + json.Unmarshal into []json.RawMessage + per-element
Unmarshal<Union>(e). Mirrors what GetChatMember (single-element)
already does, applied uniformly so any future slice-of-union method
Telegram introduces inherits the right shape.

Survey of v1.1.1 across all 23 sealed-interface unions confirms
GetChatAdministrators was the only broken site; the fix regenerates
just that one method body. New regression tests in
api/getchatadministrators_test.go cover the typical
admin+owner response and the empty-array case.
2026-05-09 18:57:54 +01:00
lukaszraczylo 5a27b53f30 fix(release): remove tag_prefixes — it filtered all v* tags out
Setting tag_prefixes: ['bot-api-'] caused semver-generator to ignore
every tag without that prefix, including the v0.x.y / v1.x.y release
tags. With no parseable existing version, it restarted numbering at
zero and produced v0.0.4 as a 'next' version after v1.1.1 — a
regression Go modules would never serve.

The bot-api-vX.Y marker is set by a separate workflow step; semver-
generator does not need to see it.
2026-05-09 18:30:46 +01:00
lukaszraczylo 1ff26006f7 chore(examples): use api.Ptr for optional pointer fields
Replace the throwaway-local-var-then-take-address pattern with
api.Ptr in the polls and moderation examples. Net effect: 14 lines
gone, the pointer wrangling no longer steals visual focus from the
actual API call.
2026-05-09 18:20:00 +01:00
lukaszraczylo f3fbef64ca chore(release): require canonical BREAKING CHANGE trailer for major
The bare 'breaking' major-bump keyword was matching substrings like
'breaking-value drift' in commit bodies, producing surprise major
bumps. Restrict the major trigger to the canonical Conventional
Commits trailer only.
2026-05-09 18:19:03 +01:00
lukaszraczylo e4614d800f feat(api): add api.Ptr helper for optional scalar fields
Telegram's optional int/bool/float fields are pointers so callers can
explicitly send false or 0 to override a chat default — distinct from
'absent', which uses the chat default. The pointer construction has been
ergonomically painful:

  photoLimit := int64(5)
  Limit: &photoLimit

api.Ptr[T any](v T) *T collapses that to a single line:

  Limit: api.Ptr[int64](5)
  DisableNotification: api.Ptr(true)

Pointers stay because the explicit-zero distinction matters for fields
like DisableNotification, ProtectContent, and getUpdates.Offset where
sending 0 / false explicitly is semantically different from omitting
the field.
2026-05-09 18:01:28 +01:00
lukaszraczylo 3c04d7b0b1 feat(api): typed enums for all string-enum fields
The Telegram docs describe many string fields and parameters with
phrases like "can be ..., or ...", "must be one of ...", or "always X",
yet the generated Go API surface used raw `string` for every one of
them. Callers had to write magic strings or `string(api.ChatTypePrivate)`
to satisfy the field type. This change makes those fields typed Go
string enums emitted from the IR, so the IDE autocompletes valid values
and breaking-value drift surfaces at compile time.

Pipeline changes:

- internal/spec/ir.go: Field gains EnumValues []string. Empty for non-
  enum fields; otherwise the wire-level values in doc order, deduped.

- cmd/scrape/enums.go: extractEnumValues recognises the curly-quoted
  patterns Telegram uses ("can be either", "currently can be", "one
  of", "must be", "always X") and rejects free-text quoted refs (e.g.
  "Can be available only for X") via a tight gap check between the
  trigger phrase and the first quoted value. parse_mode parameters
  get the canonical Markdown / MarkdownV2 / HTML triple injected
  because Telegram links to a separate formatting-options section
  instead of listing values inline.

- cmd/genapi/enums.go: planEnums groups fields by sorted value-tuple,
  picks a canonical Go enum name (most-common candidate, parent-
  prefixed beats plain, shortest beats longer, alphabetical for
  determinism), resolves cross-group name collisions by parent prefix.

- cmd/genapi/emitter.go + templates: goField rewrites the field type
  to the planned enum name; multipartFieldEntry casts typed enum
  values back to string when composing the wire map; enums.tmpl now
  iterates the planned enums instead of hardcoding four hand-curated
  ones; sentinelForField produces typed-constant test fixtures.

- api/enums.gen.go: regenerated from the live IR. 66 enum types, 155
  constants. ParseMode, ChatType, MessageEntityType, ChatMember /
  MessageOrigin / PaidMedia / Background / StoryAreaType / Reaction /
  TransactionPartner / PassportElement variant Status & Type fields
  are now typed.

- api/enums.go: hand-coded UpdateType (used by transport.LongPoller).
  The Telegram docs do not enumerate Update payload kinds inline, so
  the codegen pipeline cannot synthesise this enum.

- api/types.gen.go, api/methods.gen.go, api/methods_gen_test.go: 137
  field declarations rewritten string -> typed enum.

- dispatch/, examples/: dropped every string(api.<Const>) cast. The
  HasEntity filter now takes api.MessageEntityType; ChatType filter
  compares typed values directly. ChatMember discriminator filter
  casts variant.Status (typed per variant) to string for comparison.

- internal/spec/api.json, testdata/golden/*: regenerated and
  refreshed. make regen-from-fixture is byte-deterministic across
  runs.

Renames (no compat shims; v1 pre-public):
- EntityX  -> MessageEntityTypeX  (e.g. EntityBotCommand -> MessageEntityTypeBotCommand)
- EntityStrike -> MessageEntityTypeStrikethrough (full wire name)
2026-05-09 17:55:34 +01:00
lukaszraczylo 1da759ba8a docs(pages): add .nojekyll to disable Jekyll
Jekyll's Liquid parser breaks on Go template syntax ({{.Field}}) inside
gomarkdoc-generated reference docs and on planning artefacts in
docs/superpowers/. Pages source set to 'Deploy from branch' triggers a
Jekyll build that fails on these files; .nojekyll makes Pages serve
docs/ as static content, which is what index.html actually wants.
2026-05-09 14:16:36 +01:00
lukaszraczylo 1088b7f4d7 docs: auto-generate markdown reference + soften README
- Add gomarkdoc-driven reference docs in docs/reference/, regenerated
  automatically by 'make regen' alongside the api/ codegen
- New 'make docs' target installs gomarkdoc on first run; 'make
  docs-check' is a CI gate
- Fold doc-clean assertion into existing codegen-clean job (single
  diff check covers spec + api + reference)
- Rewrite README header: logo via <picture>, friendlier tagline,
  emoji-led 'Why you'll like it' bullets instead of Why-table
- Drop duplicate echo snippet, soften 'Codegen pipeline' section into
  'Keeping up with Telegram'
- Link reference from README, Pages nav, and a new Markdown reference
  card on index.html (target = GitHub source view, renders .md natively)
2026-05-09 14:14:37 +01:00
lukaszraczylo 35058dd70b docs(pages): add GitHub Pages landing page mirroring kportal's design
- docs/index.html  — full landing page (Tailwind CDN, FA icons, Inter+JetBrains Mono,
  light/dark mode, glass-morphism nav, hero + features + comparison + install +
  usage + examples grid + codegen pipeline + advanced collapsibles + footer)
- docs/logo-light.svg / logo-dark.svg — paper-plane wordmark SVGs
- docs/CNAME — go-telegram.raczylo.com
- .github/workflows/pages.yml — GitHub Pages deploy from docs/ on push to main

ACTION REQUIRED: enable GitHub Pages in repo Settings → Pages → Source: GitHub Actions.
Add a DNS CNAME record: go-telegram.raczylo.com → lukaszraczylo.github.io
2026-05-09 14:14:37 +01:00
lukaszraczylo 750a7f51f6 Create CNAME 2026-05-09 14:13:12 +01:00
lukaszraczylo b491829267 Add sponsorship 2026-05-09 13:56:40 +01:00
lukaszraczylo ac7cae8fa7 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
67 changed files with 24856 additions and 7361 deletions
+8 -14
View File
@@ -134,7 +134,7 @@ jobs:
- name: Regenerate against pinned snapshot
run: make regen-from-fixture
- name: Assert clean diff
run: git diff --exit-code internal/spec/api.json api/
run: git diff --exit-code internal/spec/api.json api/ docs/reference/
audit:
runs-on: ubuntu-latest
@@ -245,23 +245,17 @@ jobs:
API_TAG: ${{ steps.api_version.outputs.tag }}
API_VER: ${{ steps.api_version.outputs.version }}
run: |
# Bot API version (currently $API_VER) is intentionally NOT
# tagged separately. semver-generator picks the most recent
# tag as the version base; a non-SemVer marker like
# bot-api-vX.Y poisons that and restarts numbering from
# v0.0.x. Bot API version stays as a comment in the lib tag
# message and in the release notes.
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$LIB_TAG" -m "Release $LIB_TAG"
git tag -a "$LIB_TAG" -m "Release $LIB_TAG (Bot API $API_VER)"
git push origin "$LIB_TAG"
if [ -n "$API_TAG" ]; then
# Force-update the bot-api tag so it always points at the latest
# release that supports that API version.
if git rev-parse "$API_TAG" >/dev/null 2>&1; then
git tag -f -a "$API_TAG" -m "go-telegram release $LIB_TAG (Bot API $API_VER)"
git push -f origin "$API_TAG"
else
git tag -a "$API_TAG" -m "go-telegram release $LIB_TAG (Bot API $API_VER)"
git push origin "$API_TAG"
fi
fi
- name: Run GoReleaser
if: github.event_name != 'workflow_dispatch' || inputs.dry-run-release == false
uses: goreleaser/goreleaser-action@v6
+33
View File
@@ -0,0 +1,33 @@
name: pages
on:
push:
branches: [main]
paths:
- 'docs/**'
- '.github/workflows/pages.yml'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: docs/
- id: deployment
uses: actions/deploy-pages@v4
+10 -6
View File
@@ -19,11 +19,12 @@ blacklist:
- "Merge remote-tracking branch"
- "go mod tidy"
# Strip the auto-generated bot-api-vX.Y tag prefix when scanning existing
# tags — those are markers that point at library releases, not version
# sources themselves.
tag_prefixes:
- "bot-api-"
# NOTE: do not configure tag_prefixes here. The action's behaviour with
# that option present is to FILTER tags down to those matching the
# listed prefixes — meaning v0.x.y / v1.x.y tags get ignored and version
# numbering restarts from zero. The bot-api-vX.Y tag is created by a
# separate workflow step and does not need to participate in version
# selection.
wording:
patch:
@@ -39,5 +40,8 @@ wording:
minor:
- "feat"
major:
- "breaking"
# Match only the canonical Conventional Commits trailer. The bare
# word "breaking" is too greedy under semver-generator's fuzzy match —
# it triggers on substrings like "breaking-value drift" inside a
# commit body and wrongly produces a major bump.
- "BREAKING CHANGE"
+1
View File
@@ -0,0 +1 @@
go-telegram.raczylo.com
+31 -1
View File
@@ -1,4 +1,4 @@
.PHONY: test test-race lint vet integration regen snapshot regen-from-fixture test-update-golden clean clean-generated audit audit-drift help
.PHONY: test test-race lint vet integration regen snapshot regen-from-fixture test-update-golden clean clean-generated audit audit-drift docs docs-check help
GO ?= go
@@ -14,6 +14,8 @@ help:
@echo " test-update-golden - refresh golden test fixtures (Plan 2)"
@echo " audit - report any-typed/bool fallbacks in current IR"
@echo " audit-drift - audit + compare against HEAD's IR for signature changes"
@echo " docs - regenerate markdown reference docs into docs/reference/"
@echo " docs-check - assert docs/reference/ is up to date (CI gate)"
@echo " clean-generated - delete generated api/*.gen.go and internal/spec/api.json"
@echo " clean - clean-generated + transient artefacts (binaries, coverage)"
@@ -44,12 +46,14 @@ regen: clean-generated
$(GO) run ./cmd/audit -ir $(SCRAPE_OUTPUT)
$(GO) run ./cmd/genapi -input $(SCRAPE_OUTPUT) -outdir api
$(GO) test ./api/...
$(MAKE) docs
regen-from-fixture: clean-generated
$(GO) run ./cmd/scrape -input $(SCRAPE_INPUT) -output $(SCRAPE_OUTPUT)
$(GO) run ./cmd/audit -ir $(SCRAPE_OUTPUT)
$(GO) run ./cmd/genapi -input $(SCRAPE_OUTPUT) -outdir api
$(GO) test ./api/...
$(MAKE) docs
audit:
$(GO) run ./cmd/audit -ir $(SCRAPE_OUTPUT)
@@ -61,6 +65,32 @@ test-update-golden:
$(GO) test -run TestEmit -update ./cmd/genapi/...
$(GO) test -run TestScrape -update ./cmd/scrape/...
# Regenerate godoc-style markdown reference docs into docs/reference/.
# Auto-installs gomarkdoc on first run.
DOC_PACKAGES := \
./client \
./transport \
./dispatch \
./dispatch/conversation \
./dispatch/filters/message \
./dispatch/filters/callback \
./dispatch/filters/inline \
./dispatch/filters/chatmember \
./dispatch/filters/chatjoinrequest \
./dispatch/filters/precheckoutquery \
./api
docs:
@which gomarkdoc > /dev/null || (echo "installing gomarkdoc..." && $(GO) install github.com/princjef/gomarkdoc/cmd/gomarkdoc@v1.1.0)
gomarkdoc \
--repository.url=https://github.com/lukaszraczylo/go-telegram \
--repository.default-branch=main \
--repository.path=/ \
-o 'docs/reference/{{.Dir}}.md' $(DOC_PACKAGES)
docs-check: docs
@git diff --exit-code docs/reference/ || (echo "docs/reference/ is stale — run 'make docs' and commit" && exit 1)
# clean-generated removes ONLY codegen output. Source code (cmd/scrape,
# cmd/genapi, runtime helpers) is untouched. Run before regen to avoid
# orphan files lingering when the IR shrinks (renamed/removed methods).
+74 -48
View File
@@ -1,61 +1,71 @@
# go-telegram
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-dark.svg">
<img alt="go-telegram" src="docs/logo-light.svg" width="320">
</picture>
</p>
> A fully-generated, strongly-typed Go client for the Telegram Bot API — no `any`, no guessing.
<p align="center">
<strong>Build Telegram bots in Go that just work.</strong><br>
Type-safe. Batteries included. Always up to date with the latest Bot API.
</p>
[![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)
[![Go Version](https://img.shields.io/github/go-mod/go-version/lukaszraczylo/go-telegram)](go.mod)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
<p align="center">
<a href="https://github.com/lukaszraczylo/go-telegram/actions/workflows/ci.yml"><img src="https://github.com/lukaszraczylo/go-telegram/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://pkg.go.dev/github.com/lukaszraczylo/go-telegram"><img src="https://pkg.go.dev/badge/github.com/lukaszraczylo/go-telegram.svg" alt="Go Reference"></a>
<a href="go.mod"><img src="https://img.shields.io/github/go-mod/go-version/lukaszraczylo/go-telegram" alt="Go Version"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
</p>
> Bot API **v10.0** · 176 methods · 301 types · 1428 auto-generated tests
<p align="center">
Bot API <strong>v10.0</strong> · 176 methods · 301 types · 1428 auto-generated tests
</p>
Most Telegram bot libraries expose Telegram's "Integer or String" fields as `interface{}` or `any`. Every union type in go-telegram is a real Go type with compile-time safety and auto-decoding. The entire API surface is code-generated from a committed HTML snapshot of the live Telegram docs — regenerating picks up new Bot API versions in one command, with a self-verifying pipeline that catches regressions before they ship.
<p align="center">
<a href="https://go-telegram.raczylo.com/">Website</a> ·
<a href="docs/reference/">API Reference</a> ·
<a href="examples/">Examples</a> ·
<a href="https://pkg.go.dev/github.com/lukaszraczylo/go-telegram">pkg.go.dev</a>
</p>
---
## Hello, Telegram 👋
```go
bot := client.New(os.Getenv("TELEGRAM_BOT_TOKEN"),
client.WithHTTPClient(client.NewRetryDoer(client.NewDefaultHTTPDoer())),
)
bot := client.New(os.Getenv("TELEGRAM_BOT_TOKEN"))
router := dispatch.New(bot)
router.OnCommand("/start", func(c *dispatch.Context, m *api.Message) error {
_, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: "Hello! Send me anything to echo.",
})
return err
})
router.OnText(`.+`, func(c *dispatch.Context, m *api.Message) error {
_, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: m.Text,
ReplyParameters: &api.ReplyParameters{MessageID: m.MessageID},
Text: "Hi " + m.From.FirstName + "! 👋",
})
return err
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
router.Run(ctx, transport.NewLongPoller(bot))
```
## Why go-telegram
That's a working bot. No magic strings, no `any`, no guessing what fields exist — your editor autocompletes everything.
| Feature | What it means for you |
|---|---|
| **Typed unions** | `ChatID`, `MessageOrBool`, `InputFile`, and 13 discriminated-union interfaces give you `switch v.(type)` instead of runtime panics |
| **Full Bot API v10.0** | 176 methods and 301 types — all generated, none hand-written, nothing missing |
| **Self-verifying codegen** | `make snapshot && make regen` regenerates everything and runs 1428 tests; any regression fails the pipeline |
| **Pluggable transport + codec** | `HTTPDoer` and `Codec` are one-method interfaces — swap in fasthttp, sonic, or your test fake without forking |
| **Retry middleware** | `RetryDoer` honours Telegram's `retry_after`, backs off on 5xx, replays request bodies |
| **Composable dispatcher** | Per-update goroutine pool (default 50), filter combinators (`And`/`Or`/`Not`), conversation state machines, named handlers |
## Why you'll like it
## Quickstart
- 🎯 **No `any`, anywhere.** Telegram's "Integer or String" and "one of N types" unions are real Go types you can `switch` on.
- 🔋 **Batteries included.** Long-poll, webhooks, retries on rate limits, conversation state machines, filters, handler groups — out of the box.
- 🔄 **Always current.** The whole API is generated from Telegram's live docs. New Bot API release? `make regen` and you're done.
- 🪶 **Pluggable everything.** Swap the HTTP client, JSON codec, or storage backend with a one-method interface. No forks.
- 🧪 **Already tested.** 1428 generated tests cover every method × every failure mode (success, API errors, network failures, parse errors, timeouts, missing fields, forbidden, server errors).
## Install
```bash
go get github.com/lukaszraczylo/go-telegram
```
Full echo bot — long-poll, graceful shutdown, retry on 429:
## A complete echo bot
Long-poll, graceful shutdown, retries on Telegram's `429 retry_after`:
```go
package main
@@ -131,7 +141,30 @@ Run any example: `TELEGRAM_BOT_TOKEN=xxx go run ./examples/<name>`
| | [`polls`](examples/polls) | `sendPoll` and answer tally |
| | [`payments`](examples/payments) | Invoice → pre-checkout → success |
## Concepts
## Optional fields
Telegram marks many fields as optional. For optional **scalars** (int, bool, float) we use pointers so you can explicitly send `false` or `0` when the wire format needs to override a chat default. The `api.Ptr` helper keeps that ergonomic:
```go
api.SendMessage(ctx, bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(chatID),
Text: "hi",
DisableNotification: api.Ptr(true), // type inferred
})
api.GetUserProfilePhotos(ctx, bot, &api.GetUserProfilePhotosParams{
UserID: userID,
Limit: api.Ptr[int64](5), // explicit type for untyped literals
})
```
Optional structs and slices are already nullable in Go — no helper needed.
## Reference docs
Full API reference is auto-generated from source comments and lives in [`docs/reference/`](docs/reference/README.md) — browse package by package on GitHub, or read it rendered at [go-telegram.raczylo.com](https://go-telegram.raczylo.com/) and [pkg.go.dev](https://pkg.go.dev/github.com/lukaszraczylo/go-telegram).
## How it works
<details>
<summary>Bot client and pluggable transport</summary>
@@ -284,25 +317,18 @@ r.OnCommand("/cmd", named.Handler())
</details>
## Codegen pipeline
## Keeping up with Telegram
The full API surface in `api/*.gen.go` is generated from a committed HTML snapshot of `core.telegram.org/bots/api`:
When Telegram ships a new Bot API version, regenerating the whole library is one command:
```bash
make snapshot # fetch and commit latest HTML from core.telegram.org
make regen # scrape → audit → emit Go code → run generated tests
go test -race ./...
make snapshot # grab the latest HTML from core.telegram.org
make regen # scrape → audit → emit Go → run tests → regenerate docs
```
`make regen` is self-verifying. The audit tool (`cmd/audit`) checks:
The audit tool checks for `any`-typed escapes, surprise `bool` returns, and signature drift. CI runs it on every PR, and a weekly workflow opens an auto-PR with regenerated code so a new Bot API version never sits longer than a week.
- `any`-typed fields or returns that escaped the union machinery
- Methods returning `bool` not on the approved list (`internal/spec/overrides.json`)
- Signature drift vs HEAD's IR (added/removed/changed return types)
Exit codes: 0 clean · 1 fallback · 2 drift · 3 invalid. CI runs the audit on every PR. A weekly `regen.yml` workflow opens a PR with regenerated code and the audit summary in the body.
To track a new Bot API release: run `make snapshot && make regen`, review the audit output, update `internal/spec/overrides.json` for any newly unparseable methods, and submit a PR.
If something in Telegram's docs trips up the scraper, add an override to `internal/spec/overrides.json`. The audit will tell you what to put there.
## Testing
+239
View File
@@ -0,0 +1,239 @@
package api
import (
"reflect"
"testing"
json "github.com/goccy/go-json"
"github.com/stretchr/testify/require"
)
// TestUnifiedEnum_BotCommandScopeType_Constants confirms the prose-form
// discriminator detection promoted BotCommandScope's per-variant Type
// fields into one shared enum.
func TestUnifiedEnum_BotCommandScopeType_Constants(t *testing.T) {
require.IsType(t, BotCommandScopeType(""), BotCommandScopeTypeDefault)
got := []BotCommandScopeType{
BotCommandScopeTypeDefault,
BotCommandScopeTypeAllPrivateChats,
BotCommandScopeTypeAllGroupChats,
BotCommandScopeTypeAllChatAdministrators,
BotCommandScopeTypeChat,
BotCommandScopeTypeChatAdministrators,
BotCommandScopeTypeChatMember,
}
want := []string{
"default", "all_private_chats", "all_group_chats",
"all_chat_administrators", "chat", "chat_administrators", "chat_member",
}
require.Len(t, got, len(want))
for i, v := range got {
require.Equal(t, want[i], string(v))
}
}
// TestUnifiedEnum_InlineQueryResultType_VariantFields walks the variants
// and asserts each one's Type field is the unified enum.
func TestUnifiedEnum_InlineQueryResultType_VariantFields(t *testing.T) {
require.IsType(t, InlineQueryResultType(""), InlineQueryResultTypeArticle)
wantType := reflect.TypeOf(InlineQueryResultType(""))
cases := []any{
&InlineQueryResultArticle{},
&InlineQueryResultPhoto{},
&InlineQueryResultGif{},
&InlineQueryResultMpeg4Gif{},
&InlineQueryResultVideo{},
&InlineQueryResultAudio{},
&InlineQueryResultVoice{},
&InlineQueryResultDocument{},
&InlineQueryResultLocation{},
&InlineQueryResultVenue{},
&InlineQueryResultContact{},
&InlineQueryResultGame{},
}
for _, c := range cases {
rt := reflect.TypeOf(c).Elem()
f, ok := rt.FieldByName("Type")
require.True(t, ok, "%s missing Type field", rt.Name())
require.Equal(t, wantType, f.Type, "%s.Type type mismatch", rt.Name())
}
}
// TestUnifiedEnum_PassportElementErrorSource_VariantFields asserts the
// retype landed on every variant of the PassportElementError union.
func TestUnifiedEnum_PassportElementErrorSource_VariantFields(t *testing.T) {
require.IsType(t, PassportElementErrorSource(""), PassportElementErrorSourceData)
wantType := reflect.TypeOf(PassportElementErrorSource(""))
cases := []any{
&PassportElementErrorDataField{},
&PassportElementErrorFrontSide{},
&PassportElementErrorReverseSide{},
&PassportElementErrorSelfie{},
&PassportElementErrorFile{},
&PassportElementErrorFiles{},
&PassportElementErrorTranslationFile{},
&PassportElementErrorTranslationFiles{},
&PassportElementErrorUnspecified{},
}
for _, c := range cases {
rt := reflect.TypeOf(c).Elem()
f, ok := rt.FieldByName("Source")
require.True(t, ok, "%s missing Source field", rt.Name())
require.Equal(t, wantType, f.Type, "%s.Source type mismatch", rt.Name())
}
}
// TestUnifiedEnum_InputMediaType_Constants covers a media-shaped union
// where the discriminator value is the wire identifier "animation",
// "photo", etc.
func TestUnifiedEnum_InputMediaType_Constants(t *testing.T) {
require.IsType(t, InputMediaType(""), InputMediaTypePhoto)
wantType := reflect.TypeOf(InputMediaType(""))
for _, c := range []any{
&InputMediaAnimation{},
&InputMediaAudio{},
&InputMediaDocument{},
&InputMediaPhoto{},
&InputMediaVideo{},
} {
rt := reflect.TypeOf(c).Elem()
f, ok := rt.FieldByName("Type")
require.True(t, ok, "%s missing Type field", rt.Name())
require.Equal(t, wantType, f.Type, "%s.Type type mismatch", rt.Name())
}
}
// TestUnifiedEnum_MenuButtonType_Constants covers the third single-Type
// union pulled in by the prose detector.
func TestUnifiedEnum_MenuButtonType_Constants(t *testing.T) {
require.IsType(t, MenuButtonType(""), MenuButtonTypeCommands)
wantType := reflect.TypeOf(MenuButtonType(""))
for _, c := range []any{
&MenuButtonCommands{},
&MenuButtonWebApp{},
&MenuButtonDefault{},
} {
rt := reflect.TypeOf(c).Elem()
f, ok := rt.FieldByName("Type")
require.True(t, ok, "%s missing Type field", rt.Name())
require.Equal(t, wantType, f.Type, "%s.Type type mismatch", rt.Name())
}
}
// TestUnifiedEnum_InlineQueryResultArticle_RoundTrip confirms the
// auto-injected discriminator survives a marshal-unmarshal cycle on the
// concrete variant and lands as the typed enum constant. There's no
// generated UnmarshalInlineQueryResult — the union has no entry in
// knownDiscriminators — so the round-trip targets the variant directly.
func TestUnifiedEnum_InlineQueryResultArticle_RoundTrip(t *testing.T) {
orig := &InlineQueryResultArticle{
ID: "x1",
Title: "test",
}
raw, err := json.Marshal(orig)
require.NoError(t, err)
var probe struct {
Type string `json:"type"`
}
require.NoError(t, json.Unmarshal(raw, &probe))
require.Equal(t, "article", probe.Type)
// Strip InputMessageContent before re-decoding: it's a sealed
// interface and the variant has no UnmarshalJSON helper to dispatch
// it. The discriminator round-trip is the property under test, not
// nested-union deserialisation.
var round struct {
Type InlineQueryResultType `json:"type"`
ID string `json:"id"`
Title string `json:"title"`
}
require.NoError(t, json.Unmarshal(raw, &round))
require.Equal(t, InlineQueryResultTypeArticle, round.Type)
require.Equal(t, orig.ID, round.ID)
require.Equal(t, orig.Title, round.Title)
}
// TestUnifiedEnum_PassportElementErrorDataField_RoundTrip mirrors the
// above for the Source-discriminated union.
func TestUnifiedEnum_PassportElementErrorDataField_RoundTrip(t *testing.T) {
orig := &PassportElementErrorDataField{
Type: "personal_details",
FieldName: "first_name",
DataHash: "abc",
Message: "boom",
}
raw, err := json.Marshal(orig)
require.NoError(t, err)
var probe struct {
Source string `json:"source"`
}
require.NoError(t, json.Unmarshal(raw, &probe))
require.Equal(t, "data", probe.Source)
var round PassportElementErrorDataField
require.NoError(t, json.Unmarshal(raw, &round))
require.Equal(t, PassportElementErrorSourceData, round.Source)
require.Equal(t, orig.FieldName, round.FieldName)
}
// TestUnifiedEnum_BotCommandScopeChat_RoundTrip covers a bot-command
// scope variant with a non-trivial extra field (ChatID).
func TestUnifiedEnum_BotCommandScopeChat_RoundTrip(t *testing.T) {
orig := &BotCommandScopeChat{ChatID: ChatIDFromInt(42)}
raw, err := json.Marshal(orig)
require.NoError(t, err)
var probe struct {
Type string `json:"type"`
}
require.NoError(t, json.Unmarshal(raw, &probe))
require.Equal(t, "chat", probe.Type)
var round BotCommandScopeChat
require.NoError(t, json.Unmarshal(raw, &round))
require.Equal(t, BotCommandScopeTypeChat, round.Type)
}
// TestUnifiedEnum_InputMessageContent_NoEnumEmitted confirms the IMC
// union — which dispatches structurally on field presence rather than a
// shared discriminator — does NOT get a unified enum, since none of its
// variants declare a single-value discriminator field.
func TestUnifiedEnum_InputMessageContent_NoEnumEmitted(t *testing.T) {
for _, name := range []string{
"InputTextMessageContent",
"InputLocationMessageContent",
"InputVenueMessageContent",
"InputContactMessageContent",
"InputInvoiceMessageContent",
} {
switch name {
case "InputTextMessageContent":
rt := reflect.TypeOf(&InputTextMessageContent{}).Elem()
_, ok := rt.FieldByName("Type")
require.False(t, ok, "%s unexpectedly grew a Type field", name)
case "InputLocationMessageContent":
rt := reflect.TypeOf(&InputLocationMessageContent{}).Elem()
_, ok := rt.FieldByName("Type")
require.False(t, ok, "%s unexpectedly grew a Type field", name)
case "InputVenueMessageContent":
rt := reflect.TypeOf(&InputVenueMessageContent{}).Elem()
_, ok := rt.FieldByName("Type")
require.False(t, ok, "%s unexpectedly grew a Type field", name)
case "InputContactMessageContent":
rt := reflect.TypeOf(&InputContactMessageContent{}).Elem()
_, ok := rt.FieldByName("Type")
require.False(t, ok, "%s unexpectedly grew a Type field", name)
case "InputInvoiceMessageContent":
rt := reflect.TypeOf(&InputInvoiceMessageContent{}).Elem()
_, ok := rt.FieldByName("Type")
require.False(t, ok, "%s unexpectedly grew a Type field", name)
}
}
}
+96
View File
@@ -0,0 +1,96 @@
package api
import (
"reflect"
"testing"
"github.com/goccy/go-json"
"github.com/stretchr/testify/require"
)
// TestDiceEmoji_Constants pins the canonical six dice-emoji values so a
// regen, refactor, or accidental rename can't silently break the wire
// contract.
func TestDiceEmoji_Constants(t *testing.T) {
cases := []struct {
name string
got DiceEmoji
want string
}{
{"Dice", DiceEmojiDice, "🎲"},
{"Dart", DiceEmojiDart, "🎯"},
{"Basketball", DiceEmojiBasketball, "🏀"},
{"Football", DiceEmojiFootball, "⚽"},
{"Bowling", DiceEmojiBowling, "🎳"},
{"SlotMachine", DiceEmojiSlotMachine, "🎰"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
require.Equal(t, c.want, string(c.got))
})
}
}
// TestSendDiceParams_EmojiFieldType asserts the codegen override wired
// SendDiceParams.Emoji to the typed enum (not plain string). Reflection
// catches a regression even if the file compiles via implicit string
// conversion of an untyped literal.
func TestSendDiceParams_EmojiFieldType(t *testing.T) {
rt := reflect.TypeOf(SendDiceParams{})
f, ok := rt.FieldByName("Emoji")
require.True(t, ok, "SendDiceParams.Emoji not present")
require.Equal(t, "DiceEmoji", f.Type.Name())
}
// TestSendDiceParams_MarshalJSON exercises the marshalled wire form to
// prove the typed enum still serialises as a JSON string holding the
// raw emoji bytes — i.e. the type override doesn't accidentally
// double-encode.
func TestSendDiceParams_MarshalJSON(t *testing.T) {
p := &SendDiceParams{
ChatID: ChatIDFromInt(1),
Emoji: DiceEmojiBasketball,
}
data, err := json.Marshal(p)
require.NoError(t, err)
require.Contains(t, string(data), `"emoji":"🏀"`)
}
// TestReactionEmoji_Constants spot-checks a representative slice of the
// 73-value enum. A full enumeration would be redundant — the test is
// here to lock the wire form, not to retest the const-block.
func TestReactionEmoji_Constants(t *testing.T) {
require.Equal(t, "👍", string(ReactionEmojiThumbsUp))
require.Equal(t, "👎", string(ReactionEmojiThumbsDown))
require.Equal(t, "❤", string(ReactionEmojiHeart))
require.Equal(t, "🔥", string(ReactionEmojiFire))
require.Equal(t, "💯", string(ReactionEmojiHundredPoints))
require.Equal(t, "🤡", string(ReactionEmojiClown))
}
// TestReactionTypeEmoji_FieldType asserts the codegen override wired
// ReactionTypeEmoji.Emoji to the typed enum.
func TestReactionTypeEmoji_FieldType(t *testing.T) {
rt := reflect.TypeOf(ReactionTypeEmoji{})
f, ok := rt.FieldByName("Emoji")
require.True(t, ok, "ReactionTypeEmoji.Emoji not present")
require.Equal(t, "ReactionEmoji", f.Type.Name())
}
// TestReactionTypeEmoji_RoundTrip proves a typed-enum value survives
// JSON marshal → unmarshal cycle without losing fidelity. The
// discriminator MarshalJSON on ReactionTypeEmoji forces type="emoji",
// so we set it explicitly here for symmetry with the unmarshal path.
func TestReactionTypeEmoji_RoundTrip(t *testing.T) {
in := &ReactionTypeEmoji{
Type: ReactionTypeKindEmoji,
Emoji: ReactionEmojiThumbsUp,
}
data, err := json.Marshal(in)
require.NoError(t, err)
require.Contains(t, string(data), `"emoji":"👍"`)
var out ReactionTypeEmoji
require.NoError(t, json.Unmarshal(data, &out))
require.Equal(t, ReactionEmojiThumbsUp, out.Emoji)
}
+420 -33
View File
@@ -4,16 +4,54 @@
package api
// ParseMode controls how Telegram interprets formatting in message text.
type ParseMode string
type BackgroundFillType string
const (
ParseModeMarkdown ParseMode = "Markdown" // legacy
ParseModeMarkdownV2 ParseMode = "MarkdownV2"
ParseModeHTML ParseMode = "HTML"
BackgroundFillTypeSolid BackgroundFillType = "solid"
BackgroundFillTypeGradient BackgroundFillType = "gradient"
BackgroundFillTypeFreeformGradient BackgroundFillType = "freeform_gradient"
)
type BackgroundTypeKind string
const (
BackgroundTypeKindFill BackgroundTypeKind = "fill"
BackgroundTypeKindWallpaper BackgroundTypeKind = "wallpaper"
BackgroundTypeKindPattern BackgroundTypeKind = "pattern"
BackgroundTypeKindChatTheme BackgroundTypeKind = "chat_theme"
)
type BotCommandScopeType string
const (
BotCommandScopeTypeDefault BotCommandScopeType = "default"
BotCommandScopeTypeAllPrivateChats BotCommandScopeType = "all_private_chats"
BotCommandScopeTypeAllGroupChats BotCommandScopeType = "all_group_chats"
BotCommandScopeTypeAllChatAdministrators BotCommandScopeType = "all_chat_administrators"
BotCommandScopeTypeChat BotCommandScopeType = "chat"
BotCommandScopeTypeChatAdministrators BotCommandScopeType = "chat_administrators"
BotCommandScopeTypeChatMember BotCommandScopeType = "chat_member"
)
type ChatBoostSourceKind string
const (
ChatBoostSourceKindPremium ChatBoostSourceKind = "premium"
ChatBoostSourceKindGiftCode ChatBoostSourceKind = "gift_code"
ChatBoostSourceKindGiveaway ChatBoostSourceKind = "giveaway"
)
type ChatMemberStatus string
const (
ChatMemberStatusCreator ChatMemberStatus = "creator"
ChatMemberStatusAdministrator ChatMemberStatus = "administrator"
ChatMemberStatusMember ChatMemberStatus = "member"
ChatMemberStatusRestricted ChatMemberStatus = "restricted"
ChatMemberStatusLeft ChatMemberStatus = "left"
ChatMemberStatusKicked ChatMemberStatus = "kicked"
)
// ChatType is the type of a Telegram chat.
type ChatType string
const (
@@ -23,38 +61,387 @@ const (
ChatTypeChannel ChatType = "channel"
)
// UpdateType identifies an Update payload variant. Used by allowed_updates
// in getUpdates / setWebhook.
type UpdateType string
type EncryptedPassportElementType 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"
EncryptedPassportElementTypePersonalDetails EncryptedPassportElementType = "personal_details"
EncryptedPassportElementTypePassport EncryptedPassportElementType = "passport"
EncryptedPassportElementTypeDriverLicense EncryptedPassportElementType = "driver_license"
EncryptedPassportElementTypeIdentityCard EncryptedPassportElementType = "identity_card"
EncryptedPassportElementTypeInternalPassport EncryptedPassportElementType = "internal_passport"
EncryptedPassportElementTypeAddress EncryptedPassportElementType = "address"
EncryptedPassportElementTypeUtilityBill EncryptedPassportElementType = "utility_bill"
EncryptedPassportElementTypeBankStatement EncryptedPassportElementType = "bank_statement"
EncryptedPassportElementTypeRentalAgreement EncryptedPassportElementType = "rental_agreement"
EncryptedPassportElementTypePassportRegistration EncryptedPassportElementType = "passport_registration"
EncryptedPassportElementTypeTemporaryRegistration EncryptedPassportElementType = "temporary_registration"
EncryptedPassportElementTypePhoneNumber EncryptedPassportElementType = "phone_number"
EncryptedPassportElementTypeEmail EncryptedPassportElementType = "email"
)
type InlineQueryChatType string
const (
InlineQueryChatTypeSender InlineQueryChatType = "sender"
InlineQueryChatTypePrivate InlineQueryChatType = "private"
InlineQueryChatTypeGroup InlineQueryChatType = "group"
InlineQueryChatTypeSupergroup InlineQueryChatType = "supergroup"
InlineQueryChatTypeChannel InlineQueryChatType = "channel"
)
type InlineQueryResultDocumentMimeType string
const (
InlineQueryResultDocumentMimeTypeApplicationOfPdf InlineQueryResultDocumentMimeType = "application/pdf"
InlineQueryResultDocumentMimeTypeApplicationOfZip InlineQueryResultDocumentMimeType = "application/zip"
)
type InlineQueryResultGifThumbnailMimeType string
const (
InlineQueryResultGifThumbnailMimeTypeImageOfJpeg InlineQueryResultGifThumbnailMimeType = "image/jpeg"
InlineQueryResultGifThumbnailMimeTypeImageOfGif InlineQueryResultGifThumbnailMimeType = "image/gif"
InlineQueryResultGifThumbnailMimeTypeVideoOfMp4 InlineQueryResultGifThumbnailMimeType = "video/mp4"
)
type InlineQueryResultType string
const (
InlineQueryResultTypeAudio InlineQueryResultType = "audio"
InlineQueryResultTypeDocument InlineQueryResultType = "document"
InlineQueryResultTypeGif InlineQueryResultType = "gif"
InlineQueryResultTypeMpeg4Gif InlineQueryResultType = "mpeg4_gif"
InlineQueryResultTypePhoto InlineQueryResultType = "photo"
InlineQueryResultTypeSticker InlineQueryResultType = "sticker"
InlineQueryResultTypeVideo InlineQueryResultType = "video"
InlineQueryResultTypeVoice InlineQueryResultType = "voice"
InlineQueryResultTypeArticle InlineQueryResultType = "article"
InlineQueryResultTypeContact InlineQueryResultType = "contact"
InlineQueryResultTypeGame InlineQueryResultType = "game"
InlineQueryResultTypeLocation InlineQueryResultType = "location"
InlineQueryResultTypeVenue InlineQueryResultType = "venue"
)
type InputMediaType string
const (
InputMediaTypeAnimation InputMediaType = "animation"
InputMediaTypeAudio InputMediaType = "audio"
InputMediaTypeDocument InputMediaType = "document"
InputMediaTypeLivePhoto InputMediaType = "live_photo"
InputMediaTypePhoto InputMediaType = "photo"
InputMediaTypeVideo InputMediaType = "video"
)
type InputPaidMediaType string
const (
InputPaidMediaTypeLivePhoto InputPaidMediaType = "live_photo"
InputPaidMediaTypePhoto InputPaidMediaType = "photo"
InputPaidMediaTypeVideo InputPaidMediaType = "video"
)
type InputPollMediaType string
const (
InputPollMediaTypeAnimation InputPollMediaType = "animation"
InputPollMediaTypeAudio InputPollMediaType = "audio"
InputPollMediaTypeDocument InputPollMediaType = "document"
InputPollMediaTypeLivePhoto InputPollMediaType = "live_photo"
InputPollMediaTypeLocation InputPollMediaType = "location"
InputPollMediaTypePhoto InputPollMediaType = "photo"
InputPollMediaTypeVenue InputPollMediaType = "venue"
InputPollMediaTypeVideo InputPollMediaType = "video"
)
type InputPollOptionMediaType string
const (
InputPollOptionMediaTypeAnimation InputPollOptionMediaType = "animation"
InputPollOptionMediaTypeLivePhoto InputPollOptionMediaType = "live_photo"
InputPollOptionMediaTypeLocation InputPollOptionMediaType = "location"
InputPollOptionMediaTypePhoto InputPollOptionMediaType = "photo"
InputPollOptionMediaTypeSticker InputPollOptionMediaType = "sticker"
InputPollOptionMediaTypeVenue InputPollOptionMediaType = "venue"
InputPollOptionMediaTypeVideo InputPollOptionMediaType = "video"
)
type InputProfilePhotoType string
const (
InputProfilePhotoTypeStatic InputProfilePhotoType = "static"
InputProfilePhotoTypeAnimated InputProfilePhotoType = "animated"
)
type InputStickerFormat string
const (
InputStickerFormatStatic InputStickerFormat = "static"
InputStickerFormatAnimated InputStickerFormat = "animated"
InputStickerFormatVideo InputStickerFormat = "video"
)
type InputStoryContentType string
const (
InputStoryContentTypePhoto InputStoryContentType = "photo"
InputStoryContentTypeVideo InputStoryContentType = "video"
)
type KeyboardButtonStyle string
const (
KeyboardButtonStyleDanger KeyboardButtonStyle = "danger"
KeyboardButtonStyleSuccess KeyboardButtonStyle = "success"
KeyboardButtonStylePrimary KeyboardButtonStyle = "primary"
)
type MaskPositionPoint string
const (
MaskPositionPointForehead MaskPositionPoint = "forehead"
MaskPositionPointEyes MaskPositionPoint = "eyes"
MaskPositionPointMouth MaskPositionPoint = "mouth"
MaskPositionPointChin MaskPositionPoint = "chin"
)
type MenuButtonType string
const (
MenuButtonTypeCommands MenuButtonType = "commands"
MenuButtonTypeWebApp MenuButtonType = "web_app"
MenuButtonTypeDefault MenuButtonType = "default"
)
// 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"
MessageEntityTypeMention MessageEntityType = "mention"
MessageEntityTypeHashtag MessageEntityType = "hashtag"
MessageEntityTypeCashtag MessageEntityType = "cashtag"
MessageEntityTypeBotCommand MessageEntityType = "bot_command"
MessageEntityTypeURL MessageEntityType = "url"
MessageEntityTypeEmail MessageEntityType = "email"
MessageEntityTypePhoneNumber MessageEntityType = "phone_number"
MessageEntityTypeBold MessageEntityType = "bold"
MessageEntityTypeItalic MessageEntityType = "italic"
MessageEntityTypeUnderline MessageEntityType = "underline"
MessageEntityTypeStrikethrough MessageEntityType = "strikethrough"
MessageEntityTypeSpoiler MessageEntityType = "spoiler"
MessageEntityTypeBlockquote MessageEntityType = "blockquote"
MessageEntityTypeExpandableBlockquote MessageEntityType = "expandable_blockquote"
MessageEntityTypeCode MessageEntityType = "code"
MessageEntityTypePre MessageEntityType = "pre"
MessageEntityTypeTextLink MessageEntityType = "text_link"
MessageEntityTypeTextMention MessageEntityType = "text_mention"
MessageEntityTypeCustomEmoji MessageEntityType = "custom_emoji"
MessageEntityTypeDateTime MessageEntityType = "date_time"
)
type MessageOriginType string
const (
MessageOriginTypeUser MessageOriginType = "user"
MessageOriginTypeHiddenUser MessageOriginType = "hidden_user"
MessageOriginTypeChat MessageOriginType = "chat"
MessageOriginTypeChannel MessageOriginType = "channel"
)
type OwnedGiftType string
const (
OwnedGiftTypeRegular OwnedGiftType = "regular"
OwnedGiftTypeUnique OwnedGiftType = "unique"
)
type PaidMediaType string
const (
PaidMediaTypeLivePhoto PaidMediaType = "live_photo"
PaidMediaTypePhoto PaidMediaType = "photo"
PaidMediaTypePreview PaidMediaType = "preview"
PaidMediaTypeVideo PaidMediaType = "video"
)
type ParseMode string
const (
ParseModeMarkdown ParseMode = "Markdown"
ParseModeMarkdownV2 ParseMode = "MarkdownV2"
ParseModeHTML ParseMode = "HTML"
)
type PassportElementErrorDataFieldType string
const (
PassportElementErrorDataFieldTypePersonalDetails PassportElementErrorDataFieldType = "personal_details"
PassportElementErrorDataFieldTypePassport PassportElementErrorDataFieldType = "passport"
PassportElementErrorDataFieldTypeDriverLicense PassportElementErrorDataFieldType = "driver_license"
PassportElementErrorDataFieldTypeIdentityCard PassportElementErrorDataFieldType = "identity_card"
PassportElementErrorDataFieldTypeInternalPassport PassportElementErrorDataFieldType = "internal_passport"
PassportElementErrorDataFieldTypeAddress PassportElementErrorDataFieldType = "address"
)
type PassportElementErrorFileType string
const (
PassportElementErrorFileTypeUtilityBill PassportElementErrorFileType = "utility_bill"
PassportElementErrorFileTypeBankStatement PassportElementErrorFileType = "bank_statement"
PassportElementErrorFileTypeRentalAgreement PassportElementErrorFileType = "rental_agreement"
PassportElementErrorFileTypePassportRegistration PassportElementErrorFileType = "passport_registration"
PassportElementErrorFileTypeTemporaryRegistration PassportElementErrorFileType = "temporary_registration"
)
type PassportElementErrorReverseSideType string
const (
PassportElementErrorReverseSideTypeDriverLicense PassportElementErrorReverseSideType = "driver_license"
PassportElementErrorReverseSideTypeIdentityCard PassportElementErrorReverseSideType = "identity_card"
)
type PassportElementErrorSelfieType string
const (
PassportElementErrorSelfieTypePassport PassportElementErrorSelfieType = "passport"
PassportElementErrorSelfieTypeDriverLicense PassportElementErrorSelfieType = "driver_license"
PassportElementErrorSelfieTypeIdentityCard PassportElementErrorSelfieType = "identity_card"
PassportElementErrorSelfieTypeInternalPassport PassportElementErrorSelfieType = "internal_passport"
)
type PassportElementErrorSource string
const (
PassportElementErrorSourceData PassportElementErrorSource = "data"
PassportElementErrorSourceFrontSide PassportElementErrorSource = "front_side"
PassportElementErrorSourceReverseSide PassportElementErrorSource = "reverse_side"
PassportElementErrorSourceSelfie PassportElementErrorSource = "selfie"
PassportElementErrorSourceFile PassportElementErrorSource = "file"
PassportElementErrorSourceFiles PassportElementErrorSource = "files"
PassportElementErrorSourceTranslationFile PassportElementErrorSource = "translation_file"
PassportElementErrorSourceTranslationFiles PassportElementErrorSource = "translation_files"
PassportElementErrorSourceUnspecified PassportElementErrorSource = "unspecified"
)
type PassportElementErrorTranslationFileType string
const (
PassportElementErrorTranslationFileTypePassport PassportElementErrorTranslationFileType = "passport"
PassportElementErrorTranslationFileTypeDriverLicense PassportElementErrorTranslationFileType = "driver_license"
PassportElementErrorTranslationFileTypeIdentityCard PassportElementErrorTranslationFileType = "identity_card"
PassportElementErrorTranslationFileTypeInternalPassport PassportElementErrorTranslationFileType = "internal_passport"
PassportElementErrorTranslationFileTypeUtilityBill PassportElementErrorTranslationFileType = "utility_bill"
PassportElementErrorTranslationFileTypeBankStatement PassportElementErrorTranslationFileType = "bank_statement"
PassportElementErrorTranslationFileTypeRentalAgreement PassportElementErrorTranslationFileType = "rental_agreement"
PassportElementErrorTranslationFileTypePassportRegistration PassportElementErrorTranslationFileType = "passport_registration"
PassportElementErrorTranslationFileTypeTemporaryRegistration PassportElementErrorTranslationFileType = "temporary_registration"
)
type PollType string
const (
PollTypeRegular PollType = "regular"
PollTypeQuiz PollType = "quiz"
)
type ReactionTypeKind string
const (
ReactionTypeKindEmoji ReactionTypeKind = "emoji"
ReactionTypeKindCustomEmoji ReactionTypeKind = "custom_emoji"
ReactionTypeKindPaid ReactionTypeKind = "paid"
)
type RefundedPaymentCurrency string
const (
RefundedPaymentCurrencyXTR RefundedPaymentCurrency = "XTR"
)
type RevenueWithdrawalStateKind string
const (
RevenueWithdrawalStateKindPending RevenueWithdrawalStateKind = "pending"
RevenueWithdrawalStateKindSucceeded RevenueWithdrawalStateKind = "succeeded"
RevenueWithdrawalStateKindFailed RevenueWithdrawalStateKind = "failed"
)
type StickerType string
const (
StickerTypeRegular StickerType = "regular"
StickerTypeMask StickerType = "mask"
StickerTypeCustomEmoji StickerType = "custom_emoji"
)
type StoryAreaTypeKind string
const (
StoryAreaTypeKindLocation StoryAreaTypeKind = "location"
StoryAreaTypeKindSuggestedReaction StoryAreaTypeKind = "suggested_reaction"
StoryAreaTypeKindLink StoryAreaTypeKind = "link"
StoryAreaTypeKindWeather StoryAreaTypeKind = "weather"
StoryAreaTypeKindUniqueGift StoryAreaTypeKind = "unique_gift"
)
type SuggestedPostInfoState string
const (
SuggestedPostInfoStatePending SuggestedPostInfoState = "pending"
SuggestedPostInfoStateApproved SuggestedPostInfoState = "approved"
SuggestedPostInfoStateDeclined SuggestedPostInfoState = "declined"
)
type SuggestedPostPaidCurrency string
const (
SuggestedPostPaidCurrencyXTR SuggestedPostPaidCurrency = "XTR"
SuggestedPostPaidCurrencyTON SuggestedPostPaidCurrency = "TON"
)
type SuggestedPostRefundedReason string
const (
SuggestedPostRefundedReasonPostDeleted SuggestedPostRefundedReason = "post_deleted"
SuggestedPostRefundedReasonPaymentRefunded SuggestedPostRefundedReason = "payment_refunded"
)
type TransactionPartnerType string
const (
TransactionPartnerTypeUser TransactionPartnerType = "user"
TransactionPartnerTypeChat TransactionPartnerType = "chat"
TransactionPartnerTypeAffiliateProgram TransactionPartnerType = "affiliate_program"
TransactionPartnerTypeFragment TransactionPartnerType = "fragment"
TransactionPartnerTypeTelegramAds TransactionPartnerType = "telegram_ads"
TransactionPartnerTypeTelegramApi TransactionPartnerType = "telegram_api"
TransactionPartnerTypeOther TransactionPartnerType = "other"
)
type TransactionPartnerUserTransactionType string
const (
TransactionPartnerUserTransactionTypeInvoicePayment TransactionPartnerUserTransactionType = "invoice_payment"
TransactionPartnerUserTransactionTypePaidMediaPayment TransactionPartnerUserTransactionType = "paid_media_payment"
TransactionPartnerUserTransactionTypeGiftPurchase TransactionPartnerUserTransactionType = "gift_purchase"
TransactionPartnerUserTransactionTypePremiumPurchase TransactionPartnerUserTransactionType = "premium_purchase"
TransactionPartnerUserTransactionTypeBusinessAccountTransfer TransactionPartnerUserTransactionType = "business_account_transfer"
)
type UniqueGiftInfoOrigin string
const (
UniqueGiftInfoOriginUpgrade UniqueGiftInfoOrigin = "upgrade"
UniqueGiftInfoOriginTransfer UniqueGiftInfoOrigin = "transfer"
UniqueGiftInfoOriginResale UniqueGiftInfoOrigin = "resale"
UniqueGiftInfoOriginGiftedUpgrade UniqueGiftInfoOrigin = "gifted_upgrade"
UniqueGiftInfoOriginOffer UniqueGiftInfoOrigin = "offer"
)
type UniqueGiftModelRarity string
const (
UniqueGiftModelRarityUncommon UniqueGiftModelRarity = "uncommon"
UniqueGiftModelRarityRare UniqueGiftModelRarity = "rare"
UniqueGiftModelRarityEpic UniqueGiftModelRarity = "epic"
UniqueGiftModelRarityLegendary UniqueGiftModelRarity = "legendary"
)
+138
View File
@@ -0,0 +1,138 @@
package api
// UpdateType identifies an Update payload variant. Used by allowed_updates
// in getUpdates / setWebhook. The Telegram docs do not enumerate these
// values inline (they are derived from the optional fields of Update),
// so the codegen pipeline cannot synthesise this enum and it lives here
// as a hand-curated companion to the generated enums.gen.go.
type UpdateType string
const (
UpdateMessage UpdateType = "message"
UpdateEditedMessage UpdateType = "edited_message"
UpdateChannelPost UpdateType = "channel_post"
UpdateEditedChannelPost UpdateType = "edited_channel_post"
UpdateBusinessConnection UpdateType = "business_connection"
UpdateBusinessMessage UpdateType = "business_message"
UpdateEditedBusinessMessage UpdateType = "edited_business_message"
UpdateDeletedBusinessMessages UpdateType = "deleted_business_messages"
UpdateMessageReaction UpdateType = "message_reaction"
UpdateMessageReactionCount UpdateType = "message_reaction_count"
UpdateInlineQuery UpdateType = "inline_query"
UpdateChosenInlineResult UpdateType = "chosen_inline_result"
UpdateCallbackQuery UpdateType = "callback_query"
UpdateShippingQuery UpdateType = "shipping_query"
UpdatePreCheckoutQuery UpdateType = "pre_checkout_query"
UpdatePurchasedPaidMedia UpdateType = "purchased_paid_media"
UpdatePoll UpdateType = "poll"
UpdatePollAnswer UpdateType = "poll_answer"
UpdateMyChatMember UpdateType = "my_chat_member"
UpdateChatMember UpdateType = "chat_member"
UpdateChatJoinRequest UpdateType = "chat_join_request"
UpdateChatBoost UpdateType = "chat_boost"
UpdateRemovedChatBoost UpdateType = "removed_chat_boost"
)
// DiceEmoji is the set of emoji values accepted by sendDice. Telegram's
// canonical list is "🎲", "🎯", "🏀", "⚽", "🎳", "🎰". The codegen
// scraper drops these values during regex extraction (multi-byte
// boundary issues with curly-quoted emoji), so this enum is hand-
// curated and wired into SendDiceParams.Emoji via the per-field type
// override in cmd/genapi/emitter.go.
type DiceEmoji string
const (
DiceEmojiDice DiceEmoji = "🎲"
DiceEmojiDart DiceEmoji = "🎯"
DiceEmojiBasketball DiceEmoji = "🏀"
DiceEmojiFootball DiceEmoji = "⚽"
DiceEmojiBowling DiceEmoji = "🎳"
DiceEmojiSlotMachine DiceEmoji = "🎰"
)
// ReactionEmoji is the set of emoji Telegram allows in a
// ReactionTypeEmoji.Emoji value. Hand-curated from
// https://core.telegram.org/bots/api#reactiontypeemoji because the
// scraper's curly-quote regex strips the emoji literals (byte-boundary
// issue on multi-byte sequences). Names mirror the Unicode CLDR short
// name where one exists; otherwise a stable common-English label.
// Telegram occasionally extends this set — passers of unrecognised
// strings still type-check (ReactionEmoji is a string alias) so this
// list need not block runtime use of newer values.
type ReactionEmoji string
const (
ReactionEmojiHeart ReactionEmoji = "❤"
ReactionEmojiThumbsUp ReactionEmoji = "👍"
ReactionEmojiThumbsDown ReactionEmoji = "👎"
ReactionEmojiFire ReactionEmoji = "🔥"
ReactionEmojiSmilingFaceWithHearts ReactionEmoji = "🥰"
ReactionEmojiClappingHands ReactionEmoji = "👏"
ReactionEmojiBeamingFace ReactionEmoji = "😁"
ReactionEmojiThinkingFace ReactionEmoji = "🤔"
ReactionEmojiExplodingHead ReactionEmoji = "🤯"
ReactionEmojiScreamingFace ReactionEmoji = "😱"
ReactionEmojiCursingFace ReactionEmoji = "🤬"
ReactionEmojiCryingFace ReactionEmoji = "😢"
ReactionEmojiPartyPopper ReactionEmoji = "🎉"
ReactionEmojiStarStruck ReactionEmoji = "🤩"
ReactionEmojiVomiting ReactionEmoji = "🤮"
ReactionEmojiPileOfPoo ReactionEmoji = "💩"
ReactionEmojiFoldedHands ReactionEmoji = "🙏"
ReactionEmojiOKHand ReactionEmoji = "👌"
ReactionEmojiDove ReactionEmoji = "🕊"
ReactionEmojiClown ReactionEmoji = "🤡"
ReactionEmojiYawning ReactionEmoji = "🥱"
ReactionEmojiWoozyFace ReactionEmoji = "🥴"
ReactionEmojiHeartEyes ReactionEmoji = "😍"
ReactionEmojiWhale ReactionEmoji = "🐳"
ReactionEmojiHeartOnFire ReactionEmoji = "❤‍🔥"
ReactionEmojiNewMoonFace ReactionEmoji = "🌚"
ReactionEmojiHotDog ReactionEmoji = "🌭"
ReactionEmojiHundredPoints ReactionEmoji = "💯"
ReactionEmojiRollingOnFloor ReactionEmoji = "🤣"
ReactionEmojiLightning ReactionEmoji = "⚡"
ReactionEmojiBanana ReactionEmoji = "🍌"
ReactionEmojiTrophy ReactionEmoji = "🏆"
ReactionEmojiBrokenHeart ReactionEmoji = "💔"
ReactionEmojiRaisedEyebrow ReactionEmoji = "🤨"
ReactionEmojiNeutralFace ReactionEmoji = "😐"
ReactionEmojiStrawberry ReactionEmoji = "🍓"
ReactionEmojiChampagne ReactionEmoji = "🍾"
ReactionEmojiKissMark ReactionEmoji = "💋"
ReactionEmojiMiddleFinger ReactionEmoji = "🖕"
ReactionEmojiDevil ReactionEmoji = "😈"
ReactionEmojiSleeping ReactionEmoji = "😴"
ReactionEmojiLoudlyCrying ReactionEmoji = "😭"
ReactionEmojiNerd ReactionEmoji = "🤓"
ReactionEmojiGhost ReactionEmoji = "👻"
ReactionEmojiManTechnologist ReactionEmoji = "👨‍💻"
ReactionEmojiEyes ReactionEmoji = "👀"
ReactionEmojiJackOLantern ReactionEmoji = "🎃"
ReactionEmojiSeeNoEvil ReactionEmoji = "🙈"
ReactionEmojiHalo ReactionEmoji = "😇"
ReactionEmojiFearful ReactionEmoji = "😨"
ReactionEmojiHandshake ReactionEmoji = "🤝"
ReactionEmojiWriting ReactionEmoji = "✍"
ReactionEmojiHugging ReactionEmoji = "🤗"
ReactionEmojiSaluting ReactionEmoji = "🫡"
ReactionEmojiSantaClaus ReactionEmoji = "🎅"
ReactionEmojiChristmasTree ReactionEmoji = "🎄"
ReactionEmojiSnowman ReactionEmoji = "☃"
ReactionEmojiNailPolish ReactionEmoji = "💅"
ReactionEmojiZanyFace ReactionEmoji = "🤪"
ReactionEmojiMoai ReactionEmoji = "🗿"
ReactionEmojiCool ReactionEmoji = "🆒"
ReactionEmojiHeartWithArrow ReactionEmoji = "💘"
ReactionEmojiHearNoEvil ReactionEmoji = "🙉"
ReactionEmojiUnicorn ReactionEmoji = "🦄"
ReactionEmojiKissingFace ReactionEmoji = "😘"
ReactionEmojiPill ReactionEmoji = "💊"
ReactionEmojiSpeakNoEvil ReactionEmoji = "🙊"
ReactionEmojiSmilingFaceWithSunglasses ReactionEmoji = "😎"
ReactionEmojiAlienMonster ReactionEmoji = "👾"
ReactionEmojiManShrugging ReactionEmoji = "🤷‍♂"
ReactionEmojiPersonShrugging ReactionEmoji = "🤷"
ReactionEmojiWomanShrugging ReactionEmoji = "🤷‍♀"
ReactionEmojiPoutingFace ReactionEmoji = "😡"
)
+55
View File
@@ -0,0 +1,55 @@
package api
import (
"context"
"testing"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// TestGetChatAdministrators_DecodesUnionSlice is a regression test for the
// bug where GetChatAdministrators was emitted with the generic client.Call
// against []ChatMember — encoding/json cannot unmarshal a slice of an
// interface, so the call always failed at the parse step.
//
// The fix makes the codegen emit CallRaw + per-element UnmarshalChatMember
// for any method returning []<sealed-interface union>.
func TestGetChatAdministrators_DecodesUnionSlice(t *testing.T) {
body := `{"ok":true,"result":[
{"status":"creator","user":{"id":1,"is_bot":false,"first_name":"Owner"},"is_anonymous":false},
{"status":"administrator","user":{"id":2,"is_bot":false,"first_name":"Admin"},"can_be_edited":false,"is_anonymous":false,"can_manage_chat":true,"can_delete_messages":true,"can_manage_video_chats":false,"can_restrict_members":true,"can_promote_members":false,"can_change_info":true,"can_invite_users":true,"can_post_stories":false,"can_edit_stories":false,"can_delete_stories":false}
]}`
m := &mockDoer{}
m.On("Do", mock.Anything).Return(newJSONResp(200, body), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
admins, err := GetChatAdministrators(context.Background(), bot,
&GetChatAdministratorsParams{ChatID: ChatIDFromInt(-100123)})
require.NoError(t, err)
require.Len(t, admins, 2)
owner, ok := admins[0].(*ChatMemberOwner)
require.True(t, ok, "first element must dispatch to ChatMemberOwner, got %T", admins[0])
require.Equal(t, int64(1), owner.User.ID)
admin, ok := admins[1].(*ChatMemberAdministrator)
require.True(t, ok, "second element must dispatch to ChatMemberAdministrator, got %T", admins[1])
require.True(t, admin.CanManageChat)
require.False(t, admin.CanPromoteMembers)
}
// TestGetChatAdministrators_EmptyArray covers the zero-admin edge case
// (a basic group with no admins, or the bot itself filtered out).
func TestGetChatAdministrators_EmptyArray(t *testing.T) {
m := &mockDoer{}
m.On("Do", mock.Anything).Return(newJSONResp(200, `{"ok":true,"result":[]}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
admins, err := GetChatAdministrators(context.Background(), bot,
&GetChatAdministratorsParams{ChatID: ChatIDFromInt(-100123)})
require.NoError(t, err)
require.Empty(t, admins)
}
+103
View File
@@ -0,0 +1,103 @@
package api
import (
"testing"
json "github.com/goccy/go-json"
"github.com/stretchr/testify/require"
)
// TestMarshalJSON_TypeDiscriminator_AutoInjected verifies the generated
// MarshalJSON hardcodes the wire discriminator for a Type-keyed variant
// even when the caller leaves the field zero.
func TestMarshalJSON_TypeDiscriminator_AutoInjected(t *testing.T) {
scope := &BotCommandScopeAllPrivateChats{}
got, err := json.Marshal(scope)
require.NoError(t, err)
require.JSONEq(t, `{"type":"all_private_chats"}`, string(got))
}
// TestMarshalJSON_SourceDiscriminator_AutoInjected verifies the same
// for variants that use a non-Type discriminator field. PassportElement
// errors key on "source" instead.
func TestMarshalJSON_SourceDiscriminator_AutoInjected(t *testing.T) {
err := &PassportElementErrorDataField{
Type: PassportElementErrorDataFieldTypePersonalDetails,
FieldName: "first_name",
DataHash: "abc123",
Message: "bad data",
}
got, mErr := json.Marshal(err)
require.NoError(t, mErr)
require.JSONEq(t,
`{"source":"data","type":"personal_details","field_name":"first_name","data_hash":"abc123","message":"bad data"}`,
string(got),
)
}
// TestMarshalJSON_UserSuppliedDiscriminator_Overridden documents the
// safety guarantee: a typo or stale value the caller pastes into the
// struct literal is silently overridden by the generated MarshalJSON.
// This is what saves callers from Telegram's "silent reject" failure
// mode when a discriminator is wrong.
func TestMarshalJSON_UserSuppliedDiscriminator_Overridden(t *testing.T) {
scope := &BotCommandScopeAllPrivateChats{Type: "wrong"}
got, err := json.Marshal(scope)
require.NoError(t, err)
require.JSONEq(t, `{"type":"all_private_chats"}`, string(got))
}
// TestMarshalJSON_RoundTrip confirms a marshal-then-unmarshal cycle
// preserves user-supplied fields. Discriminator field is set on the
// way out, read back on the way in — no data loss.
//
// Uses ChatMember (one of the auto-decode unions) so the round-trip
// can route through the generated UnmarshalChatMember dispatcher.
func TestMarshalJSON_RoundTrip(t *testing.T) {
orig := &ChatMemberLeft{
User: User{ID: 42, IsBot: false, FirstName: "alice"},
}
raw, err := json.Marshal(orig)
require.NoError(t, err)
out, err := UnmarshalChatMember(raw)
require.NoError(t, err)
round, ok := out.(*ChatMemberLeft)
require.True(t, ok, "expected *ChatMemberLeft, got %T", out)
require.Equal(t, ChatMemberStatusLeft, round.Status)
require.Equal(t, orig.User.ID, round.User.ID)
require.Equal(t, orig.User.FirstName, round.User.FirstName)
}
// TestMarshalJSON_InputMessageContent_NoDiscriminator confirms that
// variants of InputMessageContent (the structurally-dispatched union
// Telegram identifies by field presence, not by a "type" field) do
// NOT get an injected discriminator. Their fields ride out as-is.
func TestMarshalJSON_InputMessageContent_NoDiscriminator(t *testing.T) {
content := &InputTextMessageContent{
MessageText: "hello world",
}
got, err := json.Marshal(content)
require.NoError(t, err)
// No "type" field should appear; just message_text.
require.JSONEq(t, `{"message_text":"hello world"}`, string(got))
}
// TestMarshalJSON_NonDiscriminatorMembers_RidealongUnchanged verifies
// the alias-embedding pattern: every non-discriminator field on the
// variant marshals through the *alias and keeps its own json tag and
// omitempty behaviour. Caption + ParseMode here exercise both
// required-string-with-discriminator and optional-with-omitempty.
func TestMarshalJSON_NonDiscriminatorMembers_RidealongUnchanged(t *testing.T) {
media := &InputMediaPhoto{
Media: "https://example.com/photo.jpg",
Caption: "look",
}
got, err := json.Marshal(media)
require.NoError(t, err)
require.JSONEq(t,
`{"type":"photo","media":"https://example.com/photo.jpg","caption":"look"}`,
string(got),
)
}
+55 -39
View File
@@ -198,7 +198,7 @@ type SendMessageParams struct {
// Text of the message to be sent, 1-4096 characters after entities parsing
Text string `json:"text"`
// Mode for parsing entities in the message text. See formatting options for more details.
ParseMode string `json:"parse_mode,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in message text, which can be specified instead of parse_mode
Entities []MessageEntity `json:"entities,omitempty"`
// Link preview generation options for the message
@@ -305,7 +305,7 @@ type CopyMessageParams struct {
// New caption for media, 0-1024 characters after entities parsing. If not specified, the original caption is kept
Caption string `json:"caption,omitempty"`
// Mode for parsing entities in the new caption. See formatting options for more details.
ParseMode string `json:"parse_mode,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in the new caption, which can be specified instead of parse_mode
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
// Pass True, if the caption must be shown above the message media. Ignored if a new caption isn't specified.
@@ -379,7 +379,7 @@ type SendPhotoParams struct {
// Photo caption (may also be used when resending photos by file_id), 0-1024 characters after entities parsing
Caption string `json:"caption,omitempty"`
// Mode for parsing entities in the photo caption. See formatting options for more details.
ParseMode string `json:"parse_mode,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in the caption, which can be specified instead of parse_mode
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
// Pass True, if the caption must be shown above the message media
@@ -427,7 +427,7 @@ func (p *SendPhotoParams) MultipartFields() map[string]string {
out["caption"] = p.Caption
}
if p.ParseMode != "" {
out["parse_mode"] = p.ParseMode
out["parse_mode"] = string(p.ParseMode)
}
if p.CaptionEntities != nil {
if b, _ := json.Marshal(p.CaptionEntities); len(b) > 0 {
@@ -509,7 +509,7 @@ type SendLivePhotoParams struct {
// Video caption (may also be used when resending videos by file_id), 0-1024 characters after entities parsing
Caption string `json:"caption,omitempty"`
// Mode for parsing entities in the video caption. See formatting options for more details.
ParseMode string `json:"parse_mode,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in the caption, which can be specified instead of parse_mode
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
// Pass True, if the caption must be shown above the message media
@@ -560,7 +560,7 @@ func (p *SendLivePhotoParams) MultipartFields() map[string]string {
out["caption"] = p.Caption
}
if p.ParseMode != "" {
out["parse_mode"] = p.ParseMode
out["parse_mode"] = string(p.ParseMode)
}
if p.CaptionEntities != nil {
if b, _ := json.Marshal(p.CaptionEntities); len(b) > 0 {
@@ -648,7 +648,7 @@ type SendAudioParams struct {
// Audio caption, 0-1024 characters after entities parsing
Caption string `json:"caption,omitempty"`
// Mode for parsing entities in the audio caption. See formatting options for more details.
ParseMode string `json:"parse_mode,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in the caption, which can be specified instead of parse_mode
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
// Duration of the audio in seconds
@@ -703,7 +703,7 @@ func (p *SendAudioParams) MultipartFields() map[string]string {
out["caption"] = p.Caption
}
if p.ParseMode != "" {
out["parse_mode"] = p.ParseMode
out["parse_mode"] = string(p.ParseMode)
}
if p.CaptionEntities != nil {
if b, _ := json.Marshal(p.CaptionEntities); len(b) > 0 {
@@ -796,7 +796,7 @@ type SendDocumentParams struct {
// Document caption (may also be used when resending documents by file_id), 0-1024 characters after entities parsing
Caption string `json:"caption,omitempty"`
// Mode for parsing entities in the document caption. See formatting options for more details.
ParseMode string `json:"parse_mode,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in the caption, which can be specified instead of parse_mode
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
// Disables automatic server-side content type detection for files uploaded using multipart/form-data
@@ -845,7 +845,7 @@ func (p *SendDocumentParams) MultipartFields() map[string]string {
out["caption"] = p.Caption
}
if p.ParseMode != "" {
out["parse_mode"] = p.ParseMode
out["parse_mode"] = string(p.ParseMode)
}
if p.CaptionEntities != nil {
if b, _ := json.Marshal(p.CaptionEntities); len(b) > 0 {
@@ -941,7 +941,7 @@ type SendVideoParams struct {
// Video caption (may also be used when resending videos by file_id), 0-1024 characters after entities parsing
Caption string `json:"caption,omitempty"`
// Mode for parsing entities in the video caption. See formatting options for more details.
ParseMode string `json:"parse_mode,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in the caption, which can be specified instead of parse_mode
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
// Pass True, if the caption must be shown above the message media
@@ -1009,7 +1009,7 @@ func (p *SendVideoParams) MultipartFields() map[string]string {
out["caption"] = p.Caption
}
if p.ParseMode != "" {
out["parse_mode"] = p.ParseMode
out["parse_mode"] = string(p.ParseMode)
}
if p.CaptionEntities != nil {
if b, _ := json.Marshal(p.CaptionEntities); len(b) > 0 {
@@ -1114,7 +1114,7 @@ type SendAnimationParams struct {
// Animation caption (may also be used when resending animation by file_id), 0-1024 characters after entities parsing
Caption string `json:"caption,omitempty"`
// Mode for parsing entities in the animation caption. See formatting options for more details.
ParseMode string `json:"parse_mode,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in the caption, which can be specified instead of parse_mode
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
// Pass True, if the caption must be shown above the message media
@@ -1174,7 +1174,7 @@ func (p *SendAnimationParams) MultipartFields() map[string]string {
out["caption"] = p.Caption
}
if p.ParseMode != "" {
out["parse_mode"] = p.ParseMode
out["parse_mode"] = string(p.ParseMode)
}
if p.CaptionEntities != nil {
if b, _ := json.Marshal(p.CaptionEntities); len(b) > 0 {
@@ -1261,7 +1261,7 @@ type SendVoiceParams struct {
// Voice message caption, 0-1024 characters after entities parsing
Caption string `json:"caption,omitempty"`
// Mode for parsing entities in the voice message caption. See formatting options for more details.
ParseMode string `json:"parse_mode,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in the caption, which can be specified instead of parse_mode
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
// Duration of the voice message in seconds
@@ -1307,7 +1307,7 @@ func (p *SendVoiceParams) MultipartFields() map[string]string {
out["caption"] = p.Caption
}
if p.ParseMode != "" {
out["parse_mode"] = p.ParseMode
out["parse_mode"] = string(p.ParseMode)
}
if p.CaptionEntities != nil {
if b, _ := json.Marshal(p.CaptionEntities); len(b) > 0 {
@@ -1511,7 +1511,7 @@ type SendPaidMediaParams struct {
// Media caption, 0-1024 characters after entities parsing
Caption string `json:"caption,omitempty"`
// Mode for parsing entities in the media caption. See formatting options for more details.
ParseMode string `json:"parse_mode,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in the caption, which can be specified instead of parse_mode
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
// Pass True, if the caption must be shown above the message media
@@ -1559,7 +1559,7 @@ func (p *SendPaidMediaParams) MultipartFields() map[string]string {
out["caption"] = p.Caption
}
if p.ParseMode != "" {
out["parse_mode"] = p.ParseMode
out["parse_mode"] = string(p.ParseMode)
}
if p.CaptionEntities != nil {
if b, _ := json.Marshal(p.CaptionEntities); len(b) > 0 {
@@ -1843,7 +1843,7 @@ type SendPollParams struct {
// Poll question, 1-300 characters
Question string `json:"question"`
// Mode for parsing entities in the question. See formatting options for more details. Currently, only custom emoji entities are allowed
QuestionParseMode string `json:"question_parse_mode,omitempty"`
QuestionParseMode ParseMode `json:"question_parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in the poll question. It can be specified instead of question_parse_mode
QuestionEntities []MessageEntity `json:"question_entities,omitempty"`
// A JSON-serialized list of 1-12 answer options
@@ -1871,11 +1871,11 @@ type SendPollParams struct {
// Text that is shown when a user chooses an incorrect answer or taps on the lamp icon in a quiz-style poll, 0-200 characters with at most 2 line feeds after entities parsing
Explanation string `json:"explanation,omitempty"`
// Mode for parsing entities in the explanation. See formatting options for more details.
ExplanationParseMode string `json:"explanation_parse_mode,omitempty"`
ExplanationParseMode ParseMode `json:"explanation_parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in the poll explanation. It can be specified instead of explanation_parse_mode
ExplanationEntities []MessageEntity `json:"explanation_entities,omitempty"`
// Media added to the quiz explanation
ExplanationMedia *InputPollMedia `json:"explanation_media,omitempty"`
ExplanationMedia InputPollMedia `json:"explanation_media,omitempty"`
// Amount of time in seconds the poll will be active after creation, 5-2628000. Can't be used together with close_date.
OpenPeriod *int64 `json:"open_period,omitempty"`
// Point in time (Unix timestamp) when the poll will be automatically closed. Must be at least 5 and no more than 2628000 seconds in the future. Can't be used together with open_period.
@@ -1885,11 +1885,11 @@ type SendPollParams struct {
// Description of the poll to be sent, 0-1024 characters after entities parsing
Description string `json:"description,omitempty"`
// Mode for parsing entities in the poll description. See formatting options for more details.
DescriptionParseMode string `json:"description_parse_mode,omitempty"`
DescriptionParseMode ParseMode `json:"description_parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in the poll description, which can be specified instead of description_parse_mode
DescriptionEntities []MessageEntity `json:"description_entities,omitempty"`
// Media added to the poll description
Media *InputPollMedia `json:"media,omitempty"`
Media InputPollMedia `json:"media,omitempty"`
// Sends the message silently. Users will receive a notification with no sound.
DisableNotification *bool `json:"disable_notification,omitempty"`
// Protects the contents of the sent message from forwarding and saving
@@ -1953,7 +1953,7 @@ type SendDiceParams struct {
// Identifier of the direct messages topic to which the message will be sent; required if the message is sent to a direct messages chat
DirectMessagesTopicID *int64 `json:"direct_messages_topic_id,omitempty"`
// Emoji on which the dice throw animation is based. Currently, must be one of “”, “”, “”, “”, “”, or “”. Dice can have values 1-6 for “”, “” and “”, values 1-5 for “” and “”, and values 1-64 for “”. Defaults to “”
Emoji string `json:"emoji,omitempty"`
Emoji DiceEmoji `json:"emoji,omitempty"`
// Sends the message silently. Users will receive a notification with no sound.
DisableNotification *bool `json:"disable_notification,omitempty"`
// Protects the contents of the sent message from forwarding
@@ -1990,7 +1990,7 @@ type SendMessageDraftParams struct {
// Text of the message to be sent, 0-4096 characters after entities parsing. Pass an empty text to show a “Thinking…” placeholder.
Text string `json:"text,omitempty"`
// Mode for parsing entities in the message text. See formatting options for more details.
ParseMode string `json:"parse_mode,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in message text, which can be specified instead of parse_mode
Entities []MessageEntity `json:"entities,omitempty"`
}
@@ -2676,7 +2676,23 @@ type GetChatAdministratorsParams struct {
//
// Use this method to get a list of administrators in a chat. Returns an Array of ChatMember objects.
func GetChatAdministrators(ctx context.Context, b *client.Bot, p *GetChatAdministratorsParams) ([]ChatMember, error) {
return client.Call[*GetChatAdministratorsParams, []ChatMember](ctx, b, "getChatAdministrators", p)
raw, err := client.CallRaw[*GetChatAdministratorsParams](ctx, b, "getChatAdministrators", p)
if err != nil {
return nil, err
}
var elems []json.RawMessage
if err := json.Unmarshal(raw, &elems); err != nil {
return nil, err
}
out := make([]ChatMember, 0, len(elems))
for _, e := range elems {
v, err := UnmarshalChatMember(e)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, nil
}
// GetChatMemberCountParams is the parameter set for GetChatMemberCount.
@@ -3124,7 +3140,7 @@ type SetMyCommandsParams struct {
// A JSON-serialized list of bot commands to be set as the list of the bot's commands. At most 100 commands can be specified.
Commands []BotCommand `json:"commands"`
// A JSON-serialized object, describing scope of users for which the commands are relevant. Defaults to BotCommandScopeDefault.
Scope *BotCommandScope `json:"scope,omitempty"`
Scope BotCommandScope `json:"scope,omitempty"`
// A two-letter ISO 639-1 language code. If empty, commands will be applied to all users from the given scope, for whose language there are no dedicated commands
LanguageCode string `json:"language_code,omitempty"`
}
@@ -3141,7 +3157,7 @@ func SetMyCommands(ctx context.Context, b *client.Bot, p *SetMyCommandsParams) (
// Use this method to delete the list of the bot's commands for the given scope and user language. After deletion, higher level commands will be shown to affected users. Returns True on success.
type DeleteMyCommandsParams struct {
// A JSON-serialized object, describing scope of users for which the commands are relevant. Defaults to BotCommandScopeDefault.
Scope *BotCommandScope `json:"scope,omitempty"`
Scope BotCommandScope `json:"scope,omitempty"`
// A two-letter ISO 639-1 language code. If empty, commands will be applied to all users from the given scope, for whose language there are no dedicated commands
LanguageCode string `json:"language_code,omitempty"`
}
@@ -3158,7 +3174,7 @@ func DeleteMyCommands(ctx context.Context, b *client.Bot, p *DeleteMyCommandsPar
// Use this method to get the current list of the bot's commands for the given scope and user language. Returns an Array of BotCommand objects. If commands aren't set, an empty list is returned.
type GetMyCommandsParams struct {
// A JSON-serialized object, describing scope of users. Defaults to BotCommandScopeDefault.
Scope *BotCommandScope `json:"scope,omitempty"`
Scope BotCommandScope `json:"scope,omitempty"`
// A two-letter ISO 639-1 language code or an empty string
LanguageCode string `json:"language_code,omitempty"`
}
@@ -3390,7 +3406,7 @@ type SendGiftParams struct {
// Text that will be shown along with the gift; 0-128 characters
Text string `json:"text,omitempty"`
// Mode for parsing entities in the text. See formatting options for more details. Entities other than “bold”, “italic”, “underline”, “strikethrough”, “spoiler”, “custom_emoji”, and “date_time” are ignored.
TextParseMode string `json:"text_parse_mode,omitempty"`
TextParseMode ParseMode `json:"text_parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in the gift text. It can be specified instead of text_parse_mode. Entities other than “bold”, “italic”, “underline”, “strikethrough”, “spoiler”, “custom_emoji”, and “date_time” are ignored.
TextEntities []MessageEntity `json:"text_entities,omitempty"`
}
@@ -3415,7 +3431,7 @@ type GiftPremiumSubscriptionParams struct {
// Text that will be shown along with the service message about the subscription; 0-128 characters
Text string `json:"text,omitempty"`
// Mode for parsing entities in the text. See formatting options for more details. Entities other than “bold”, “italic”, “underline”, “strikethrough”, “spoiler”, “custom_emoji”, and “date_time” are ignored.
TextParseMode string `json:"text_parse_mode,omitempty"`
TextParseMode ParseMode `json:"text_parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in the gift text. It can be specified instead of text_parse_mode. Entities other than “bold”, “italic”, “underline”, “strikethrough”, “spoiler”, “custom_emoji”, and “date_time” are ignored.
TextEntities []MessageEntity `json:"text_entities,omitempty"`
}
@@ -3840,7 +3856,7 @@ type PostStoryParams struct {
// Caption of the story, 0-2048 characters after entities parsing
Caption string `json:"caption,omitempty"`
// Mode for parsing entities in the story caption. See formatting options for more details.
ParseMode string `json:"parse_mode,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in the caption, which can be specified instead of parse_mode
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
// A JSON-serialized list of clickable areas to be shown on the story
@@ -3896,7 +3912,7 @@ type EditStoryParams struct {
// Caption of the story, 0-2048 characters after entities parsing
Caption string `json:"caption,omitempty"`
// Mode for parsing entities in the story caption. See formatting options for more details.
ParseMode string `json:"parse_mode,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in the caption, which can be specified instead of parse_mode
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
// A JSON-serialized list of clickable areas to be shown on the story
@@ -4001,7 +4017,7 @@ type EditMessageTextParams struct {
// New text of the message, 1-4096 characters after entities parsing
Text string `json:"text"`
// Mode for parsing entities in the message text. See formatting options for more details.
ParseMode string `json:"parse_mode,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in message text, which can be specified instead of parse_mode
Entities []MessageEntity `json:"entities,omitempty"`
// Link preview generation options for the message
@@ -4032,7 +4048,7 @@ type EditMessageCaptionParams struct {
// New caption of the message, 0-1024 characters after entities parsing
Caption string `json:"caption,omitempty"`
// Mode for parsing entities in the message caption. See formatting options for more details.
ParseMode string `json:"parse_mode,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in the caption, which can be specified instead of parse_mode
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
// Pass True, if the caption must be shown above the message media. Supported only for animation, photo and video messages.
@@ -4492,7 +4508,7 @@ type UploadStickerFileParams struct {
// A file with the sticker in .WEBP, .PNG, .TGS, or .WEBM format. See https://core.telegram.org/stickers for technical requirements. More information on Sending Files »
Sticker *InputFile `json:"sticker"`
// Format of the sticker, must be one of “static”, “animated”, “video”
StickerFormat string `json:"sticker_format"`
StickerFormat InputStickerFormat `json:"sticker_format"`
}
// HasFile reports whether a multipart upload is required.
@@ -4507,7 +4523,7 @@ func (p *UploadStickerFileParams) HasFile() bool {
func (p *UploadStickerFileParams) MultipartFields() map[string]string {
out := map[string]string{}
out["user_id"] = strconv.FormatInt(p.UserID, 10)
out["sticker_format"] = p.StickerFormat
out["sticker_format"] = string(p.StickerFormat)
return out
}
@@ -4707,7 +4723,7 @@ type SetStickerSetThumbnailParams struct {
// A .WEBP or .PNG image with the thumbnail, must be up to 128 kilobytes in size and have a width and height of exactly 100px, or a .TGS animation with a thumbnail up to 32 kilobytes in size (see https://core.telegram.org/stickers#animation-requirements for animated sticker technical requirements), or a .WEBM video with the thumbnail up to 32 kilobytes in size; see https://core.telegram.org/stickers#video-requirements for video sticker technical requirements. Pass a file_id as a String to send a file that already exists on the Telegram servers, pass an HTTP URL as a String for Telegram to get a file from the Internet, or upload a new one using multipart/form-data. More information on Sending Files ». Animated and video sticker set thumbnails can't be uploaded via HTTP URL. If omitted, then the thumbnail is dropped and the first sticker is used as the thumbnail.
Thumbnail *InputFile `json:"thumbnail,omitempty"`
// Format of the thumbnail, must be one of “static” for a .WEBP or .PNG image, “animated” for a .TGS animation, or “video” for a .WEBM video
Format string `json:"format"`
Format InputStickerFormat `json:"format"`
}
// HasFile reports whether a multipart upload is required.
@@ -4723,7 +4739,7 @@ func (p *SetStickerSetThumbnailParams) MultipartFields() map[string]string {
out := map[string]string{}
out["name"] = p.Name
out["user_id"] = strconv.FormatInt(p.UserID, 10)
out["format"] = p.Format
out["format"] = string(p.Format)
return out
}
+14 -14
View File
@@ -20954,7 +20954,7 @@ func Test_UploadStickerFile_Success(t *testing.T) {
params := &UploadStickerFileParams{
UserID: 42,
Sticker: &InputFile{PathOrID: "file_id_test"},
StickerFormat: "test_value",
StickerFormat: InputStickerFormatStatic,
}
_, err := UploadStickerFile(context.Background(), bot, params)
require.NoError(t, err)
@@ -20969,7 +20969,7 @@ func Test_UploadStickerFile_APIError(t *testing.T) {
params := &UploadStickerFileParams{
UserID: 42,
Sticker: &InputFile{PathOrID: "file_id_test"},
StickerFormat: "test_value",
StickerFormat: InputStickerFormatStatic,
}
_, err := UploadStickerFile(context.Background(), bot, params)
require.Error(t, err)
@@ -20987,7 +20987,7 @@ func Test_UploadStickerFile_NetworkError(t *testing.T) {
params := &UploadStickerFileParams{
UserID: 42,
Sticker: &InputFile{PathOrID: "file_id_test"},
StickerFormat: "test_value",
StickerFormat: InputStickerFormatStatic,
}
_, err := UploadStickerFile(context.Background(), bot, params)
require.Error(t, err)
@@ -21003,7 +21003,7 @@ func Test_UploadStickerFile_ParseError(t *testing.T) {
params := &UploadStickerFileParams{
UserID: 42,
Sticker: &InputFile{PathOrID: "file_id_test"},
StickerFormat: "test_value",
StickerFormat: InputStickerFormatStatic,
}
_, err := UploadStickerFile(context.Background(), bot, params)
require.Error(t, err)
@@ -21022,7 +21022,7 @@ func Test_UploadStickerFile_ContextCanceled(t *testing.T) {
params := &UploadStickerFileParams{
UserID: 42,
Sticker: &InputFile{PathOrID: "file_id_test"},
StickerFormat: "test_value",
StickerFormat: InputStickerFormatStatic,
}
_, err := UploadStickerFile(ctx, bot, params)
require.Error(t, err)
@@ -21063,7 +21063,7 @@ func Test_UploadStickerFile_Forbidden(t *testing.T) {
params := &UploadStickerFileParams{
UserID: 42,
Sticker: &InputFile{PathOrID: "file_id_test"},
StickerFormat: "test_value",
StickerFormat: InputStickerFormatStatic,
}
_, err := UploadStickerFile(context.Background(), bot, params)
require.Error(t, err)
@@ -21085,7 +21085,7 @@ func Test_UploadStickerFile_ServerError(t *testing.T) {
params := &UploadStickerFileParams{
UserID: 42,
Sticker: &InputFile{PathOrID: "file_id_test"},
StickerFormat: "test_value",
StickerFormat: InputStickerFormatStatic,
}
_, err := UploadStickerFile(context.Background(), bot, params)
require.Error(t, err)
@@ -22415,7 +22415,7 @@ func Test_SetStickerSetThumbnail_Success(t *testing.T) {
params := &SetStickerSetThumbnailParams{
Name: "test_value",
UserID: 42,
Format: "test_value",
Format: InputStickerFormatStatic,
}
_, err := SetStickerSetThumbnail(context.Background(), bot, params)
require.NoError(t, err)
@@ -22430,7 +22430,7 @@ func Test_SetStickerSetThumbnail_APIError(t *testing.T) {
params := &SetStickerSetThumbnailParams{
Name: "test_value",
UserID: 42,
Format: "test_value",
Format: InputStickerFormatStatic,
}
_, err := SetStickerSetThumbnail(context.Background(), bot, params)
require.Error(t, err)
@@ -22448,7 +22448,7 @@ func Test_SetStickerSetThumbnail_NetworkError(t *testing.T) {
params := &SetStickerSetThumbnailParams{
Name: "test_value",
UserID: 42,
Format: "test_value",
Format: InputStickerFormatStatic,
}
_, err := SetStickerSetThumbnail(context.Background(), bot, params)
require.Error(t, err)
@@ -22464,7 +22464,7 @@ func Test_SetStickerSetThumbnail_ParseError(t *testing.T) {
params := &SetStickerSetThumbnailParams{
Name: "test_value",
UserID: 42,
Format: "test_value",
Format: InputStickerFormatStatic,
}
_, err := SetStickerSetThumbnail(context.Background(), bot, params)
require.Error(t, err)
@@ -22483,7 +22483,7 @@ func Test_SetStickerSetThumbnail_ContextCanceled(t *testing.T) {
params := &SetStickerSetThumbnailParams{
Name: "test_value",
UserID: 42,
Format: "test_value",
Format: InputStickerFormatStatic,
}
_, err := SetStickerSetThumbnail(ctx, bot, params)
require.Error(t, err)
@@ -22524,7 +22524,7 @@ func Test_SetStickerSetThumbnail_Forbidden(t *testing.T) {
params := &SetStickerSetThumbnailParams{
Name: "test_value",
UserID: 42,
Format: "test_value",
Format: InputStickerFormatStatic,
}
_, err := SetStickerSetThumbnail(context.Background(), bot, params)
require.Error(t, err)
@@ -22546,7 +22546,7 @@ func Test_SetStickerSetThumbnail_ServerError(t *testing.T) {
params := &SetStickerSetThumbnailParams{
Name: "test_value",
UserID: 42,
Format: "test_value",
Format: InputStickerFormatStatic,
}
_, err := SetStickerSetThumbnail(context.Background(), bot, params)
require.Error(t, err)
+16
View File
@@ -0,0 +1,16 @@
package api
// Ptr returns a pointer to v. Useful for optional scalar fields where
// the wire format must distinguish absent (nil) from an explicit zero
// value (e.g. DisableNotification: api.Ptr(false) to override the
// chat default).
//
// For untyped literals, supply the type parameter explicitly:
//
// Limit: api.Ptr[int64](5)
//
// For already-typed values, type inference handles it:
//
// var n int64 = 5
// Limit: api.Ptr(n)
func Ptr[T any](v T) *T { return &v }
+28
View File
@@ -0,0 +1,28 @@
package api_test
import (
"testing"
"github.com/lukaszraczylo/go-telegram/api"
)
func TestPtr(t *testing.T) {
if got := api.Ptr[int64](5); got == nil || *got != 5 {
t.Fatalf("Ptr[int64](5) = %v, want *5", got)
}
if got := api.Ptr(false); got == nil || *got != false {
t.Fatalf("Ptr(false) = %v, want *false", got)
}
if got := api.Ptr("hello"); got == nil || *got != "hello" {
t.Fatalf("Ptr(\"hello\") = %v, want *\"hello\"", got)
}
n := int64(42)
got := api.Ptr(n)
if got == nil || *got != 42 {
t.Fatalf("Ptr(n) = %v, want *42", got)
}
if got == &n {
t.Fatalf("Ptr should copy, not alias caller's variable")
}
}
+1760 -176
View File
File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
package api
import (
"reflect"
"testing"
json "github.com/goccy/go-json"
"github.com/stretchr/testify/require"
)
// TestUnifiedEnum_ChatMemberStatus_HasAllConstants asserts the unified
// enum exists with the full set of variant values and is a typed string.
func TestUnifiedEnum_ChatMemberStatus_HasAllConstants(t *testing.T) {
require.IsType(t, ChatMemberStatus(""), ChatMemberStatusCreator)
values := []ChatMemberStatus{
ChatMemberStatusCreator,
ChatMemberStatusAdministrator,
ChatMemberStatusMember,
ChatMemberStatusRestricted,
ChatMemberStatusLeft,
ChatMemberStatusKicked,
}
wantWire := []string{"creator", "administrator", "member", "restricted", "left", "kicked"}
require.Len(t, values, 6)
for i, v := range values {
require.Equal(t, wantWire[i], string(v))
}
}
// TestUnifiedEnum_ChatMember_VariantFieldsRetyped confirms every concrete
// variant's discriminator field is the unified enum, NOT a per-variant
// alias type. Reflection walks the struct field directly.
func TestUnifiedEnum_ChatMember_VariantFieldsRetyped(t *testing.T) {
cases := []struct {
name string
val any
}{
{"ChatMemberOwner", &ChatMemberOwner{}},
{"ChatMemberAdministrator", &ChatMemberAdministrator{}},
{"ChatMemberMember", &ChatMemberMember{}},
{"ChatMemberRestricted", &ChatMemberRestricted{}},
{"ChatMemberLeft", &ChatMemberLeft{}},
{"ChatMemberBanned", &ChatMemberBanned{}},
}
wantType := reflect.TypeOf(ChatMemberStatus(""))
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, ok := reflect.TypeOf(tc.val).Elem().FieldByName("Status")
require.True(t, ok, "%s missing Status field", tc.name)
require.Equal(t, wantType, f.Type, "%s.Status type mismatch", tc.name)
})
}
}
// TestUnifiedEnum_ChatMember_DirectComparison verifies the unified enum
// lets callers compare a variant's Status directly against constants
// without conversion.
func TestUnifiedEnum_ChatMember_DirectComparison(t *testing.T) {
owner := &ChatMemberOwner{Status: ChatMemberStatusCreator}
require.True(t, owner.Status == ChatMemberStatusCreator)
require.False(t, owner.Status == ChatMemberStatusKicked)
banned := &ChatMemberBanned{Status: ChatMemberStatusKicked}
require.True(t, banned.Status == ChatMemberStatusKicked)
}
// TestUnifiedEnum_ChatMember_MarshalDiscriminator verifies the auto-inject
// MarshalJSON still emits the right wire discriminator after the enum
// retype — no regression from commit 370c9c0.
func TestUnifiedEnum_ChatMember_MarshalDiscriminator(t *testing.T) {
cases := []struct {
val any
wantWire string
}{
{&ChatMemberOwner{User: User{ID: 1, FirstName: "a"}}, "creator"},
{&ChatMemberAdministrator{User: User{ID: 2, FirstName: "b"}}, "administrator"},
{&ChatMemberMember{User: User{ID: 3, FirstName: "c"}}, "member"},
{&ChatMemberRestricted{User: User{ID: 4, FirstName: "d"}}, "restricted"},
{&ChatMemberLeft{User: User{ID: 5, FirstName: "e"}}, "left"},
{&ChatMemberBanned{User: User{ID: 6, FirstName: "f"}}, "kicked"},
}
for _, tc := range cases {
raw, err := json.Marshal(tc.val)
require.NoError(t, err)
var probe struct {
Status string `json:"status"`
}
require.NoError(t, json.Unmarshal(raw, &probe))
require.Equal(t, tc.wantWire, probe.Status)
}
}
// TestUnifiedEnum_ChatMember_RoundTrip confirms a marshal-unmarshal cycle
// preserves the unified-enum field value.
func TestUnifiedEnum_ChatMember_RoundTrip(t *testing.T) {
orig := &ChatMemberOwner{User: User{ID: 99, FirstName: "owner"}}
raw, err := json.Marshal(orig)
require.NoError(t, err)
out, err := UnmarshalChatMember(raw)
require.NoError(t, err)
round, ok := out.(*ChatMemberOwner)
require.True(t, ok, "expected *ChatMemberOwner, got %T", out)
require.Equal(t, ChatMemberStatusCreator, round.Status)
require.Equal(t, orig.User.ID, round.User.ID)
}
// TestUnifiedEnum_MessageOriginType verifies a second union also unifies
// correctly — guards against a one-off implementation that only handles
// ChatMember.
func TestUnifiedEnum_MessageOriginType(t *testing.T) {
require.IsType(t, MessageOriginType(""), MessageOriginTypeUser)
values := []MessageOriginType{
MessageOriginTypeUser,
MessageOriginTypeHiddenUser,
MessageOriginTypeChat,
MessageOriginTypeChannel,
}
wantWire := []string{"user", "hidden_user", "chat", "channel"}
for i, v := range values {
require.Equal(t, wantWire[i], string(v))
}
// Variant fields use the unified type.
wantType := reflect.TypeOf(MessageOriginType(""))
for _, name := range []string{"MessageOriginUser", "MessageOriginHiddenUser", "MessageOriginChat", "MessageOriginChannel"} {
switch name {
case "MessageOriginUser":
f, ok := reflect.TypeOf(&MessageOriginUser{}).Elem().FieldByName("Type")
require.True(t, ok)
require.Equal(t, wantType, f.Type)
case "MessageOriginHiddenUser":
f, ok := reflect.TypeOf(&MessageOriginHiddenUser{}).Elem().FieldByName("Type")
require.True(t, ok)
require.Equal(t, wantType, f.Type)
case "MessageOriginChat":
f, ok := reflect.TypeOf(&MessageOriginChat{}).Elem().FieldByName("Type")
require.True(t, ok)
require.Equal(t, wantType, f.Type)
case "MessageOriginChannel":
f, ok := reflect.TypeOf(&MessageOriginChannel{}).Elem().FieldByName("Type")
require.True(t, ok)
require.Equal(t, wantType, f.Type)
}
}
}
// TestUnifiedEnum_StutterSuffix_Kind covers the naming-collision rule:
// when the union name ends in a discriminator concept noun, the unified
// enum is suffixed with "Kind" to avoid stuttery names like
// "BackgroundTypeType".
func TestUnifiedEnum_StutterSuffix_Kind(t *testing.T) {
require.IsType(t, BackgroundTypeKind(""), BackgroundTypeKindFill)
require.IsType(t, ReactionTypeKind(""), ReactionTypeKindEmoji)
require.IsType(t, StoryAreaTypeKind(""), StoryAreaTypeKindLocation)
require.IsType(t, ChatBoostSourceKind(""), ChatBoostSourceKindPremium)
require.IsType(t, RevenueWithdrawalStateKind(""), RevenueWithdrawalStateKindPending)
// Variant struct field types match the unified enum.
wantType := reflect.TypeOf(BackgroundTypeKind(""))
f, ok := reflect.TypeOf(&BackgroundTypeFill{}).Elem().FieldByName("Type")
require.True(t, ok)
require.Equal(t, wantType, f.Type)
}
// TestUnifiedEnum_PerVariantTypesNotEmitted asserts the obsolete
// per-variant single-value enum types (e.g. ChatMemberOwnerStatus) are
// gone — ensures the codegen doesn't double-emit. We rely on compile-time
// behaviour: if any of these names existed, a referencing package would
// fail to build. Instead we verify the variant struct field type's name
// is the unified one.
func TestUnifiedEnum_PerVariantTypesNotEmitted(t *testing.T) {
got := reflect.TypeOf(&ChatMemberOwner{}).Elem()
statusField, ok := got.FieldByName("Status")
require.True(t, ok)
require.Equal(t, "ChatMemberStatus", statusField.Type.Name(),
"ChatMemberOwner.Status should be ChatMemberStatus, not ChatMemberOwnerStatus")
}
+77
View File
@@ -0,0 +1,77 @@
package api
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// TestSetMyCommands_BotCommandScope_NoPointerToInterface is a regression
// test for the bug where sealed-interface union types without an
// auto-decode discriminator (BotCommandScope, InputMedia, etc.) were
// emitted as `*<Union>` (pointer-to-interface) when used as optional
// fields. Pointer-to-interface is a Go anti-pattern: the interface is
// already nil-able, and callers were forced to write
// `Scope: &someConcreteScope` instead of `Scope: someConcreteScope`.
//
// This test confirms the field is now bare-interface-typed: a concrete
// variant assigns directly, and a nil scope omits the field via
// omitempty.
func TestSetMyCommands_BotCommandScope_NoPointerToInterface(t *testing.T) {
var captured string
m := &mockDoer{}
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
if r.Body != nil {
b, _ := io.ReadAll(r.Body)
captured = string(b)
}
return true
})).Return(&http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil)
bot := client.New("test:token", client.WithHTTPClient(m))
// Direct assignment of a concrete variant — only possible when Scope
// is `BotCommandScope` (interface), not `*BotCommandScope`.
ok, err := SetMyCommands(context.Background(), bot, &SetMyCommandsParams{
Commands: []BotCommand{{Command: "start", Description: "begin"}},
Scope: &BotCommandScopeAllPrivateChats{},
})
require.NoError(t, err)
require.True(t, ok)
require.Contains(t, captured, `"scope":{"type":"all_private_chats"}`)
}
// TestSetMyCommands_NilScope_OmitsField confirms omitempty works on the
// bare-interface field when the caller doesn't supply a scope.
func TestSetMyCommands_NilScope_OmitsField(t *testing.T) {
var captured string
m := &mockDoer{}
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
if r.Body != nil {
b, _ := io.ReadAll(r.Body)
captured = string(b)
}
return true
})).Return(&http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil)
bot := client.New("test:token", client.WithHTTPClient(m))
_, err := SetMyCommands(context.Background(), bot, &SetMyCommandsParams{
Commands: []BotCommand{{Command: "start", Description: "begin"}},
})
require.NoError(t, err)
require.NotContains(t, captured, `"scope"`, "nil scope must be omitted from JSON")
}
+307 -27
View File
@@ -8,12 +8,22 @@ import (
"go/format"
"os"
"path/filepath"
"regexp"
"sort"
"text/template"
"github.com/lukaszraczylo/go-telegram/internal/spec"
)
// Discriminator-value extractors. The curly form ("always “X”") is
// authoritative because Telegram quotes wire literals with curly quotes
// throughout the docs; the bare form ("must be X") is the looser
// non-quoted variant used for BotCommandScope, InputMedia, etc.
var (
discCurlyRE = regexp.MustCompile(`(?:must be|always)\s+“([^”]+)”`)
discBareRE = regexp.MustCompile(`must be\s+([A-Za-z0-9_]+)(?:[\s.,]|$)`)
)
//go:embed types.tmpl
var typesTmpl string
@@ -36,6 +46,30 @@ var runtimeTypes = map[string]bool{
"MessageOrBool": true,
}
// fieldTypeOverrides maps "<TypeOrParamsName>.<FieldName>" → Go type expression.
// Used for fields whose values are restricted but whose enum the scraper
// can't detect (Telegram's curly-quoted emoji literals are routinely
// stripped by the scraper's regex due to byte-boundary issues with
// multi-byte sequences). The hand-curated typed-string enum lives in
// api/enums.go (manual file); this override just retypes the field so
// callers get IDE completion and compile-time checks. Generated fields
// stay typed even after `make regen`.
var fieldTypeOverrides = map[string]string{
"ReactionTypeEmoji.Emoji": "ReactionEmoji",
"SendDiceParams.Emoji": "DiceEmoji",
}
// fieldTypeOverride returns the override type for a (parent, fieldName)
// pair, or "" if none. parent is the Go type name owning the field —
// either a struct type (e.g. "ReactionTypeEmoji") or a method-params
// type (e.g. "SendDiceParams").
func fieldTypeOverride(parent, fieldName string) string {
if parent == "" {
return ""
}
return fieldTypeOverrides[parent+"."+fieldName]
}
// discriminatorSpec describes how to decode a sealed-interface union by
// peeking at a single JSON field.
type discriminatorSpec struct {
@@ -164,15 +198,168 @@ var knownDiscriminators = map[string]discriminatorSpec{
type emitter struct {
api *spec.API
outDir string
enums *enumPlan
// variantDiscs maps a concrete variant type name (e.g.
// "BotCommandScopeAllPrivateChats") to its discriminator wire-field
// + value. Populated once at construction; consulted by the types
// template to emit per-variant MarshalJSON that hardcodes the
// discriminator so callers don't have to set it by hand.
variantDiscs map[string]variantDiscriminator
}
func newEmitter(api *spec.API, outDir string) *emitter {
return &emitter{api: api, outDir: outDir}
knownInterfaceTypes = buildUnionTypeSet(api)
return &emitter{
api: api,
outDir: outDir,
enums: planEnums(api),
variantDiscs: variantDiscriminators(api),
}
}
// variantDiscriminator describes the JSON field+value that identifies a
// concrete variant of a sealed-interface union on the wire.
type variantDiscriminator struct {
JSONField string // wire field name, e.g. "type" or "source"
GoField string // Go struct field name, e.g. "Type" or "Source"
Value string // the wire value, e.g. "all_private_chats"
}
// variantDiscriminators returns variantTypeName → discriminator for every
// concrete struct that participates in a sealed-interface union and has
// a string-typed first field whose doc fixes its value (the canonical
// "must be X" / "always “X”" patterns Telegram uses).
//
// Resolution order:
//
// 1. knownDiscriminators reverse-lookup (the 13 auto-decode unions).
// This guarantees parity with UnmarshalXxx dispatch for the unions
// that round-trip through the library.
// 2. Doc-string analysis of the variant's first field, for marker-only
// unions (BotCommandScope, InputMedia, etc.) where the IR has no
// explicit discriminator metadata.
//
// Variants whose first field has no discriminator hint (Message,
// InaccessibleMessage, the InputMessageContent family) are omitted —
// the caller writes the dispatching fields directly and Telegram
// identifies them structurally.
func variantDiscriminators(api *spec.API) map[string]variantDiscriminator {
out := make(map[string]variantDiscriminator, 128)
// Pass 1: reverse-lookup from knownDiscriminators.
for _, ds := range knownDiscriminators {
if ds.Field == "" {
continue
}
for value, variant := range ds.Variants {
out[variant] = variantDiscriminator{
JSONField: ds.Field,
Value: value,
}
}
}
// Build the set of every variant type referenced by any OneOf so we
// can scan only those (avoids matching free-text "must be" prose in
// non-variant types like Message).
variantSet := make(map[string]bool, 128)
for _, t := range api.Types {
for _, v := range t.OneOf {
variantSet[v] = true
}
}
// Pass 2: doc-parse for variants without a known discriminator.
for _, t := range api.Types {
if !variantSet[t.Name] {
continue
}
if _, ok := out[t.Name]; ok {
// Pass-1 already provided the wire value; we still need
// the Go field name (mirrors the JSON field but with
// proper case). Resolve from t.Fields by JSONName match.
disc := out[t.Name]
for _, f := range t.Fields {
if f.JSONName == disc.JSONField {
disc.GoField = f.Name
out[t.Name] = disc
break
}
}
continue
}
disc, ok := extractVariantDiscriminator(t)
if !ok {
continue
}
out[t.Name] = disc
}
// Drop entries we couldn't resolve a Go field for (defensive — every
// pass-1 hit should have matched, but better to skip than emit
// broken code referencing an unknown field name).
for name, d := range out {
if d.GoField == "" {
delete(out, name)
}
}
return out
}
// extractVariantDiscriminator inspects the first field of a variant
// struct and returns its discriminator if the field is a required
// string whose doc nails the value via "must be X" or "always “X”".
// Returns (zero, false) when no clear discriminator is present.
func extractVariantDiscriminator(t spec.TypeDecl) (variantDiscriminator, bool) {
if len(t.Fields) == 0 {
return variantDiscriminator{}, false
}
f := t.Fields[0]
if !f.Required || f.Type.Kind != spec.KindPrimitive || f.Type.Name != "string" {
return variantDiscriminator{}, false
}
value := parseDiscriminatorDoc(f.Doc)
if value == "" {
return variantDiscriminator{}, false
}
return variantDiscriminator{
JSONField: f.JSONName,
GoField: f.Name,
Value: value,
}, true
}
// parseDiscriminatorDoc extracts the wire-level discriminator value
// from a field doc string. Handles both Telegram phrasings:
//
// - "Scope type, must be all_private_chats" (bare token)
// - "Type of the message origin, always “user”" (curly-quoted)
//
// Returns "" when no discriminator is present.
func parseDiscriminatorDoc(doc string) string {
// Curly-quoted form takes priority: "must be “X”" or "always “X”".
if m := discCurlyRE.FindStringSubmatch(doc); len(m) == 2 {
return m[1]
}
// Bare-token form: "must be <ident>" terminated by end-of-string,
// punctuation, or whitespace.
if m := discBareRE.FindStringSubmatch(doc); len(m) == 2 {
return m[1]
}
return ""
}
// knownInterfaceTypes is the full set of sealed-interface union type names
// (both auto-decoded ones in knownDiscriminators and marker-only ones from
// types with OneOf). Populated at emitter construction. goType and
// unionTypeFor consult this so optional fields of any union type stay
// bare interface, never *Interface (which is meaningless in Go and trips
// users at every call site).
var knownInterfaceTypes = map[string]bool{}
// emitTypes renders types.gen.go.
func (e *emitter) emitTypes() error {
t, err := template.New("types").Funcs(funcs()).Parse(typesTmpl)
t, err := template.New("types").Funcs(funcsWithDiscs(e.enums, e.variantDiscs)).Parse(typesTmpl)
if err != nil {
return fmt.Errorf("parse types.tmpl: %w", err)
}
@@ -208,20 +395,55 @@ func loadAPI(path string) (*spec.API, error) {
return &api, nil
}
// funcs is the FuncMap shared across templates.
func funcs() template.FuncMap {
// funcsWithDiscs returns the shared FuncMap with the variant
// discriminator helpers bound to discs. types.tmpl uses
// variantDiscFor/variantHasDisc to emit per-variant MarshalJSON that
// hardcodes the wire discriminator value.
func funcsWithDiscs(plan *enumPlan, discs map[string]variantDiscriminator) template.FuncMap {
fm := funcs(plan)
fm["variantHasDisc"] = func(name string) bool {
_, ok := discs[name]
return ok
}
fm["variantDiscField"] = func(name string) string { return discs[name].JSONField }
fm["variantDiscGoField"] = func(name string) string { return discs[name].GoField }
fm["variantDiscValue"] = func(name string) string { return discs[name].Value }
return fm
}
// funcs is the FuncMap shared across templates. plan is the resolved
// enum plan; pass nil only in unit tests that don't exercise enums.
func funcs(plan *enumPlan) template.FuncMap {
return template.FuncMap{
"goType": goType,
"goField": goField,
"docComment": docComment,
"isOptional": func(f spec.Field) bool { return !f.Required },
"not": func(b bool) bool { return !b },
"title": title,
"isFileField": isFileField,
"fileCheck": fileCheck,
"multipartFieldEntry": multipartFieldEntry,
"multipartFileEntry": multipartFileEntry,
"returnGoType": returnGoType,
"goType": goType,
"goField": func(parent string, f spec.Field) string {
return goField(plan, parent, f)
},
"goFieldP": func(methodName string, f spec.Field) string {
return goFieldX(plan, "", title(methodName)+"Params", f)
},
"docComment": docComment,
"isOptional": func(f spec.Field) bool { return !f.Required },
"not": func(b bool) bool { return !b },
"title": title,
"isFileField": isFileField,
"fileCheck": fileCheck,
"multipartFieldEntry": func(parent string, f spec.Field) string {
return multipartFieldEntry(plan, parent, f)
},
"multipartFieldEntryP": func(methodName string, f spec.Field) string {
return multipartFieldEntryX(plan, "", title(methodName)+"Params", f)
},
"multipartFileEntry": multipartFileEntry,
"returnGoType": returnGoType,
// enum helpers
"enums": func() []enumDecl {
if plan == nil {
return nil
}
return plan.All()
},
"enumConstName": constName,
// discriminator helpers for types.tmpl
"hasDiscriminator": func(name string) bool { s, ok := knownDiscriminators[name]; return ok && len(s.Variants) > 0 },
"isSealedUnionReturn": func(tr spec.TypeRef) bool {
@@ -231,6 +453,19 @@ func funcs() template.FuncMap {
s, ok := knownDiscriminators[tr.Name]
return ok && len(s.Variants) > 0
},
"isSealedUnionArrayReturn": func(tr spec.TypeRef) bool {
if tr.Kind != spec.KindArray || tr.ElemType == nil || tr.ElemType.Kind != spec.KindNamed {
return false
}
s, ok := knownDiscriminators[tr.ElemType.Name]
return ok && len(s.Variants) > 0
},
"sealedUnionElemName": func(tr spec.TypeRef) string {
if tr.Kind == spec.KindArray && tr.ElemType != nil {
return tr.ElemType.Name
}
return ""
},
"isMaybeInaccessibleMessage": func(name string) bool { return name == "MaybeInaccessibleMessage" },
"discriminatorField": func(name string) string { return knownDiscriminators[name].Field },
"discriminatorMap": func(name string) map[string]string { return knownDiscriminators[name].Variants },
@@ -295,8 +530,20 @@ func multipartFileEntry(f spec.Field) string {
// multipartFieldEntry generates the line that adds f to the multipart map.
// Required scalar fields go in unconditionally; optional ones go in only
// when non-zero/non-empty.
func multipartFieldEntry(f spec.Field) string {
// when non-zero/non-empty. Typed-string enum fields are cast to string
// before assignment because the multipart map is map[string]string.
func multipartFieldEntry(plan *enumPlan, parent string, f spec.Field) string {
return multipartFieldEntryX(plan, parent, parent, f)
}
// multipartFieldEntryX mirrors goFieldX: enumParent keys the enum plan,
// overrideParent keys fieldTypeOverrides. They differ only for method
// params.
func multipartFieldEntryX(plan *enumPlan, enumParent, overrideParent string, f spec.Field) string {
enumName := plan.FieldEnum(enumParent, f.Name)
if enumName == "" {
enumName = fieldTypeOverride(overrideParent, f.Name)
}
switch f.Type.Kind {
case spec.KindPrimitive:
switch f.Type.Name {
@@ -306,6 +553,12 @@ func multipartFieldEntry(f spec.Field) string {
}
return fmt.Sprintf("\tif p.%s != nil { out[%q] = strconv.FormatInt(*p.%s, 10) }\n", f.Name, f.JSONName, f.Name)
case "string":
if enumName != "" {
if f.Required {
return fmt.Sprintf("\tout[%q] = string(p.%s)\n", f.JSONName, f.Name)
}
return fmt.Sprintf("\tif p.%s != \"\" { out[%q] = string(p.%s) }\n", f.Name, f.JSONName, f.Name)
}
if f.Required {
return fmt.Sprintf("\tout[%q] = p.%s\n", f.JSONName, f.Name)
}
@@ -392,7 +645,7 @@ func returnGoElem(tr spec.TypeRef) string {
// emitMethods renders methods.gen.go.
func (e *emitter) emitMethods() error {
t, err := template.New("methods").Funcs(funcs()).Parse(methodsTmpl)
t, err := template.New("methods").Funcs(funcs(e.enums)).Parse(methodsTmpl)
if err != nil {
return fmt.Errorf("parse methods.tmpl: %w", err)
}
@@ -409,7 +662,7 @@ func (e *emitter) emitMethods() error {
// emitEnums renders enums.gen.go.
func (e *emitter) emitEnums() error {
t, err := template.New("enums").Funcs(funcs()).Parse(enumsTmpl)
t, err := template.New("enums").Funcs(funcs(e.enums)).Parse(enumsTmpl)
if err != nil {
return fmt.Errorf("parse enums.tmpl: %w", err)
}
@@ -442,7 +695,7 @@ func goType(tr spec.TypeRef, optional bool) string {
// multipart helpers (fileCheck, multipartFileEntry) call
// f.IsLocalUpload() and dereference Reader, both of which
// expect a pointer receiver.
if _, isUnion := knownDiscriminators[tr.Name]; isUnion {
if knownInterfaceTypes[tr.Name] {
// Interface type — never add *.
return tr.Name
}
@@ -558,8 +811,30 @@ func matchesVariants(got []string, want ...string) bool {
}
// goField returns the Go struct-field declaration for a Field.
func goField(f spec.Field) string {
// When the field carries scraper-detected enum values and the emitter
// has a planned enum name for (parent, field), the field's Go type is
// the enum identifier. Typed-string enums use the zero string ""
// behaviour for omitempty, so we do not pointer-wrap optional enum
// fields. Parent is "" for method parameters; pass the params type
// name (e.g. "SendDiceParams") via overrideParent when calling from
// the methods template so fieldTypeOverrides can resolve.
func goField(plan *enumPlan, parent string, f spec.Field) string {
return goFieldX(plan, parent, parent, f)
}
// goFieldX is the underlying field-emitter. enumParent is used for the
// enum-plan lookup (which keys method params under ""); overrideParent
// is used for fieldTypeOverrides (which keys method params under the
// params type name). For struct types both are the same; for method
// params they differ.
func goFieldX(plan *enumPlan, enumParent, overrideParent string, f spec.Field) string {
tag := fmt.Sprintf("`json:%q`", f.JSONName+omitempty(f))
if name := plan.FieldEnum(enumParent, f.Name); name != "" {
return fmt.Sprintf("%s %s %s", f.Name, name, tag)
}
if name := fieldTypeOverride(overrideParent, f.Name); name != "" {
return fmt.Sprintf("%s %s %s", f.Name, name, tag)
}
return fmt.Sprintf("%s %s %s", f.Name, goType(f.Type, !f.Required), tag)
}
@@ -623,14 +898,19 @@ func buildUnionTypeSet(api *spec.API) map[string]bool {
// makeSentinelValue returns a sentinelValue func that uses the given union type set.
// It returns a minimal valid Go expression for a spec.Field's type,
// used in generated test param literals.
func makeSentinelValue(unionTypes map[string]bool) func(spec.Field) string {
// used in generated test param literals. plan supplies typed-enum names
// so a method-param sentinel for a ParseMode field becomes a typed
// constant rather than a magic string.
func makeSentinelValue(unionTypes map[string]bool, plan *enumPlan) func(spec.Field) string {
return func(f spec.Field) string {
return sentinelForField(f, unionTypes)
return sentinelForField(f, unionTypes, plan)
}
}
func sentinelForField(f spec.Field, unionTypes map[string]bool) string {
func sentinelForField(f spec.Field, unionTypes map[string]bool, plan *enumPlan) string {
if name := plan.FieldEnum("", f.Name); name != "" && len(f.EnumValues) > 0 {
return constName(name, f.EnumValues[0])
}
tr := f.Type
switch tr.Kind {
case spec.KindPrimitive:
@@ -729,8 +1009,8 @@ func (e *emitter) emitTests() error {
unionTypes := buildUnionTypeSet(e.api)
// Add test-specific helpers to the shared func map.
fm := funcs()
fm["sentinelValue"] = makeSentinelValue(unionTypes)
fm := funcs(e.enums)
fm["sentinelValue"] = makeSentinelValue(unionTypes, e.enums)
fm["successResp"] = successResp
t, err := template.New("tests").Funcs(fm).Parse(testsTmpl)
+483
View File
@@ -0,0 +1,483 @@
package main
import (
"sort"
"strings"
"github.com/lukaszraczylo/go-telegram/internal/spec"
)
// enumDecl is one generated enum: a Go type alias of string plus a set
// of named constants. Values keep doc order; constant identifiers are
// derived from values via constName.
type enumDecl struct {
Name string
Values []string
}
// enumPlan is the deduplicated, name-resolved set of enums emitted from
// an API IR. Lookup returns the enum name for a given field reference;
// All returns the deterministic-ordered list of declarations to emit.
type enumPlan struct {
// fieldKey -> enum name. The fieldKey is a string built by enumKey.
byField map[string]string
// enum name -> declaration.
decls map[string]enumDecl
}
// enumKey identifies a single Field occurrence so the emitter can look
// up the enum name later. Parent is "" for method params (the method
// doesn't share a Go type with the field).
func enumKey(parent, fieldName string) string { return parent + "::" + fieldName }
// planEnums walks the IR, decides on enum names, deduplicates, and
// returns an enumPlan. All scraper-marked enum fields are covered.
func planEnums(api *spec.API) *enumPlan {
type ref struct {
parent string
fieldName string
jsonName string
values []string
valueKey string // canonical key for value-set dedup
}
// Unification pass: for each sealed-interface union, fold per-variant
// single-value enum fields that share a discriminator name into ONE
// unified enum at union level. Claimed (parent,fieldName) tuples are
// excluded from the per-field grouping below.
unifiedDecls, unifiedByField := planUnifiedUnionEnums(api)
claimed := func(parent, fieldName string) bool {
_, ok := unifiedByField[enumKey(parent, fieldName)]
return ok
}
var refs []ref
collect := func(parent string, fields []spec.Field) {
for _, f := range fields {
if len(f.EnumValues) == 0 {
continue
}
if claimed(parent, f.Name) {
continue
}
refs = append(refs, ref{
parent: parent,
fieldName: f.Name,
jsonName: f.JSONName,
values: f.EnumValues,
valueKey: valueKey(f.EnumValues),
})
}
}
for _, t := range api.Types {
collect(t.Name, t.Fields)
}
for _, m := range api.Methods {
// Method params have no shared Go parent type, so we pass "" as
// the parent. The default-name heuristic still produces the
// right answer for ParseMode-style enums.
collect("", m.Params)
}
// candidate name per ref (before collision resolution)
candidate := make([]string, len(refs))
for i, r := range refs {
candidate[i] = defaultEnumName(r.parent, r.jsonName, r.fieldName)
}
// Group by valueKey to coalesce identical value-sets across fields.
// Choose canonical name: prefer the most common candidate; tie-break
// by shortest name; final tie-break alphabetical.
type groupInfo struct {
values []string
name string
first int
}
groups := map[string]*groupInfo{}
for i, r := range refs {
g, ok := groups[r.valueKey]
if !ok {
groups[r.valueKey] = &groupInfo{values: r.values, first: i}
g = groups[r.valueKey]
}
_ = g
}
// Rank candidate names per group.
for vk := range groups {
counts := map[string]int{}
hasParent := map[string]bool{}
var names []string
for i, r := range refs {
if r.valueKey != vk {
continue
}
n := candidate[i]
if _, ok := counts[n]; !ok {
names = append(names, n)
}
counts[n]++
if r.parent != "" {
hasParent[n] = true
}
}
// Pick the canonical name for this group:
// 1. highest occurrence count wins;
// 2. names that originated from a parent type win over plain
// method-param candidates (avoids "Format"-style
// monosyllables);
// 3. shortest name wins;
// 4. alphabetical for full determinism.
sort.SliceStable(names, func(a, b int) bool {
if counts[names[a]] != counts[names[b]] {
return counts[names[a]] > counts[names[b]]
}
if hasParent[names[a]] != hasParent[names[b]] {
return hasParent[names[a]]
}
if len(names[a]) != len(names[b]) {
return len(names[a]) < len(names[b])
}
return names[a] < names[b]
})
groups[vk].name = names[0]
}
// Collision pass: two groups must not share the same enum name.
// When that happens, suffix the loser(s) with their parent type
// name so the result is unique. Iterate in deterministic order
// (groups sorted by valueKey).
used := map[string]string{} // name -> valueKey owner
var keys []string
for vk := range groups {
keys = append(keys, vk)
}
sort.Strings(keys)
for _, vk := range keys {
g := groups[vk]
if _, taken := used[g.name]; !taken {
used[g.name] = vk
continue
}
// Find a unique name by prepending a parent prefix from one of
// the contributing refs (the lowest-index ref in this group).
for i, r := range refs {
if r.valueKey != vk {
continue
}
if r.parent == "" {
continue
}
cand := r.parent + goNamePart(r.jsonName)
if _, taken := used[cand]; !taken {
g.name = cand
used[cand] = vk
goto next
}
_ = i
}
// Fallback: append a numeric disambiguator. Should not happen
// in practice for the Telegram docs but keeps the algorithm
// total.
for n := 2; ; n++ {
cand := groups[vk].name + itoa(n)
if _, taken := used[cand]; !taken {
g.name = cand
used[cand] = vk
break
}
}
next:
}
// Build the plan.
plan := &enumPlan{
byField: map[string]string{},
decls: map[string]enumDecl{},
}
for i, r := range refs {
name := groups[r.valueKey].name
plan.byField[enumKey(r.parent, r.fieldName)] = name
_ = i
}
for vk, g := range groups {
plan.decls[g.name] = enumDecl{Name: g.name, Values: g.values}
_ = vk
}
// Merge unified union enums (already named with stutter handling and
// keyed per-variant in unifiedByField).
for k, name := range unifiedByField {
plan.byField[k] = name
}
for name, d := range unifiedDecls {
plan.decls[name] = d
}
return plan
}
// planUnifiedUnionEnums detects sealed-interface unions whose variants
// share a single discriminator field with one enum value each, and emits
// ONE unified enum per union covering all variant values. Returns the
// declarations to emit and the per-(variant,fieldName) map to point each
// variant's field at the unified enum.
//
// A union qualifies when EVERY variant in t.OneOf:
// 1. defines a field with the same Go-name (e.g. "Status", "Type", "Source");
// 2. that field is a required string with len(EnumValues)==1.
//
// The picked Go-name is the first one tried in this priority order:
// - knownDiscriminators[union].Field's Go-name (resolved via JSONName match);
// - "Type", "Status", "Source" (the three discriminators Telegram uses).
//
// First match wins; if none qualify, the union is skipped (variants keep
// their existing per-field treatment, which still single-emits via the
// regular grouping pass).
func planUnifiedUnionEnums(api *spec.API) (map[string]enumDecl, map[string]string) {
decls := map[string]enumDecl{}
byField := map[string]string{}
typeByName := make(map[string]*spec.TypeDecl, len(api.Types))
for i := range api.Types {
typeByName[api.Types[i].Name] = &api.Types[i]
}
// Iterate unions in deterministic (declaration) order.
for ui := range api.Types {
u := &api.Types[ui]
if len(u.OneOf) == 0 {
continue
}
// Resolve the variants. Skip unions where any variant is missing
// (defensive — shouldn't happen in a well-formed IR).
variants := make([]*spec.TypeDecl, 0, len(u.OneOf))
for _, vName := range u.OneOf {
v, ok := typeByName[vName]
if !ok {
variants = nil
break
}
variants = append(variants, v)
}
if len(variants) == 0 {
continue
}
// Build the candidate Go-name list. Priority order:
// 1. discriminator GoField from knownDiscriminators (resolved via JSONName);
// 2. "Type", "Status", "Source".
var candidateNames []string
seen := map[string]bool{}
add := func(name string) {
if name == "" || seen[name] {
return
}
seen[name] = true
candidateNames = append(candidateNames, name)
}
if ds, ok := knownDiscriminators[u.Name]; ok && ds.Field != "" {
// Resolve Go-name from the first variant whose field matches the JSON name.
for _, v := range variants {
for _, f := range v.Fields {
if f.JSONName == ds.Field {
add(f.Name)
break
}
}
}
}
for _, n := range []string{"Type", "Status", "Source"} {
add(n)
}
// Find the first candidate Go-name where every variant has a
// matching single-value string-enum field.
var (
pickedName string
pickedDocs map[string]spec.Field // variant name -> field
)
for _, name := range candidateNames {
matches := map[string]spec.Field{}
ok := true
for _, v := range variants {
var hit *spec.Field
for fi := range v.Fields {
if v.Fields[fi].Name == name {
hit = &v.Fields[fi]
break
}
}
if hit == nil ||
hit.Type.Kind != spec.KindPrimitive ||
hit.Type.Name != "string" ||
len(hit.EnumValues) != 1 {
ok = false
break
}
matches[v.Name] = *hit
}
if ok {
pickedName = name
pickedDocs = matches
break
}
}
if pickedName == "" {
continue
}
// Build the unified enum name with stutter handling.
enumName := unifiedEnumName(u.Name, pickedName)
// Collect values across variants in deterministic order, deduping.
valueOrder := make([]string, 0, len(variants))
valueSeen := map[string]bool{}
for _, v := range u.OneOf {
f := pickedDocs[v]
val := f.EnumValues[0]
if valueSeen[val] {
continue
}
valueSeen[val] = true
valueOrder = append(valueOrder, val)
}
decls[enumName] = enumDecl{Name: enumName, Values: valueOrder}
for _, v := range variants {
byField[enumKey(v.Name, pickedName)] = enumName
}
}
return decls, byField
}
// unifiedEnumName builds the union-level enum name. Falls back to a
// "Kind" suffix when the naive concatenation reads as a stutter:
//
// - union name ends in the field name verbatim (e.g. BackgroundType+Type);
// - union name ends in any "concept noun" — Type/Status/Source/State —
// so appending another such noun would duplicate the suffix
// (e.g. ChatBoostSource+Source, RevenueWithdrawalState+Type).
//
// Otherwise the natural concatenation wins (ChatMember+Status →
// ChatMemberStatus, MessageOrigin+Type → MessageOriginType).
func unifiedEnumName(unionName, fieldName string) string {
for _, suf := range []string{"Type", "Status", "Source", "State"} {
if strings.HasSuffix(unionName, suf) {
return unionName + "Kind"
}
}
return unionName + fieldName
}
// All returns the enum declarations sorted by name for deterministic emit.
func (p *enumPlan) All() []enumDecl {
out := make([]enumDecl, 0, len(p.decls))
for _, d := range p.decls {
out = append(out, d)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// FieldEnum returns the enum name for a field on a given parent type
// (use parent="" for method parameters), or "" if the field is not an
// enum.
func (p *enumPlan) FieldEnum(parent, fieldName string) string {
if p == nil {
return ""
}
return p.byField[enumKey(parent, fieldName)]
}
// defaultEnumName picks an initial Go enum name for a field. parse_mode
// fields collapse to the canonical "ParseMode"; otherwise the name is
// parent + PascalCase(jsonName).
func defaultEnumName(parent, jsonName, fieldName string) string {
if strings.HasSuffix(jsonName, "parse_mode") {
return "ParseMode"
}
return parent + goNamePart(jsonName)
}
// constName builds a Go constant identifier "<EnumName><PascalValue>"
// from a wire value. Slashes (mime types) become "Of" so
// "image/jpeg" → "ImageOfJpeg".
func constName(enumName, value string) string {
return enumName + valuePascal(value)
}
func valuePascal(v string) string {
// "image/jpeg" → "ImageOfJpeg"
parts := strings.Split(v, "/")
for i, p := range parts {
parts[i] = goNamePart(p)
}
return strings.Join(parts, "Of")
}
// goNamePart converts a snake_case (or already-PascalCase) token to
// PascalCase, mirroring scrape.goName behaviour without the acronym
// special-cases (which apply to wire identifiers, not enum values).
func goNamePart(s string) string {
if s == "" {
return ""
}
parts := strings.Split(s, "_")
var b strings.Builder
for _, p := range parts {
if p == "" {
continue
}
// Acronyms used in Telegram wire names. Keeping in sync with
// scrape/table.go avoids divergent capitalisation between
// fieldName and constName.
switch p {
case "id":
b.WriteString("ID")
continue
case "url":
b.WriteString("URL")
continue
case "ip":
b.WriteString("IP")
continue
case "https":
b.WriteString("HTTPS")
continue
case "json":
b.WriteString("JSON")
continue
case "html":
b.WriteString("HTML")
continue
}
if c := p[0]; c >= 'a' && c <= 'z' {
b.WriteByte(c - 'a' + 'A')
b.WriteString(p[1:])
} else {
b.WriteString(p)
}
}
return b.String()
}
func valueKey(values []string) string {
cp := make([]string, len(values))
copy(cp, values)
sort.Strings(cp)
return strings.Join(cp, "\x00")
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
+5 -52
View File
@@ -4,57 +4,10 @@
package api
// ParseMode controls how Telegram interprets formatting in message text.
type ParseMode string
{{range $e := enums}}
type {{$e.Name}} string
const (
ParseModeMarkdown ParseMode = "Markdown" // legacy
ParseModeMarkdownV2 ParseMode = "MarkdownV2"
ParseModeHTML ParseMode = "HTML"
)
// ChatType is the type of a Telegram chat.
type ChatType string
const (
ChatTypePrivate ChatType = "private"
ChatTypeGroup ChatType = "group"
ChatTypeSupergroup ChatType = "supergroup"
ChatTypeChannel ChatType = "channel"
)
// 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"
)
// 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"
)
{{range $v := $e.Values}} {{enumConstName $e.Name $v}} {{$e.Name}} = {{printf "%q" $v}}
{{end}})
{{end}}
+17 -17
View File
@@ -175,101 +175,101 @@ func makeFieldVariants(name, jname string, kind spec.Kind, variants []string, re
func TestMultipartFieldEntry_Int64Required(t *testing.T) {
f := makeField("ChatID", "chat_id", "int64", spec.KindPrimitive, true)
got := multipartFieldEntry(f)
got := multipartFieldEntry(nil, "", f)
require.Contains(t, got, `FormatInt`)
require.NotContains(t, got, "if p.")
}
func TestMultipartFieldEntry_Int64Optional(t *testing.T) {
f := makeField("MessageThreadID", "message_thread_id", "int64", spec.KindPrimitive, false)
got := multipartFieldEntry(f)
got := multipartFieldEntry(nil, "", f)
require.Contains(t, got, `FormatInt`)
require.Contains(t, got, "if p.")
}
func TestMultipartFieldEntry_StringRequired(t *testing.T) {
f := makeField("Text", "text", "string", spec.KindPrimitive, true)
got := multipartFieldEntry(f)
got := multipartFieldEntry(nil, "", f)
require.Contains(t, got, `out["text"]`)
require.NotContains(t, got, "if p.Text")
}
func TestMultipartFieldEntry_StringOptional(t *testing.T) {
f := makeField("ParseMode", "parse_mode", "string", spec.KindPrimitive, false)
got := multipartFieldEntry(f)
got := multipartFieldEntry(nil, "", f)
require.Contains(t, got, `if p.ParseMode`)
}
func TestMultipartFieldEntry_BoolRequired(t *testing.T) {
f := makeField("DisableNotification", "disable_notification", "bool", spec.KindPrimitive, true)
got := multipartFieldEntry(f)
got := multipartFieldEntry(nil, "", f)
require.Contains(t, got, `FormatBool`)
require.NotContains(t, got, "if p.")
}
func TestMultipartFieldEntry_BoolOptional(t *testing.T) {
f := makeField("Protected", "protect_content", "bool", spec.KindPrimitive, false)
got := multipartFieldEntry(f)
got := multipartFieldEntry(nil, "", f)
require.Contains(t, got, `FormatBool`)
require.Contains(t, got, "if p.")
}
func TestMultipartFieldEntry_Float64Required(t *testing.T) {
f := makeField("Latitude", "latitude", "float64", spec.KindPrimitive, true)
got := multipartFieldEntry(f)
got := multipartFieldEntry(nil, "", f)
require.Contains(t, got, `FormatFloat`)
require.NotContains(t, got, "if p.")
}
func TestMultipartFieldEntry_Float64Optional(t *testing.T) {
f := makeField("Longitude", "longitude", "float64", spec.KindPrimitive, false)
got := multipartFieldEntry(f)
got := multipartFieldEntry(nil, "", f)
require.Contains(t, got, `FormatFloat`)
require.Contains(t, got, "if p.")
}
func TestMultipartFieldEntry_OneOf_ChatIDRequired(t *testing.T) {
f := makeFieldVariants("ChatID", "chat_id", spec.KindOneOf, []string{"int64", "string"}, true)
got := multipartFieldEntry(f)
got := multipartFieldEntry(nil, "", f)
require.Contains(t, got, `.String()`)
require.NotContains(t, got, "IsZero")
}
func TestMultipartFieldEntry_OneOf_ChatIDOptional(t *testing.T) {
f := makeFieldVariants("ChatID", "chat_id", spec.KindOneOf, []string{"int64", "string"}, false)
got := multipartFieldEntry(f)
got := multipartFieldEntry(nil, "", f)
require.Contains(t, got, `IsZero`)
}
func TestMultipartFieldEntry_OneOf_InputFileOrString(t *testing.T) {
f := makeFieldVariants("Photo", "photo", spec.KindOneOf, []string{"InputFile", "string"}, false)
got := multipartFieldEntry(f)
got := multipartFieldEntry(nil, "", f)
require.Contains(t, got, `PathOrID`)
}
func TestMultipartFieldEntry_OneOf_SealedRequired(t *testing.T) {
f := makeFieldVariants("Markup", "reply_markup", spec.KindOneOf, []string{"A", "B"}, true)
got := multipartFieldEntry(f)
got := multipartFieldEntry(nil, "", f)
require.Contains(t, got, `json.Marshal`)
}
func TestMultipartFieldEntry_OneOf_SealedOptional(t *testing.T) {
f := makeFieldVariants("Markup", "reply_markup", spec.KindOneOf, []string{"A", "B"}, false)
got := multipartFieldEntry(f)
got := multipartFieldEntry(nil, "", f)
require.Contains(t, got, `json.Marshal`)
require.Contains(t, got, "if p.Markup")
}
func TestMultipartFieldEntry_Named_Required(t *testing.T) {
f := makeField("Entities", "entities", "MessageEntity", spec.KindNamed, true)
got := multipartFieldEntry(f)
got := multipartFieldEntry(nil, "", f)
require.Contains(t, got, `json.Marshal`)
require.NotContains(t, got, "if p.")
}
func TestMultipartFieldEntry_Named_Optional(t *testing.T) {
f := makeField("Entities", "entities", "MessageEntity", spec.KindNamed, false)
got := multipartFieldEntry(f)
got := multipartFieldEntry(nil, "", f)
require.Contains(t, got, `json.Marshal`)
require.Contains(t, got, "if p.")
}
@@ -574,7 +574,7 @@ func TestSentinelForField(t *testing.T) {
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := sentinelForField(c.field, unionTypes)
got := sentinelForField(c.field, unionTypes, nil)
require.Contains(t, got, c.contains, "sentinelForField for %q", c.name)
})
}
@@ -637,7 +637,7 @@ func TestUnionTypeFor_OneOfNoMatch(t *testing.T) {
// ---------------------------------------------------------------------------
func TestFuncs_HasExpectedKeys(t *testing.T) {
fm := funcs()
fm := funcs(nil)
require.NotNil(t, fm)
for _, key := range []string{"goType", "docComment", "returnGoType", "unionFields"} {
require.NotNil(t, fm[key], "funcs() missing key %q", key)
+21 -3
View File
@@ -15,12 +15,12 @@ import (
var _ = strconv.Itoa // keep import for multipart helpers
var _ = json.Marshal // keep import for complex multipart fields
{{range .Methods}}
{{range .Methods}}{{$methodName := .Name}}
// {{title .Name}}Params is the parameter set for {{title .Name}}.
//
{{docComment .Doc -}}
type {{title .Name}}Params struct {
{{range .Params}}{{docComment .Doc}} {{goField .}}
{{range .Params}}{{docComment .Doc}} {{goFieldP $methodName .}}
{{end}}}
{{if .HasFiles}}
// HasFile reports whether a multipart upload is required.
@@ -31,7 +31,7 @@ func (p *{{title .Name}}Params) HasFile() bool {
// MultipartFields returns the non-file fields used in the multipart body.
func (p *{{title .Name}}Params) MultipartFields() map[string]string {
out := map[string]string{}
{{range .Params}}{{if not (isFileField .)}}{{multipartFieldEntry .}}{{end}}{{end}} return out
{{range .Params}}{{if not (isFileField .)}}{{multipartFieldEntryP $methodName .}}{{end}}{{end}} return out
}
// MultipartFiles returns the file parts.
@@ -51,6 +51,24 @@ func {{title .Name}}(ctx context.Context, b *client.Bot, p *{{title .Name}}Param
return nil, err
}
return Unmarshal{{.Returns.Name}}(raw)
{{else if isSealedUnionArrayReturn .Returns -}}
raw, err := client.CallRaw[*{{title .Name}}Params](ctx, b, "{{.Name}}", p)
if err != nil {
return nil, err
}
var elems []json.RawMessage
if err := json.Unmarshal(raw, &elems); err != nil {
return nil, err
}
out := make([]{{sealedUnionElemName .Returns}}, 0, len(elems))
for _, e := range elems {
v, err := Unmarshal{{sealedUnionElemName .Returns}}(e)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, nil
{{else -}}
return client.Call[*{{title .Name}}Params, {{returnGoType .Returns}}](ctx, b, "{{.Name}}", p)
{{end -}}
+18 -1
View File
@@ -82,8 +82,25 @@ func UnmarshalMaybeInaccessibleMessage(data []byte) (MaybeInaccessibleMessage, e
{{else}}
{{docComment .Doc -}}
type {{.Name}} struct {
{{range .Fields}}{{docComment .Doc}}{{goField .}}
{{range .Fields}}{{docComment .Doc}}{{goField $td.Name .}}
{{end}}}
{{if variantHasDisc .Name}}
// MarshalJSON encodes {{.Name}} with the discriminator field
// "{{variantDiscField .Name}}" forced to {{printf "%q" (variantDiscValue .Name)}}.
// The hardcoded value frees callers from setting {{variantDiscGoField .Name}} by hand —
// any user-supplied value on the struct literal is overridden so a typo
// can't slip through to Telegram.
func (v *{{.Name}}) MarshalJSON() ([]byte, error) {
type alias {{.Name}}
return json.Marshal(&struct {
{{variantDiscGoField .Name}} string `json:"{{variantDiscField .Name}}"`
*alias
}{
{{variantDiscGoField .Name}}: {{printf "%q" (variantDiscValue .Name)}},
alias: (*alias)(v),
})
}
{{end}}
{{$unionFields := unionFields .}}{{if $unionFields}}
// UnmarshalJSON decodes {{.Name}} by dispatching union-typed fields
// ({{range $i, $u := $unionFields}}{{if $i}}, {{end}}{{$u.Field.Name}}{{end}}) through their concrete UnmarshalXxx helpers.
+291
View File
@@ -0,0 +1,291 @@
package main
import (
"regexp"
"strings"
)
// extractEnumValues inspects a field-description string and returns the
// list of wire-level string values when the description matches one of
// the enum-like patterns Telegram uses in its docs. Order follows doc
// order; duplicates are removed but order of first occurrence is kept.
//
// Handled patterns (curly quotes “…” are required to avoid false
// positives on free-text quoting):
//
// - "Type of the chat, can be either “private”, “group”, … or “channel”"
// - "Currently, can be “mention”, “hashtag”, …"
// - "Currently, one of “XTR” … or “TON” …"
// - "Currently, must be one of “XTR” …"
// - "Currently, it can be one of “pending”, “approved”, “declined”."
// - "Must be one of “danger” …, “success” …"
// - "Must be one of “image/jpeg”, “image/gif”, or “video/mp4”"
// - "Format … must be one of “static” …, “animated” …, “video” …"
// - "Currently, either “upgrade” …, “transfer” …, “resale” …"
// - "..., always “creator”"
// - parse_mode parameter special case ("Mode for parsing entities …")
// emits the canonical Markdown / MarkdownV2 / HTML triple.
//
// Returns nil when the description does not look like an enum.
// extractEnumValues inspects a field-description string and returns the
// list of wire-level string values when the description matches one of
// the enum-like patterns Telegram uses in its docs. Order follows doc
// order; duplicates are removed but order of first occurrence is kept.
//
// Handled patterns (curly quotes “…” are required to avoid false
// positives on free-text quoting):
//
// - "Type of the chat, can be either “private”, “group”, … or “channel”"
// - "Currently, can be “mention”, “hashtag”, …"
// - "Currently, one of “XTR” … or “TON” …"
// - "Currently, must be one of “XTR” …"
// - "Currently, it can be one of “pending”, “approved”, “declined”."
// - "Must be one of “danger” …, “success” …"
// - "Must be one of “image/jpeg”, “image/gif”, or “video/mp4”"
// - "Format … must be one of “static” …, “animated” …, “video” …"
// - "Currently, either “upgrade” …, “transfer” …, “resale” …"
// - "..., always “creator”"
// - parse_mode parameter special case ("Mode for parsing entities …")
// emits the canonical Markdown / MarkdownV2 / HTML triple.
// - bare prose discriminator at end of description, e.g.
// "Type of the result, must be article" or
// "Scope type, must be all_private_chats". Used by sealed-interface
// union variants whose Type/Source field carries a single literal
// value declared without curly quotes.
//
// Returns nil when the description does not look like an enum.
func extractEnumValues(jsonName, desc string) []string {
if values := parseModeEnumValues(jsonName, desc); values != nil {
return values
}
trigger, triggerEnd, isAlways := findEnumTrigger(desc)
if trigger < 0 {
return extractProseDiscriminator(desc)
}
tail := desc[trigger:]
values := collectQuotedValues(tail)
if len(values) == 0 {
if v := extractProseDiscriminator(desc); v != nil {
return v
}
return nil
}
// First quoted value must sit close to the trigger phrase (e.g.
// "can be “private”…"). Phrasings like "can be available only for
// “invoice_payment”…" introduce a referenced value, not an enum,
// and the gap between trigger end and first quote rules them out.
firstQuote := strings.Index(desc[triggerEnd:], "“")
if firstQuote < 0 {
return nil
}
gap := desc[triggerEnd : triggerEnd+firstQuote]
// Allow "always " as a permitted bridge (e.g. "Currently, always
// “XTR”") and promote the match to single-value form.
if strings.Contains(strings.ToLower(gap), "always ") {
isAlways = true
} else if firstQuote > 8 {
return nil
}
// Single-value matches are only credible after "always". Multi-
// value matches are credible after any trigger; the trigger phrase
// already constrained the context.
if !isAlways && len(values) < 2 {
return nil
}
for _, v := range values {
if !looksLikeEnumValue(v) {
return nil
}
}
return dedupeStrings(values)
}
// parseMode parameters do not list values inline — Telegram links to a
// separate "formatting options" section. We hardcode the canonical set
// here so callers get a typed ParseMode without writing magic strings.
func parseModeEnumValues(jsonName, desc string) []string {
if !strings.HasSuffix(jsonName, "parse_mode") {
return nil
}
if !strings.Contains(desc, "Mode for parsing entities") {
return nil
}
return []string{"Markdown", "MarkdownV2", "HTML"}
}
// enumTriggers are anchor phrases that introduce a list of valid wire
// values. Order matches longest-prefix priority; the matcher uses the
// earliest match in the description.
var enumTriggers = []string{
"can be either ",
"can be one of ",
"can be ",
"must be one of ",
"must be ",
"currently one of ",
"currently, one of ",
"currently, either ",
"currently, must be one of ",
"currently, can be ",
"currently, it can be one of ",
"currently, ",
"one of ",
"either ",
"always ",
}
// findEnumTrigger returns the byte offset where the first enum trigger
// phrase begins, the offset just past the phrase, and whether the
// trigger is the single-value "always" form. Returns (-1, -1, false)
// when no trigger matches. Matching is case-insensitive so "Currently"
// and "currently" both fire.
func findEnumTrigger(desc string) (int, int, bool) {
lower := strings.ToLower(desc)
bestStart := -1
bestEnd := -1
bestAlways := false
for _, t := range enumTriggers {
i := strings.Index(lower, t)
if i < 0 {
continue
}
if bestStart != -1 && i >= bestStart {
// Earlier-trigger wins outright; on a tie, the longer trigger
// (which we visit first) already populated bestEnd.
continue
}
bestStart = i
bestEnd = i + len(t)
bestAlways = t == "always "
}
return bestStart, bestEnd, bestAlways
}
// quotedRE matches a curly-quoted token: “value”.
var quotedRE = regexp.MustCompile(`“([^”]*)”`)
// collectQuotedValues returns the contents of every “…” pair in s in
// order. Multi-line is fine; the docs use single-paragraph cells.
func collectQuotedValues(s string) []string {
matches := quotedRE.FindAllStringSubmatch(s, -1)
out := make([]string, 0, len(matches))
for _, m := range matches {
out = append(out, m[1])
}
return out
}
// looksLikeEnumValue returns true for short identifiers that fit the
// shape of a Telegram wire enum. This rules out values like
// "attach://…", "h264", arbitrary URLs, and stylised punctuation.
//
// Permitted shapes:
//
// a-z0-9_ (e.g. "private", "bot_command")
// A-Z0-9_ (e.g. "XTR", "TON", "MarkdownV2")
// mixed case incl. "/" once (e.g. "image/jpeg", "video/mp4")
func looksLikeEnumValue(v string) bool {
if v == "" || len(v) > 64 {
return false
}
if strings.Contains(v, "://") || strings.Contains(v, " ") {
return false
}
// "image/jpeg"-style mime types: at most one slash, both halves alnum.
if i := strings.Index(v, "/"); i >= 0 {
if strings.Count(v, "/") > 1 {
return false
}
left, right := v[:i], v[i+1:]
return isIdent(left) && isIdent(right)
}
return isIdent(v)
}
func isIdent(s string) bool {
if s == "" {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '_' || r == '-' || r == '.':
default:
return false
}
}
return true
}
func dedupeStrings(in []string) []string {
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, s := range in {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
// proseDiscRE matches a terminal "must be <ident>" clause: the
// discriminator value sits at the END of the description (optionally
// followed by trailing punctuation/whitespace) so multi-clause prose
// like "must be shown above the message" is not picked up.
//
// The identifier is a snake_case wire literal: lowercase letters, digits,
// and underscores, starting with a letter. Numeric-only and prose words
// are filtered separately by isProseWord.
var proseDiscRE = regexp.MustCompile(`(?i)\bmust be\s+([a-z][a-z0-9_]*)\s*[.,]?\s*$`)
// extractProseDiscriminator detects unambiguous single-value
// discriminator declarations of the form "..., must be <ident>" used by
// sealed-interface union variants (e.g. "Type of the result, must be
// article" or "Scope type, must be all_private_chats"). Returns the
// extracted value as a one-element slice or nil when no match is found.
//
// The terminal-position anchor is what protects against prose like
// "must be shown above" or "must be one of 3, 6, or 12" — the candidate
// must close the description.
func extractProseDiscriminator(desc string) []string {
desc = strings.TrimSpace(desc)
if desc == "" {
return nil
}
m := proseDiscRE.FindStringSubmatch(desc)
if m == nil {
return nil
}
v := m[1]
if isProseWord(v) {
return nil
}
return []string{v}
}
// isProseWord rejects bare-prose continuations that pass the regex but
// are clearly English filler ("must be sent", "must be available"). The
// list is the closed set of words that empirically appear in the IR's
// "must be …" tails outside the variant-discriminator pattern. Wire
// identifiers are always single tokens with no English meaning, so any
// match here is a free-text false positive.
func isProseWord(s string) bool {
switch s {
case "a", "an", "the",
"sent", "shown", "set", "used", "passed", "specified", "available",
"applied", "supported", "assumed", "active", "paid", "between",
"of", "on", "in", "at", "by", "to", "from", "for", "with",
"and", "or", "no", "non",
"positive", "negative",
"administrator", "repainted",
"one", "exactly":
return true
}
return false
}
+138
View File
@@ -0,0 +1,138 @@
package main
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestExtractEnumValues_CanBeEither(t *testing.T) {
desc := "Type of the chat, can be either “private”, “group”, “supergroup” or “channel”"
got := extractEnumValues("type", desc)
require.Equal(t, []string{"private", "group", "supergroup", "channel"}, got)
}
func TestExtractEnumValues_CurrentlyCanBe(t *testing.T) {
desc := "Type of the entity. Currently, can be “mention” (@username), “hashtag” (#hashtag), or “code” (monowidth string)"
got := extractEnumValues("type", desc)
require.Equal(t, []string{"mention", "hashtag", "code"}, got)
}
func TestExtractEnumValues_AlwaysSingle(t *testing.T) {
desc := "Type of the message origin, always “user”"
got := extractEnumValues("type", desc)
require.Equal(t, []string{"user"}, got)
}
func TestExtractEnumValues_MustBeOneOfMime(t *testing.T) {
desc := "Optional. MIME type of the thumbnail, must be one of “image/jpeg”, “image/gif”, or “video/mp4”. Defaults to “image/jpeg”"
got := extractEnumValues("thumbnail_mime_type", desc)
require.Equal(t, []string{"image/jpeg", "image/gif", "video/mp4"}, got)
}
func TestExtractEnumValues_ParseModeSpecial(t *testing.T) {
desc := "Optional. Mode for parsing entities in the message text. See formatting options for more details."
got := extractEnumValues("parse_mode", desc)
require.Equal(t, []string{"Markdown", "MarkdownV2", "HTML"}, got)
}
func TestExtractEnumValues_QuestionParseMode(t *testing.T) {
desc := "Mode for parsing entities in the question. See formatting options for more details."
got := extractEnumValues("question_parse_mode", desc)
require.Equal(t, []string{"Markdown", "MarkdownV2", "HTML"}, got)
}
func TestExtractEnumValues_FalsePositiveReferencedValue(t *testing.T) {
// "Can be available only for "X"" is NOT an enum: the quote is a
// reference to a transaction-type value, not an introduced list.
desc := "Optional. Bot-specified invoice payload. Can be available only for “invoice_payment” transactions."
got := extractEnumValues("invoice_payload", desc)
require.Nil(t, got)
}
func TestExtractEnumValues_FalsePositiveSingleQuotedNonEnum(t *testing.T) {
// "can be ignored" with a quoted reference value later — not an enum.
desc := "Optional. Thumbnail of the file sent; can be ignored if thumbnail generation for the file is supported server-side."
got := extractEnumValues("thumbnail", desc)
require.Nil(t, got)
}
func TestExtractEnumValues_RefundedPaymentCurrentlyAlways(t *testing.T) {
desc := "Three-letter ISO 4217 currency code, or “XTR” for payments in Telegram Stars. Currently, always “XTR”"
got := extractEnumValues("currency", desc)
require.Equal(t, []string{"XTR"}, got)
}
func TestExtractEnumValues_RejectURLValues(t *testing.T) {
// "attach://<file_attach_name>" must never be promoted to an enum value.
desc := "Pass “attach://<file_attach_name>” to upload a new one"
got := extractEnumValues("media", desc)
require.Nil(t, got)
}
func TestExtractEnumValues_StringTypeOnly(t *testing.T) {
// (Sanity — table.go gates on string type, but the function itself
// should still respond consistently.)
desc := "ABC, can be “a”, “b”"
got := extractEnumValues("x", desc)
require.Equal(t, []string{"a", "b"}, got)
}
func TestExtractEnumValues_DedupeRepeatedValues(t *testing.T) {
desc := "Currently, one of “XTR” for Telegram Stars or “XTR” again"
got := extractEnumValues("currency", desc)
require.Equal(t, []string{"XTR"}, got)
}
func TestExtractEnumValues_ProseDiscriminator(t *testing.T) {
cases := []struct {
name string
desc string
want []string
}{
{"InlineQueryResultArticle", "Type of the result, must be article", []string{"article"}},
{"InlineQueryResultPhoto", "Type of the result, must be photo", []string{"photo"}},
{"InlineQueryResultMpeg4Gif", "Type of the result, must be mpeg4_gif", []string{"mpeg4_gif"}},
{"BotCommandScopeAllPrivateChats", "Scope type, must be all_private_chats", []string{"all_private_chats"}},
{"BotCommandScopeChat", "Scope type, must be chat", []string{"chat"}},
{"PassportElementErrorData", "Error source, must be data", []string{"data"}},
{"MenuButtonWebApp", "Type of the button, must be web_app", []string{"web_app"}},
{"InputProfilePhotoAnimated", "Type of the profile photo, must be animated", []string{"animated"}},
{"InputStoryContentVideo", "Type of the content, must be video", []string{"video"}},
{"InputPaidMediaPhoto", "Type of the media, must be photo", []string{"photo"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, extractEnumValues("type", tc.desc))
})
}
}
func TestExtractEnumValues_ProseFalsePositives(t *testing.T) {
cases := []struct {
name string
desc string
}{
{"available_only_for", "Optional. Bot-specified invoice payload. Can be available only for “invoice_payment” transactions."},
{"must_be_sent", "If True, the message must be sent immediately."},
{"must_be_shown_above", "Optional. True, if the link preview must be shown above the message text"},
{"must_be_specified", "The identifiers must be specified in a strictly increasing order."},
{"must_be_paid", "The number of Telegram Stars that must be paid to send the sticker"},
{"must_be_one_of_numbers", "Number of months the Telegram Premium subscription will be active for the user; must be one of 3, 6, or 12"},
{"must_be_between", "Currently, price in Telegram Stars must be between 5 and 100000"},
{"must_be_a_pay_button", "If not empty, the first button must be a Pay button."},
{"must_be_repainted", "True, if the sticker must be repainted to a text color in messages"},
{"must_be_active", "the subscription must be active up to the end of the current subscription period"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
require.Nil(t, extractEnumValues("type", tc.desc))
})
}
}
func TestExtractEnumValues_CanonicalMustBeOneOfStillWorks(t *testing.T) {
desc := "Currently, must be one of “Markdown”, “MarkdownV2”, “HTML”"
got := extractEnumValues("parse_mode_kind", desc)
require.Equal(t, []string{"Markdown", "MarkdownV2", "HTML"}, got)
}
+22 -10
View File
@@ -30,12 +30,18 @@ func parseFieldsTable(t *html.Node) []spec.Field {
desc := strings.TrimSpace(textOf(cells[2]))
required := !strings.HasPrefix(desc, "Optional.")
tref := parseTypeRef(typeText)
var enumVals []string
if tref.Kind == spec.KindPrimitive && tref.Name == "string" {
enumVals = extractEnumValues(jname, desc)
}
fields = append(fields, spec.Field{
Name: goName(jname),
JSONName: jname,
Type: parseTypeRef(typeText),
Required: required,
Doc: desc,
Name: goName(jname),
JSONName: jname,
Type: tref,
Required: required,
Doc: desc,
EnumValues: enumVals,
})
}
return fields
@@ -59,12 +65,18 @@ func parseParamsTable(t *html.Node) []spec.Field {
req := strings.EqualFold(strings.TrimSpace(textOf(cells[2])), "Yes")
desc := strings.TrimSpace(textOf(cells[3]))
tref := parseTypeRef(typeText)
var enumVals []string
if tref.Kind == spec.KindPrimitive && tref.Name == "string" {
enumVals = extractEnumValues(jname, desc)
}
params = append(params, spec.Field{
Name: goName(jname),
JSONName: jname,
Type: parseTypeRef(typeText),
Required: req,
Doc: desc,
Name: goName(jname),
JSONName: jname,
Type: tref,
Required: req,
Doc: desc,
EnumValues: enumVals,
})
}
return params
+6 -6
View File
@@ -15,17 +15,17 @@ func NewStatus(s string) dispatch.Filter[*api.ChatMemberUpdated] {
}
switch m := u.NewChatMember.(type) {
case *api.ChatMemberOwner:
return m.Status == s
return string(m.Status) == s
case *api.ChatMemberAdministrator:
return m.Status == s
return string(m.Status) == s
case *api.ChatMemberMember:
return m.Status == s
return string(m.Status) == s
case *api.ChatMemberRestricted:
return m.Status == s
return string(m.Status) == s
case *api.ChatMemberLeft:
return m.Status == s
return string(m.Status) == s
case *api.ChatMemberBanned:
return m.Status == s
return string(m.Status) == s
default:
return false
}
@@ -12,15 +12,15 @@ func memberUpdate(status string, fromID int64) *api.ChatMemberUpdated {
var newMember api.ChatMember
switch status {
case "member":
newMember = &api.ChatMemberMember{Status: status}
newMember = &api.ChatMemberMember{Status: api.ChatMemberStatusMember}
case "administrator":
newMember = &api.ChatMemberAdministrator{Status: status}
newMember = &api.ChatMemberAdministrator{Status: api.ChatMemberStatusAdministrator}
case "kicked":
newMember = &api.ChatMemberBanned{Status: status}
newMember = &api.ChatMemberBanned{Status: api.ChatMemberStatusKicked}
case "left":
newMember = &api.ChatMemberLeft{Status: status}
newMember = &api.ChatMemberLeft{Status: api.ChatMemberStatusLeft}
default:
newMember = &api.ChatMemberMember{Status: status}
newMember = &api.ChatMemberMember{Status: api.ChatMemberStatusMember}
}
return &api.ChatMemberUpdated{
From: api.User{ID: fromID},
@@ -70,7 +70,7 @@ func TestComposedFilters(t *testing.T) {
func TestNewStatus_Owner(t *testing.T) {
u := &api.ChatMemberUpdated{
From: api.User{ID: 1},
NewChatMember: &api.ChatMemberOwner{Status: "creator"},
NewChatMember: &api.ChatMemberOwner{Status: api.ChatMemberStatusCreator},
}
require.True(t, cmfilter.NewStatus("creator")(u))
require.False(t, cmfilter.NewStatus("member")(u))
@@ -79,7 +79,7 @@ func TestNewStatus_Owner(t *testing.T) {
func TestNewStatus_Restricted(t *testing.T) {
u := &api.ChatMemberUpdated{
From: api.User{ID: 1},
NewChatMember: &api.ChatMemberRestricted{Status: "restricted"},
NewChatMember: &api.ChatMemberRestricted{Status: api.ChatMemberStatusRestricted},
}
require.True(t, cmfilter.NewStatus("restricted")(u))
require.False(t, cmfilter.NewStatus("member")(u))
+5 -5
View File
@@ -48,7 +48,7 @@ func Command(name string) dispatch.Filter[*api.Message] {
return false
}
first := m.Entities[0]
if first.Type != string(api.EntityBotCommand) || first.Offset != 0 {
if first.Type != api.MessageEntityTypeBotCommand || first.Offset != 0 {
return false
}
end := int(first.Length)
@@ -72,7 +72,7 @@ func AnyCommand() dispatch.Filter[*api.Message] {
return false
}
first := m.Entities[0]
return first.Type == string(api.EntityBotCommand) && first.Offset == 0
return first.Type == api.MessageEntityTypeBotCommand && first.Offset == 0
}
}
@@ -105,8 +105,8 @@ func HasDocument() dispatch.Filter[*api.Message] {
}
// HasEntity returns a Filter that matches messages whose Entities contain at
// least one entity of type t (e.g. string(api.EntityBotCommand)).
func HasEntity(t string) dispatch.Filter[*api.Message] {
// least one entity of type t (e.g. api.MessageEntityTypeBotCommand).
func HasEntity(t api.MessageEntityType) dispatch.Filter[*api.Message] {
return func(m *api.Message) bool {
if m == nil {
return false
@@ -123,7 +123,7 @@ func HasEntity(t string) dispatch.Filter[*api.Message] {
// ChatType returns a Filter that matches messages whose Chat.Type equals t.
func ChatType(t api.ChatType) dispatch.Filter[*api.Message] {
return func(m *api.Message) bool {
return m != nil && m.Chat.Type == string(t)
return m != nil && m.Chat.Type == t
}
}
+9 -9
View File
@@ -11,7 +11,7 @@ import (
func msg(text string) *api.Message {
return &api.Message{
MessageID: 1,
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
Text: text,
}
}
@@ -20,10 +20,10 @@ func cmdMsg(cmd string) *api.Message {
text := cmd
return &api.Message{
MessageID: 1,
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
Text: text,
Entities: []api.MessageEntity{
{Type: string(api.EntityBotCommand), Offset: 0, Length: int64(len([]rune(text)))},
{Type: api.MessageEntityTypeBotCommand, Offset: 0, Length: int64(len([]rune(text)))},
},
}
}
@@ -72,7 +72,7 @@ func TestCommand(t *testing.T) {
t.Run("strips BotName suffix", func(t *testing.T) {
m := &api.Message{
Text: "/start@MyBot",
Entities: []api.MessageEntity{{Type: string(api.EntityBotCommand), Offset: 0, Length: 12}},
Entities: []api.MessageEntity{{Type: api.MessageEntityTypeBotCommand, Offset: 0, Length: 12}},
}
f := msgfilter.Command("/start")
require.True(t, f(m))
@@ -109,7 +109,7 @@ func TestIsForward(t *testing.T) {
// ForwardOrigin is a MessageOrigin interface; set via a concrete type.
f := msgfilter.IsForward()
m := msg("fwd")
m.ForwardOrigin = &api.MessageOriginUser{Type: "user"}
m.ForwardOrigin = &api.MessageOriginUser{}
require.True(t, f(m))
require.False(t, f(msg("no fwd")))
require.False(t, f(nil))
@@ -134,9 +134,9 @@ func TestHasDocument(t *testing.T) {
}
func TestHasEntity(t *testing.T) {
f := msgfilter.HasEntity(string(api.EntityURL))
f := msgfilter.HasEntity(api.MessageEntityTypeURL)
m := msg("check https://example.com")
m.Entities = []api.MessageEntity{{Type: string(api.EntityURL), Offset: 6, Length: 19}}
m.Entities = []api.MessageEntity{{Type: api.MessageEntityTypeURL, Offset: 6, Length: 19}}
require.True(t, f(m))
require.False(t, f(msg("plain")))
require.False(t, f(nil))
@@ -148,7 +148,7 @@ func TestChatType(t *testing.T) {
require.True(t, f(private))
group := msg("hi")
group.Chat.Type = string(api.ChatTypeGroup)
group.Chat.Type = api.ChatTypeGroup
require.False(t, f(group))
require.False(t, f(nil))
}
@@ -183,6 +183,6 @@ func TestComposedMessageFilters(t *testing.T) {
require.True(t, f(m))
m2 := msg("say hello")
m2.Chat.Type = string(api.ChatTypeGroup)
m2.Chat.Type = api.ChatTypeGroup
require.False(t, f(m2))
}
+3 -3
View File
@@ -17,7 +17,7 @@ func msgUpdate(id int64, text string) api.Update {
UpdateID: id,
Message: &api.Message{
MessageID: id,
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
Text: text,
},
}
@@ -29,10 +29,10 @@ func cmdUpdate(id int64, cmd string) api.Update {
UpdateID: id,
Message: &api.Message{
MessageID: id,
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
Text: cmd,
Entities: []api.MessageEntity{
{Type: string(api.EntityBotCommand), Offset: 0, Length: int64(len(cmd))},
{Type: api.MessageEntityTypeBotCommand, Offset: 0, Length: int64(len(cmd))},
},
},
}
+1 -1
View File
@@ -516,7 +516,7 @@ func extractCommand(m *api.Message) (cmd, args string, ok bool) {
return "", "", false
}
first := m.Entities[0]
if first.Type != string(api.EntityBotCommand) || first.Offset != 0 {
if first.Type != api.MessageEntityTypeBotCommand || first.Offset != 0 {
return "", "", false
}
cmd, sliceOk := utf16Slice(m.Text, int(first.Offset), int(first.Length))
+15 -15
View File
@@ -31,9 +31,9 @@ func cmdMessage(text string) api.Update {
return api.Update{
UpdateID: 1,
Message: &api.Message{
MessageID: 1, Date: 0, Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
MessageID: 1, Date: 0, Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
Text: text,
Entities: []api.MessageEntity{{Type: string(api.EntityBotCommand), Offset: 0, Length: int64(indexEnd(text))}},
Entities: []api.MessageEntity{{Type: api.MessageEntityTypeBotCommand, Offset: 0, Length: int64(indexEnd(text))}},
},
}
}
@@ -153,10 +153,10 @@ func TestRouter_NonASCIICommand(t *testing.T) {
UpdateID: 1,
Message: &api.Message{
MessageID: 1,
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
Text: text,
Entities: []api.MessageEntity{
{Type: string(api.EntityBotCommand), Offset: 0, Length: cmdU16Len},
{Type: api.MessageEntityTypeBotCommand, Offset: 0, Length: cmdU16Len},
},
},
}
@@ -196,7 +196,7 @@ func TestRouter_CommandValuesNotLeakedOnNoMatch(t *testing.T) {
u := api.Update{UpdateID: 1, Message: &api.Message{
MessageID: 1, Chat: api.Chat{ID: 1, Type: "private"},
Text: "/unknown",
Entities: []api.MessageEntity{{Type: string(api.EntityBotCommand), Offset: 0, Length: 8}},
Entities: []api.MessageEntity{{Type: api.MessageEntityTypeBotCommand, Offset: 0, Length: 8}},
}}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
@@ -247,7 +247,7 @@ func TestRouter_OnChannelPost(t *testing.T) {
})
u := api.Update{UpdateID: 1, ChannelPost: &api.Message{
MessageID: 99, Chat: api.Chat{ID: -100, Type: string(api.ChatTypeChannel)},
MessageID: 99, Chat: api.Chat{ID: -100, Type: api.ChatTypeChannel},
}}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
@@ -393,7 +393,7 @@ func TestRouter_OnInlineQueryFilter_Matches(t *testing.T) {
func TestRouter_FilterChain_Composition(t *testing.T) {
// Filter: private chat AND text contains "hello"
privateChat := Filter[*api.Message](func(m *api.Message) bool {
return m != nil && m.Chat.Type == string(api.ChatTypePrivate)
return m != nil && m.Chat.Type == api.ChatTypePrivate
})
hasHello := Filter[*api.Message](func(m *api.Message) bool {
return m != nil && len(m.Text) > 0 && containsStr(m.Text, "hello")
@@ -405,10 +405,10 @@ func TestRouter_FilterChain_Composition(t *testing.T) {
r.OnMessageFilter(combined, func(c *Context, m *api.Message) error { hit <- m.Text; return nil })
match := api.Update{UpdateID: 1, Message: &api.Message{
MessageID: 1, Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)}, Text: "say hello",
MessageID: 1, Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate}, Text: "say hello",
}}
noMatch := api.Update{UpdateID: 2, Message: &api.Message{
MessageID: 2, Chat: api.Chat{ID: 2, Type: string(api.ChatTypeGroup)}, Text: "say hello",
MessageID: 2, Chat: api.Chat{ID: 2, Type: api.ChatTypeGroup}, Text: "say hello",
}}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
@@ -462,7 +462,7 @@ func TestRouter_ConcurrentDispatch_AllHandlersFire(t *testing.T) {
for i := range ups {
ups[i] = api.Update{UpdateID: int64(i + 1), Message: &api.Message{
MessageID: int64(i + 1),
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
Text: "hi",
}}
}
@@ -493,7 +493,7 @@ func TestRouter_ConcurrentDispatch_SemaphoreBoundsConcurrency(t *testing.T) {
for i := range ups {
ups[i] = api.Update{UpdateID: int64(i + 1), Message: &api.Message{
MessageID: int64(i + 1),
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
Text: "hi",
}}
}
@@ -555,7 +555,7 @@ func TestRouter_ConcurrentDispatch_WaitsForInFlight(t *testing.T) {
)
u := api.Update{UpdateID: 1, Message: &api.Message{
MessageID: 1, Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)}, Text: "hi",
MessageID: 1, Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate}, Text: "hi",
}}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
@@ -594,7 +594,7 @@ func TestRouter_SerialMode_NoRace(t *testing.T) {
for i := range ups {
ups[i] = api.Update{UpdateID: int64(i + 1), Message: &api.Message{
MessageID: int64(i + 1),
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
Text: "hi",
}}
}
@@ -908,7 +908,7 @@ func TestRouter_ContextCancel_UnblocksWaitingAcquire(t *testing.T) {
for i := range limit {
lu.Send(api.Update{UpdateID: int64(i + 1), Message: &api.Message{
MessageID: int64(i + 1),
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
Text: "hi",
}})
}
@@ -919,7 +919,7 @@ func TestRouter_ContextCancel_UnblocksWaitingAcquire(t *testing.T) {
// Send one more update — Run will block trying to acquire the full semaphore.
lu.Send(api.Update{UpdateID: int64(limit + 1), Message: &api.Message{
MessageID: int64(limit + 1),
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
Text: "extra",
}})
View File
+1
View File
@@ -0,0 +1 @@
go-telegram.raczylo.com
+962
View File
@@ -0,0 +1,962 @@
<!doctype html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>go-telegram — Strongly-typed Go client for the Telegram Bot API</title>
<meta
name="description"
content="A fully-generated, strongly-typed Go client for the Telegram Bot API. 176 methods, 1408 generated tests, zero any in the public surface."
/>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class'
}
</script>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<style>
body { font-family: "Inter", sans-serif; }
code, pre { font-family: "JetBrains Mono", monospace; }
.theme-transition {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.animate-fade-in-up { animation: fadeInUp 0.6s ease-out; }
.animate-float { animation: float 3s ease-in-out infinite; }
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dark .glass {
background: rgba(17, 24, 39, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.gradient-text {
background: linear-gradient(135deg, #29B6F6 0%, #0288D1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dark .gradient-text {
background: linear-gradient(135deg, #4FC3F7 0%, #81D4FA 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.shadow-modern { box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.1); }
.dark .shadow-modern { box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.4); }
html { scroll-behavior: smooth; }
/* Syntax highlight tokens */
.tok-kw { color: #c792ea; }
.tok-fn { color: #82aaff; }
.tok-str { color: #c3e88d; }
.tok-cmt { color: #546e7a; font-style: italic; }
.tok-pkg { color: #ffcb6b; }
.tok-num { color: #f78c6c; }
.tok-type { color: #4FC3F7; }
</style>
<script>
if (localStorage.theme === "dark" || (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
</script>
</head>
<body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 theme-transition">
<!-- Navigation -->
<nav class="fixed w-full glass shadow-modern z-50 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="flex justify-between h-16 items-center">
<a href="#" class="flex items-center hover:opacity-80 transition-opacity duration-300">
<img src="logo-light.svg" alt="go-telegram logo" class="h-10 w-auto dark:hidden" />
<img src="logo-dark.svg" alt="go-telegram logo" class="h-10 w-auto hidden dark:block" />
</a>
<div class="hidden md:flex space-x-6">
<a href="#features" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Features</a>
<a href="#comparison" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Comparison</a>
<a href="#install" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Install</a>
<a href="#usage" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Usage</a>
<a href="#examples" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Examples</a>
<a href="#advanced" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Advanced</a>
<a href="https://github.com/lukaszraczylo/go-telegram/tree/main/docs/reference" target="_blank" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Reference</a>
</div>
<div class="flex items-center space-x-4">
<button id="theme-toggle" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="Toggle theme">
<i class="fas fa-moon dark:hidden text-xl"></i>
<i class="fas fa-sun hidden dark:inline text-xl"></i>
</button>
<a href="https://github.com/lukaszraczylo/go-telegram" target="_blank" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="View on GitHub">
<i class="fab fa-github text-xl"></i>
</a>
<button id="mobile-menu-toggle" class="md:hidden text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="Toggle menu">
<i class="fas fa-bars text-xl" id="menu-open-icon"></i>
<i class="fas fa-times text-xl hidden" id="menu-close-icon"></i>
</button>
</div>
</div>
</div>
<div id="mobile-menu" class="hidden md:hidden border-t border-gray-200 dark:border-gray-700">
<div class="px-4 py-3 space-y-1 bg-white dark:bg-gray-800">
<a href="#features" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Features</a>
<a href="#comparison" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Comparison</a>
<a href="#install" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Install</a>
<a href="#usage" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Usage</a>
<a href="#examples" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Examples</a>
<a href="#advanced" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Advanced</a>
<a href="https://github.com/lukaszraczylo/go-telegram/tree/main/docs/reference" target="_blank" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Reference</a>
</div>
</div>
</nav>
<!-- Hero Section -->
<section class="relative pt-24 sm:pt-32 pb-12 sm:pb-20 overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-sky-50 via-cyan-50 to-blue-50 dark:from-gray-900 dark:via-sky-900/20 dark:to-cyan-900/20 theme-transition"></div>
<div class="absolute top-0 -left-4 w-72 h-72 bg-cyan-300 dark:bg-cyan-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float"></div>
<div class="absolute top-0 -right-4 w-72 h-72 bg-sky-300 dark:bg-sky-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float" style="animation-delay: 1s;"></div>
<div class="absolute -bottom-8 left-20 w-72 h-72 bg-blue-300 dark:bg-blue-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float" style="animation-delay: 2s;"></div>
<div class="relative max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center">
<div class="mb-6 sm:mb-8 flex justify-center animate-fade-in-up">
<img src="logo-light.svg" alt="go-telegram logo" class="h-20 sm:h-24 md:h-32 w-auto dark:hidden" />
<img src="logo-dark.svg" alt="go-telegram logo" class="h-20 sm:h-24 md:h-32 w-auto hidden dark:block" />
</div>
<h1 class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-gray-100 mb-4 sm:mb-6 leading-tight animate-fade-in-up" style="animation-delay: 0.1s;">
A fully-generated,<br /><span class="gradient-text">strongly-typed</span> Go client<br />for the Telegram Bot API
</h1>
<p class="text-base sm:text-lg md:text-xl text-gray-600 dark:text-gray-300 mb-8 sm:mb-10 max-w-2xl mx-auto leading-relaxed px-4 animate-fade-in-up" style="animation-delay: 0.2s;">
176 methods. 1408 generated tests. Zero <code class="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm">any</code> in the public surface.
</p>
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 justify-center mb-8 sm:mb-10 px-4 animate-fade-in-up" style="animation-delay: 0.3s;">
<a href="#install" class="group relative bg-gradient-to-r from-sky-500 to-cyan-600 hover:from-sky-600 hover:to-cyan-700 text-white px-8 py-3 rounded-lg font-medium transition-all duration-300 min-h-[48px] flex items-center justify-center shadow-lg hover:shadow-xl hover:scale-105">
<i class="fas fa-rocket mr-2"></i><span class="relative z-10">Get started</span>
</a>
<a href="https://github.com/lukaszraczylo/go-telegram" target="_blank" class="group glass hover:shadow-lg text-gray-900 dark:text-gray-100 px-8 py-3 rounded-lg font-medium transition-all duration-300 min-h-[48px] flex items-center justify-center hover:scale-105">
<i class="fab fa-github mr-2"></i>View on GitHub
</a>
</div>
<!-- Stats row -->
<div class="flex flex-wrap justify-center gap-x-3 gap-y-1 text-sm font-mono text-gray-500 dark:text-gray-400 mb-12 sm:mb-16 px-4 animate-fade-in-up" style="animation-delay: 0.35s;">
<span>176 methods</span>
<span class="text-gray-300 dark:text-gray-600">&middot;</span>
<span>301 types</span>
<span class="text-gray-300 dark:text-gray-600">&middot;</span>
<span>1,408 tests</span>
<span class="text-gray-300 dark:text-gray-600">&middot;</span>
<span>MIT licensed</span>
</div>
<!-- Code preview card -->
<div class="mt-4 max-w-2xl mx-auto px-4 animate-fade-in-up animate-float" style="animation-delay: 0.4s; animation-duration: 4s;">
<div class="relative group">
<div class="absolute -inset-1 bg-gradient-to-r from-sky-500 to-cyan-600 rounded-xl blur opacity-25 group-hover:opacity-50 transition duration-500"></div>
<div class="relative bg-gradient-to-br from-gray-900 to-gray-800 rounded-xl p-5 text-left border border-gray-700">
<div class="flex items-center gap-2 mb-4">
<span class="w-3 h-3 rounded-full bg-red-500"></span>
<span class="w-3 h-3 rounded-full bg-yellow-500"></span>
<span class="w-3 h-3 rounded-full bg-green-500"></span>
<span class="ml-2 text-xs text-gray-500 font-mono">echo_bot.go</span>
</div>
<pre class="text-sm text-gray-100 overflow-x-auto"><code class="font-mono"><span class="tok-kw">package</span> <span class="tok-pkg">main</span>
<span class="tok-kw">import</span> (
<span class="tok-str">"context"</span>
<span class="tok-str">"log"</span>
<span class="tok-str">"os"</span>
<span class="tok-str">"github.com/lukaszraczylo/go-telegram/api"</span>
<span class="tok-str">"github.com/lukaszraczylo/go-telegram/client"</span>
<span class="tok-str">"github.com/lukaszraczylo/go-telegram/dispatch"</span>
)
<span class="tok-kw">func</span> <span class="tok-fn">main</span>() {
bot, _ := <span class="tok-pkg">client</span>.<span class="tok-fn">NewRetryDoer</span>(os.<span class="tok-fn">Getenv</span>(<span class="tok-str">"BOT_TOKEN"</span>), <span class="tok-kw">nil</span>)
d := <span class="tok-pkg">dispatch</span>.<span class="tok-fn">New</span>(bot)
d.<span class="tok-fn">OnMessage</span>(<span class="tok-kw">func</span>(ctx <span class="tok-type">context.Context</span>, msg *<span class="tok-type">api.Message</span>) {
bot.<span class="tok-fn">SendMessage</span>(ctx, &amp;<span class="tok-type">api.SendMessageParams</span>{
ChatID: <span class="tok-pkg">api</span>.<span class="tok-fn">ChatIDFromInt</span>(msg.Chat.ID),
Text: msg.Text,
})
})
<span class="tok-kw">if</span> err := d.<span class="tok-fn">Run</span>(<span class="tok-pkg">context</span>.<span class="tok-fn">Background</span>()); err != <span class="tok-kw">nil</span> {
<span class="tok-pkg">log</span>.<span class="tok-fn">Fatal</span>(err)
}
}</code></pre>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section id="features" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Features</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Built for correctness, composability, and production reliability</p>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-sky-500 to-sky-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-code-branch text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Generated from the live docs</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">IR + emitter pipeline runs weekly; a PR opens automatically for any Telegram-side change.</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-shield-halved text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">No <code class="text-xs bg-emerald-100 dark:bg-emerald-900 px-1 rounded">any</code> in the public API</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Typed unions — <code class="text-xs">ChatID</code>, <code class="text-xs">MessageOrBool</code> — sealed interfaces with marker methods and auto-decode.</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-plug text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Pluggable transport + codec</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Drop in fasthttp, sonic, or goccy/go-json with a one-line swap.</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-bolt text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Production-ready out of the box</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Retry middleware honouring <code class="text-xs">retry_after</code>, panic recovery, structured errors with sentinels.</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-rose-500 to-rose-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-route text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Typed dispatcher</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Generic <code class="text-xs">Handler[T]</code>, composable filters, conversation handler with pluggable storage, per-update goroutine pool.</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-teal-500 to-teal-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-circle-check text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Self-verifying codegen</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Every regen runs scrape → audit → emit → 1408 generated tests. Nothing ships without passing.</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Comparison Section -->
<section id="comparison" class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Comparison</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">How go-telegram compares to other Go Telegram bot libraries</p>
</div>
<!-- Mobile cards -->
<div class="block md:hidden space-y-4 mb-8">
<div class="glass rounded-xl p-5 shadow-modern">
<div class="flex items-center gap-3 mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-sky-500 to-cyan-600 flex items-center justify-center">
<i class="fas fa-paper-plane text-white text-sm"></i>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100">go-telegram</h4>
<p class="text-xs text-gray-500">HTML scraper · this library</p>
</div>
</div>
<div class="grid grid-cols-1 gap-2 text-sm">
<div class="flex items-center gap-2"><span class="text-green-500 font-bold"></span><span class="text-gray-600 dark:text-gray-400">Generated from spec (HTML)</span></div>
<div class="flex items-center gap-2"><span class="text-green-500 font-bold"></span><span class="text-gray-600 dark:text-gray-400">Typed unions, no any</span></div>
<div class="flex items-center gap-2"><span class="text-green-500 font-bold"></span><span class="text-gray-600 dark:text-gray-400">1,408 auto-generated tests</span></div>
<div class="flex items-center gap-2"><span class="text-green-500 font-bold"></span><span class="text-gray-600 dark:text-gray-400">Conversation handler</span></div>
<div class="flex items-center gap-2"><span class="text-green-500 font-bold"></span><span class="text-gray-600 dark:text-gray-400">Retry middleware (retry_after)</span></div>
<div class="flex items-center gap-2"><span class="text-green-500 font-bold"></span><span class="text-gray-600 dark:text-gray-400">Pluggable JSON codec</span></div>
</div>
</div>
<div class="glass rounded-xl p-5 shadow-modern">
<div class="flex items-center gap-3 mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-indigo-600 to-indigo-700 flex items-center justify-center">
<span class="text-white font-bold text-xs">gtb</span>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100">gotgbot/v2</h4>
<p class="text-xs text-gray-500">JSON spec · popular library</p>
</div>
</div>
<div class="grid grid-cols-1 gap-2 text-sm">
<div class="flex items-center gap-2"><span class="text-green-500 font-bold"></span><span class="text-gray-600 dark:text-gray-400">Generated from spec (JSON)</span></div>
<div class="flex items-center gap-2"><span class="text-yellow-500 font-bold"></span><span class="text-gray-600 dark:text-gray-400">Partial typed unions</span></div>
<div class="flex items-center gap-2"><span class="text-red-500 font-bold"></span><span class="text-gray-400">Auto-generated tests</span></div>
<div class="flex items-center gap-2"><span class="text-green-500 font-bold"></span><span class="text-gray-600 dark:text-gray-400">Conversation handler</span></div>
<div class="flex items-center gap-2"><span class="text-yellow-500 font-bold"></span><span class="text-gray-600 dark:text-gray-400">User-implemented retry</span></div>
<div class="flex items-center gap-2"><span class="text-red-500 font-bold"></span><span class="text-gray-400">Pluggable JSON codec</span></div>
</div>
</div>
<div class="glass rounded-xl p-5 shadow-modern">
<div class="flex items-center gap-3 mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-gray-600 to-gray-700 flex items-center justify-center">
<span class="text-white font-bold text-xs">tba</span>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100">telegram-bot-api/v5</h4>
<p class="text-xs text-gray-500">Hand-coded</p>
</div>
</div>
<div class="grid grid-cols-1 gap-2 text-sm">
<div class="flex items-center gap-2"><span class="text-red-500 font-bold"></span><span class="text-gray-400">Generated from spec</span></div>
<div class="flex items-center gap-2"><span class="text-red-500 font-bold"></span><span class="text-gray-400">Typed unions</span></div>
<div class="flex items-center gap-2"><span class="text-red-500 font-bold"></span><span class="text-gray-400">Auto-generated tests</span></div>
<div class="flex items-center gap-2"><span class="text-red-500 font-bold"></span><span class="text-gray-400">Conversation handler</span></div>
<div class="flex items-center gap-2"><span class="text-red-500 font-bold"></span><span class="text-gray-400">Retry middleware</span></div>
<div class="flex items-center gap-2"><span class="text-red-500 font-bold"></span><span class="text-gray-400">Pluggable JSON codec</span></div>
</div>
</div>
</div>
<!-- Desktop table -->
<div class="hidden md:block glass rounded-xl overflow-hidden shadow-modern">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gradient-to-r from-sky-500 to-cyan-600 text-white">
<tr>
<th class="px-4 py-3 text-left font-semibold">Feature</th>
<th class="px-4 py-3 text-center font-semibold">go-telegram</th>
<th class="px-4 py-3 text-center font-semibold"><a href="https://github.com/PaulSonOfLars/gotgbot" class="hover:underline" target="_blank">gotgbot/v2</a></th>
<th class="px-4 py-3 text-center font-semibold"><a href="https://github.com/go-telegram-bot-api/telegram-bot-api" class="hover:underline" target="_blank">telegram-bot-api/v5</a></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Generated from spec</td>
<td class="px-4 py-3 text-center"><span class="text-green-500 font-bold"></span> <span class="text-xs text-gray-500">HTML scraper</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500 font-bold"></span> <span class="text-xs text-gray-500">JSON spec</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500 font-bold"></span> <span class="text-xs text-gray-500">hand-coded</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Typed unions (no <code class="text-xs">any</code>)</td>
<td class="px-4 py-3 text-center"><span class="text-green-500 font-bold"></span> <span class="text-xs text-gray-500">ChatID, MessageOrBool, sealed interfaces</span></td>
<td class="px-4 py-3 text-center"><span class="text-yellow-500 font-bold"></span> <span class="text-xs text-gray-500">partial</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500 font-bold"></span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Auto-generated tests</td>
<td class="px-4 py-3 text-center"><span class="text-green-500 font-bold"></span> <span class="text-xs text-gray-500">1,408 (8 scenarios/method)</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500 font-bold"></span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500 font-bold"></span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Conversation handler</td>
<td class="px-4 py-3 text-center"><span class="text-green-500 font-bold"></span> <span class="text-xs text-gray-500">pluggable storage</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500 font-bold"></span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500 font-bold"></span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Retry middleware</td>
<td class="px-4 py-3 text-center"><span class="text-green-500 font-bold"></span> <span class="text-xs text-gray-500">honours retry_after</span></td>
<td class="px-4 py-3 text-center"><span class="text-yellow-500 font-bold"></span> <span class="text-xs text-gray-500">user-implemented</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500 font-bold"></span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Pluggable JSON codec</td>
<td class="px-4 py-3 text-center"><span class="text-green-500 font-bold"></span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500 font-bold"></span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500 font-bold"></span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<!-- Installation Section -->
<section id="install" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-4xl mx-auto px-4 sm:px-6">
<div class="text-center mb-10 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Installation</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">One command. No CGO, no system deps.</p>
</div>
<div class="space-y-4 sm:space-y-6">
<div class="glass p-6 sm:p-8 rounded-xl shadow-modern hover:shadow-xl transition-all duration-300">
<div class="flex items-center mb-4">
<i class="fas fa-cubes text-sky-500 dark:text-sky-400 text-2xl mr-3"></i>
<div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100">go get</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm">Requires Go 1.21+</p>
</div>
</div>
<div onclick="copyToClipboard('go get github.com/lukaszraczylo/go-telegram', this)" class="relative bg-gradient-to-br from-gray-900 to-gray-800 dark:from-gray-950 dark:to-black text-gray-100 p-4 rounded-lg text-sm cursor-pointer group overflow-x-auto border border-gray-700 hover:border-sky-500 transition-all duration-300">
<code class="block whitespace-nowrap font-mono">go get github.com/lukaszraczylo/go-telegram</code>
<div class="absolute top-3 right-3"><i class="fas fa-copy text-gray-500 group-hover:text-sky-400 transition-colors duration-300"></i></div>
</div>
</div>
<div class="glass p-6 sm:p-8 rounded-xl shadow-modern hover:shadow-xl transition-all duration-300">
<div class="flex items-center mb-4">
<i class="fas fa-book text-cyan-500 dark:text-cyan-400 text-2xl mr-3"></i>
<div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100">pkg.go.dev</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm">Full API reference</p>
</div>
</div>
<a href="https://pkg.go.dev/github.com/lukaszraczylo/go-telegram" target="_blank" class="block text-center bg-gradient-to-r from-cyan-600 to-sky-700 hover:from-cyan-700 hover:to-sky-800 text-white px-4 py-3 rounded-lg text-sm font-medium shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105">
<i class="fas fa-external-link-alt mr-2"></i>View on pkg.go.dev
</a>
</div>
<div class="glass p-6 sm:p-8 rounded-xl shadow-modern hover:shadow-xl transition-all duration-300">
<div class="flex items-center mb-4">
<i class="fas fa-book-open text-emerald-500 dark:text-emerald-400 text-2xl mr-3"></i>
<div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Markdown reference</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm">Auto-generated, browse on GitHub</p>
</div>
</div>
<a href="https://github.com/lukaszraczylo/go-telegram/tree/main/docs/reference" target="_blank" class="block text-center bg-gradient-to-r from-emerald-600 to-teal-700 hover:from-emerald-700 hover:to-teal-800 text-white px-4 py-3 rounded-lg text-sm font-medium shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105">
<i class="fas fa-external-link-alt mr-2"></i>Browse reference docs
</a>
</div>
<div class="glass p-6 sm:p-8 rounded-xl shadow-modern hover:shadow-xl transition-all duration-300">
<div class="flex items-center mb-4">
<i class="fas fa-file-alt text-blue-500 dark:text-blue-400 text-2xl mr-3"></i>
<div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Telegram Bot API reference</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm">Upstream spec this library tracks</p>
</div>
</div>
<a href="https://core.telegram.org/bots/api" target="_blank" class="block text-center bg-gradient-to-r from-blue-500 to-blue-700 hover:from-blue-600 hover:to-blue-800 text-white px-4 py-3 rounded-lg text-sm font-medium shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105">
<i class="fas fa-external-link-alt mr-2"></i>core.telegram.org/bots/api
</a>
</div>
</div>
</div>
</section>
<!-- Usage Section -->
<section id="usage" class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-10 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Usage</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Echo bot — the minimal working example</p>
</div>
<div class="relative group mb-10">
<div class="absolute -inset-1 bg-gradient-to-r from-sky-500 to-cyan-600 rounded-xl blur opacity-25 group-hover:opacity-50 transition duration-300"></div>
<div class="relative bg-gradient-to-br from-gray-900 to-gray-800 rounded-xl p-4 sm:p-6 overflow-x-auto border border-gray-700">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center">
<i class="fas fa-file-code text-sky-400 mr-2"></i>
<span class="text-sky-400 text-sm font-mono font-semibold">echo_bot.go</span>
</div>
<button onclick="copyToClipboard(document.getElementById('echo-bot-code').textContent, this)" class="text-gray-400 hover:text-sky-400 transition-colors duration-300" aria-label="Copy code">
<i class="fas fa-copy"></i>
</button>
</div>
<pre class="text-xs sm:text-sm text-gray-100 overflow-x-auto"><code id="echo-bot-code" class="font-mono">package main
import (
"context"
"log"
"os"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
)
func main() {
// NewRetryDoer wraps the default transport with retry middleware
// that honours Telegram's retry_after field automatically.
bot, err := client.NewRetryDoer(os.Getenv("BOT_TOKEN"), nil)
if err != nil {
log.Fatal(err)
}
d := dispatch.New(bot)
// Handler[T] is generic — the type parameter is the concrete update type.
d.OnMessage(func(ctx context.Context, msg *api.Message) {
_, err := bot.SendMessage(ctx, &amp;api.SendMessageParams{
// ChatIDFromInt returns a typed ChatID — no interface{} here.
ChatID: api.ChatIDFromInt(msg.Chat.ID),
Text: msg.Text,
})
if err != nil {
log.Printf("send error: %v", err)
}
})
if err := d.Run(context.Background()); err != nil {
log.Fatal(err)
}
}</code></pre>
</div>
</div>
<!-- Codegen pipeline -->
<div id="codegen" class="glass p-6 sm:p-8 rounded-xl shadow-modern">
<h3 class="text-lg sm:text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2"><i class="fas fa-cogs text-sky-500 mr-2"></i>Codegen pipeline</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">The emitter scrapes the live Telegram Bot API docs (HTML), builds an intermediate representation (<code class="text-xs">api.json</code>), then generates Go code and tests. An audit step validates every method signature against the IR before emission.</p>
<!-- Flow diagram -->
<div class="flex flex-wrap items-center justify-center gap-2 sm:gap-3 mb-6 text-sm font-mono">
<div class="px-3 py-2 bg-sky-100 dark:bg-sky-900/40 text-sky-800 dark:text-sky-300 rounded-lg border border-sky-200 dark:border-sky-700 flex items-center gap-2">
<i class="fas fa-globe text-sky-500"></i> HTML docs
</div>
<span class="text-gray-400 font-sans"></span>
<div class="px-3 py-2 bg-purple-100 dark:bg-purple-900/40 text-purple-800 dark:text-purple-300 rounded-lg border border-purple-200 dark:border-purple-700 flex items-center gap-2">
<i class="fas fa-database text-purple-500"></i> IR (api.json)
</div>
<span class="text-gray-400 font-sans"></span>
<div class="px-3 py-2 bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-300 rounded-lg border border-amber-200 dark:border-amber-700 flex items-center gap-2">
<i class="fas fa-search text-amber-500"></i> audit
</div>
<span class="text-gray-400 font-sans"></span>
<div class="px-3 py-2 bg-emerald-100 dark:bg-emerald-900/40 text-emerald-800 dark:text-emerald-300 rounded-lg border border-emerald-200 dark:border-emerald-700 flex items-center gap-2">
<i class="fas fa-code text-emerald-500"></i> Go code + tests
</div>
</div>
<div onclick="copyToClipboard('make snapshot && make regen', this)" class="relative bg-gradient-to-br from-gray-900 to-gray-800 text-gray-100 p-4 rounded-lg text-sm cursor-pointer group overflow-x-auto border border-gray-700 hover:border-sky-500 transition-all duration-300">
<code class="block whitespace-nowrap font-mono">make snapshot &amp;&amp; make regen</code>
<div class="absolute top-3 right-3"><i class="fas fa-copy text-gray-500 group-hover:text-sky-400 transition-colors duration-300"></i></div>
</div>
</div>
</div>
</section>
<!-- Examples Section -->
<section id="examples" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Examples</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">14 runnable bots covering the most common patterns</p>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Echo -->
<a href="https://github.com/lukaszraczylo/go-telegram/tree/main/examples/echo" target="_blank" class="glass p-4 rounded-xl group hover:shadow-lg transition-all duration-300 flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-sky-500 to-sky-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-reply text-white text-sm"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1 group-hover:text-sky-600 dark:group-hover:text-sky-400">echo</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Minimal echo bot — get up and running in 30 lines.</p>
</div>
</a>
<!-- Callback -->
<a href="https://github.com/lukaszraczylo/go-telegram/tree/main/examples/callback" target="_blank" class="glass p-4 rounded-xl group hover:shadow-lg transition-all duration-300 flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-hand-pointer text-white text-sm"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1 group-hover:text-sky-600 dark:group-hover:text-sky-400">callback</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Inline keyboard buttons and callback query handling.</p>
</div>
</a>
<!-- Inline -->
<a href="https://github.com/lukaszraczylo/go-telegram/tree/main/examples/inline" target="_blank" class="glass p-4 rounded-xl group hover:shadow-lg transition-all duration-300 flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-cyan-500 to-cyan-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-search text-white text-sm"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1 group-hover:text-sky-600 dark:group-hover:text-sky-400">inline</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Inline mode queries and result sets.</p>
</div>
</a>
<!-- Conversation -->
<a href="https://github.com/lukaszraczylo/go-telegram/tree/main/examples/conversation" target="_blank" class="glass p-4 rounded-xl group hover:shadow-lg transition-all duration-300 flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-comments text-white text-sm"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1 group-hover:text-sky-600 dark:group-hover:text-sky-400">conversation</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Multi-step conversation flows with pluggable state storage.</p>
</div>
</a>
<!-- Stateful -->
<a href="https://github.com/lukaszraczylo/go-telegram/tree/main/examples/stateful" target="_blank" class="glass p-4 rounded-xl group hover:shadow-lg transition-all duration-300 flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-memory text-white text-sm"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1 group-hover:text-sky-600 dark:group-hover:text-sky-400">stateful</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Per-user state machine pattern.</p>
</div>
</a>
<!-- Admin -->
<a href="https://github.com/lukaszraczylo/go-telegram/tree/main/examples/admin" target="_blank" class="glass p-4 rounded-xl group hover:shadow-lg transition-all duration-300 flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-rose-500 to-rose-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-user-shield text-white text-sm"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1 group-hover:text-sky-600 dark:group-hover:text-sky-400">admin</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Admin commands — ban, kick, restrict members.</p>
</div>
</a>
<!-- Middleware -->
<a href="https://github.com/lukaszraczylo/go-telegram/tree/main/examples/middleware" target="_blank" class="glass p-4 rounded-xl group hover:shadow-lg transition-all duration-300 flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-indigo-500 to-indigo-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-layer-group text-white text-sm"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1 group-hover:text-sky-600 dark:group-hover:text-sky-400">middleware</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Composable middleware chain — logging, auth, rate limiting.</p>
</div>
</a>
<!-- Files -->
<a href="https://github.com/lukaszraczylo/go-telegram/tree/main/examples/files" target="_blank" class="glass p-4 rounded-xl group hover:shadow-lg transition-all duration-300 flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-teal-500 to-teal-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-paperclip text-white text-sm"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1 group-hover:text-sky-600 dark:group-hover:text-sky-400">files</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Upload and download photos, documents, audio.</p>
</div>
</a>
<!-- Webhook -->
<a href="https://github.com/lukaszraczylo/go-telegram/tree/main/examples/webhook" target="_blank" class="glass p-4 rounded-xl group hover:shadow-lg transition-all duration-300 flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-lime-500 to-lime-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-network-wired text-white text-sm"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1 group-hover:text-sky-600 dark:group-hover:text-sky-400">webhook</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Webhook server instead of long-polling.</p>
</div>
</a>
<!-- Polls -->
<a href="https://github.com/lukaszraczylo/go-telegram/tree/main/examples/polls" target="_blank" class="glass p-4 rounded-xl group hover:shadow-lg transition-all duration-300 flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-fuchsia-500 to-fuchsia-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-poll text-white text-sm"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1 group-hover:text-sky-600 dark:group-hover:text-sky-400">polls</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Send polls and handle poll-answer updates.</p>
</div>
</a>
<!-- Payments -->
<a href="https://github.com/lukaszraczylo/go-telegram/tree/main/examples/payments" target="_blank" class="glass p-4 rounded-xl group hover:shadow-lg transition-all duration-300 flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-yellow-500 to-yellow-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-credit-card text-white text-sm"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1 group-hover:text-sky-600 dark:group-hover:text-sky-400">payments</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Telegram Payments — invoices, pre-checkout, successful payment.</p>
</div>
</a>
<!-- Pagination -->
<a href="https://github.com/lukaszraczylo/go-telegram/tree/main/examples/pagination" target="_blank" class="glass p-4 rounded-xl group hover:shadow-lg transition-all duration-300 flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-slate-500 to-slate-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-chevron-right text-white text-sm"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1 group-hover:text-sky-600 dark:group-hover:text-sky-400">pagination</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Paginated inline keyboards for long result sets.</p>
</div>
</a>
<!-- Welcome -->
<a href="https://github.com/lukaszraczylo/go-telegram/tree/main/examples/welcome" target="_blank" class="glass p-4 rounded-xl group hover:shadow-lg transition-all duration-300 flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-door-open text-white text-sm"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1 group-hover:text-sky-600 dark:group-hover:text-sky-400">welcome</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Greet new members joining a group or channel.</p>
</div>
</a>
<!-- Moderation -->
<a href="https://github.com/lukaszraczylo/go-telegram/tree/main/examples/moderation" target="_blank" class="glass p-4 rounded-xl group hover:shadow-lg transition-all duration-300 flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-red-500 to-red-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-gavel text-white text-sm"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1 group-hover:text-sky-600 dark:group-hover:text-sky-400">moderation</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Automated content moderation with filter chains.</p>
</div>
</a>
</div>
</div>
</section>
<!-- Advanced Section -->
<section id="advanced" class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
<div class="max-w-4xl mx-auto px-4 sm:px-6">
<div class="text-center mb-10 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Advanced</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Power-user patterns — expand to read</p>
</div>
<div class="space-y-3">
<!-- Conversation flows -->
<details class="glass rounded-xl overflow-hidden group">
<summary class="flex items-center justify-between p-5 cursor-pointer list-none select-none">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center flex-shrink-0">
<i class="fas fa-comments text-white text-sm"></i>
</div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Conversation flows</h3>
</div>
<i class="fas fa-chevron-down text-gray-400 transition-transform duration-300 group-open:rotate-180"></i>
</summary>
<div class="px-5 pb-5 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400 mt-4 mb-4">
The conversation handler chains multiple message steps and stores state between them. Any storage backend implementing the
<code class="text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded">StateStorage</code> interface works — in-memory, Redis, Postgres.
</p>
<div class="bg-gradient-to-br from-gray-900 to-gray-800 rounded-xl p-4 text-sm text-gray-100 overflow-x-auto border border-gray-700">
<pre class="font-mono"><code>conv := conversation.New(storage)
conv.AddState("ask_name", func(ctx context.Context, msg *api.Message) (string, error) {
bot.SendMessage(ctx, &amp;api.SendMessageParams{
ChatID: api.ChatIDFromInt(msg.Chat.ID),
Text: "What's your name?",
})
return "ask_age", nil
})
conv.AddState("ask_age", func(ctx context.Context, msg *api.Message) (string, error) {
name := conv.GetData(ctx, "name")
// ... handle age input
return conversation.Done, nil
})
d.OnMessage(conv.Handler("start"))</code></pre>
</div>
</div>
</details>
<!-- Custom filters -->
<details class="glass rounded-xl overflow-hidden group">
<summary class="flex items-center justify-between p-5 cursor-pointer list-none select-none">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center flex-shrink-0">
<i class="fas fa-filter text-white text-sm"></i>
</div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Custom filters</h3>
</div>
<i class="fas fa-chevron-down text-gray-400 transition-transform duration-300 group-open:rotate-180"></i>
</summary>
<div class="px-5 pb-5 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400 mt-4 mb-4">
Filters compose with <code class="text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded">And</code>, <code class="text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded">Or</code>, <code class="text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded">Not</code>.
A filter is just a function <code class="text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded">func(*api.Update) bool</code>.
</p>
<div class="bg-gradient-to-br from-gray-900 to-gray-800 rounded-xl p-4 text-sm text-gray-100 overflow-x-auto border border-gray-700">
<pre class="font-mono"><code>// Only handle private messages from admins
adminOnly := filters.And(
filters.IsPrivate,
filters.UserIDIn(adminIDs...),
)
d.OnMessage(func(ctx context.Context, msg *api.Message) {
// handler body
}, adminOnly)
// Custom filter — any function works
isLong := func(u *api.Update) bool {
return u.Message != nil &amp;&amp; len(u.Message.Text) > 200
}
d.OnMessage(handleLongMsg, isLong)</code></pre>
</div>
</div>
</details>
<!-- Custom HTTP/JSON -->
<details class="glass rounded-xl overflow-hidden group">
<summary class="flex items-center justify-between p-5 cursor-pointer list-none select-none">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center flex-shrink-0">
<i class="fas fa-plug text-white text-sm"></i>
</div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Custom HTTP / JSON codec</h3>
</div>
<i class="fas fa-chevron-down text-gray-400 transition-transform duration-300 group-open:rotate-180"></i>
</summary>
<div class="px-5 pb-5 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400 mt-4 mb-4">
Pass a <code class="text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded">transport.Options</code> to swap the HTTP client or JSON codec.
Useful for squeezing throughput on high-volume bots.
</p>
<div class="bg-gradient-to-br from-gray-900 to-gray-800 rounded-xl p-4 text-sm text-gray-100 overflow-x-auto border border-gray-700">
<pre class="font-mono"><code>import (
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/transport"
jsoniter "github.com/json-iterator/go"
)
opts := &amp;client.Options{
Transport: transport.NewFasthttpTransport(nil),
Codec: transport.JSONCodec(jsoniter.ConfigCompatibleWithStandardLibrary),
}
bot, err := client.NewRetryDoer(token, opts)</code></pre>
</div>
</div>
</details>
</div>
</div>
</section>
<!-- Footer -->
<footer class="bg-gray-900 dark:bg-black text-gray-400 py-8 sm:py-10 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-4 gap-6 sm:gap-8">
<div class="col-span-2 sm:col-span-1">
<img src="logo-dark.svg" alt="go-telegram logo" class="h-10 sm:h-12 w-auto mb-3 sm:mb-4" />
<p class="text-xs sm:text-sm">Fully-generated, strongly-typed Go client for the Telegram Bot API.</p>
</div>
<div>
<h3 class="text-white font-semibold mb-3 sm:mb-4 text-sm sm:text-base">Links</h3>
<ul class="space-y-1.5 sm:space-y-2 text-xs sm:text-sm">
<li><a href="https://github.com/lukaszraczylo/go-telegram" target="_blank" class="hover:text-white transition"><i class="fab fa-github mr-2"></i>GitHub</a></li>
<li><a href="https://github.com/lukaszraczylo/go-telegram/issues" target="_blank" class="hover:text-white transition"><i class="fas fa-bug mr-2"></i>Issues</a></li>
<li><a href="https://github.com/lukaszraczylo/go-telegram/releases" target="_blank" class="hover:text-white transition"><i class="fas fa-tag mr-2"></i>Releases</a></li>
</ul>
</div>
<div>
<h3 class="text-white font-semibold mb-3 sm:mb-4 text-sm sm:text-base">Docs</h3>
<ul class="space-y-1.5 sm:space-y-2 text-xs sm:text-sm">
<li><a href="https://pkg.go.dev/github.com/lukaszraczylo/go-telegram" target="_blank" class="hover:text-white transition"><i class="fas fa-book mr-1"></i> pkg.go.dev</a></li>
<li><a href="https://core.telegram.org/bots/api" target="_blank" class="hover:text-white transition">Telegram Bot API</a></li>
<li><a href="https://github.com/lukaszraczylo/go-telegram#readme" target="_blank" class="hover:text-white transition">README</a></li>
</ul>
</div>
<div class="col-span-2 sm:col-span-1">
<h3 class="text-white font-semibold mb-3 sm:mb-4 text-sm sm:text-base">Sibling projects</h3>
<ul class="space-y-1.5 sm:space-y-2 text-xs sm:text-sm">
<li><a href="https://kportal.raczylo.com" target="_blank" class="hover:text-white transition"><i class="fas fa-dharmachakra mr-2"></i>kportal</a></li>
</ul>
</div>
</div>
<div class="mt-6 sm:mt-8 pt-6 sm:pt-8 border-t border-gray-800 text-center text-xs sm:text-sm">
<p>Made by <a href="https://github.com/lukaszraczylo" class="text-sky-400 hover:text-sky-300 transition">Lukasz Raczylo</a></p>
<p class="mt-1.5 sm:mt-2">MIT License</p>
</div>
</div>
</footer>
<script>
// Mobile menu toggle
const mobileMenuToggle = document.getElementById("mobile-menu-toggle");
const mobileMenu = document.getElementById("mobile-menu");
const menuOpenIcon = document.getElementById("menu-open-icon");
const menuCloseIcon = document.getElementById("menu-close-icon");
mobileMenuToggle.addEventListener("click", () => {
mobileMenu.classList.toggle("hidden");
menuOpenIcon.classList.toggle("hidden");
menuCloseIcon.classList.toggle("hidden");
});
const mobileMenuLinks = mobileMenu.querySelectorAll("a");
mobileMenuLinks.forEach(link => {
link.addEventListener("click", () => {
mobileMenu.classList.add("hidden");
menuOpenIcon.classList.remove("hidden");
menuCloseIcon.classList.add("hidden");
});
});
// Theme toggle
const themeToggle = document.getElementById("theme-toggle");
themeToggle.addEventListener("click", () => {
if (document.documentElement.classList.contains("dark")) {
document.documentElement.classList.remove("dark");
localStorage.theme = "light";
} else {
document.documentElement.classList.add("dark");
localStorage.theme = "dark";
}
});
// Copy to clipboard
function copyToClipboard(text, button) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => showCopySuccess(button)).catch(() => fallbackCopy(text, button));
} else {
fallbackCopy(text, button);
}
}
function fallbackCopy(text, button) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand("copy") ? showCopySuccess(button) : showCopyError(button);
} catch (err) {
showCopyError(button);
}
document.body.removeChild(textarea);
}
function showCopySuccess(button) {
const original = button.innerHTML;
button.innerHTML = '<i class="fas fa-check text-green-500"></i>';
setTimeout(() => button.innerHTML = original, 2000);
}
function showCopyError(button) {
const original = button.innerHTML;
button.innerHTML = '<i class="fas fa-times text-red-500"></i>';
setTimeout(() => button.innerHTML = original, 2000);
}
// Smooth scrolling
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener("click", function(e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute("href"));
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
</script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 56" fill="none">
<!-- Paper plane glyph in Telegram blue gradient -->
<defs>
<linearGradient id="planeGradD" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#29B6F6"/>
<stop offset="100%" stop-color="#4FC3F7"/>
</linearGradient>
</defs>
<!-- Plane body -->
<polygon points="4,28 44,12 36,44" fill="url(#planeGradD)" opacity="0.15"/>
<polygon points="4,28 44,12 36,44" stroke="url(#planeGradD)" stroke-width="1.5" fill="none"/>
<!-- Plane wings / send arrow -->
<path d="M4 28 L44 12 L30 30 Z" fill="url(#planeGradD)"/>
<path d="M30 30 L36 44 L24 34 Z" fill="url(#planeGradD)" opacity="0.7"/>
<!-- Fold line -->
<line x1="30" y1="30" x2="24" y2="34" stroke="white" stroke-width="1" opacity="0.8"/>
<!-- Wordmark -->
<text x="56" y="35" font-family="Inter, sans-serif" font-weight="700" font-size="20" fill="#e5e7eb" letter-spacing="-0.5">go-telegram</text>
</svg>

After

Width:  |  Height:  |  Size: 977 B

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 56" fill="none">
<!-- Paper plane glyph in Telegram blue gradient -->
<defs>
<linearGradient id="planeGradL" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#29B6F6"/>
<stop offset="100%" stop-color="#0288D1"/>
</linearGradient>
</defs>
<!-- Plane body -->
<polygon points="4,28 44,12 36,44" fill="url(#planeGradL)" opacity="0.15"/>
<polygon points="4,28 44,12 36,44" stroke="url(#planeGradL)" stroke-width="1.5" fill="none"/>
<!-- Plane wings / send arrow -->
<path d="M4 28 L44 12 L30 30 Z" fill="url(#planeGradL)"/>
<path d="M30 30 L36 44 L24 34 Z" fill="url(#planeGradL)" opacity="0.7"/>
<!-- Fold line -->
<line x1="30" y1="30" x2="24" y2="34" stroke="white" stroke-width="1" opacity="0.8"/>
<!-- Wordmark -->
<text x="56" y="35" font-family="Inter, sans-serif" font-weight="700" font-size="20" fill="#1f2937" letter-spacing="-0.5">go-telegram</text>
</svg>

After

Width:  |  Height:  |  Size: 977 B

+25
View File
@@ -0,0 +1,25 @@
# API Reference
Auto-generated from Go source comments by [gomarkdoc](https://github.com/princjef/gomarkdoc). Do not edit by hand — run `make docs` to regenerate.
## Packages
| Package | Description |
|---|---|
| [`api`](api.md) | Telegram Bot API types and method wrappers — 176 methods, 301 types, fully generated |
| [`client`](client.md) | Bot client, codec, HTTP doer, retry middleware |
| [`transport`](transport.md) | Long-poll and webhook transports |
| [`dispatch`](dispatch.md) | Update router, filters, handler groups, named handlers |
| [`dispatch/conversation`](dispatch/conversation.md) | Multi-step conversation state machines |
| [`dispatch/filters/message`](dispatch/filters/message.md) | Message filters — `Command`, `Text`, `IsReply`, etc. |
| [`dispatch/filters/callback`](dispatch/filters/callback.md) | Callback query filters |
| [`dispatch/filters/inline`](dispatch/filters/inline.md) | Inline query filters |
| [`dispatch/filters/chatmember`](dispatch/filters/chatmember.md) | Chat member update filters |
| [`dispatch/filters/chatjoinrequest`](dispatch/filters/chatjoinrequest.md) | Join request filters |
| [`dispatch/filters/precheckoutquery`](dispatch/filters/precheckoutquery.md) | Pre-checkout filters for payments |
## Also see
- [Project home](../index.html) — landing page with examples and overview
- [GitHub repository](https://github.com/lukaszraczylo/go-telegram)
- [pkg.go.dev](https://pkg.go.dev/github.com/lukaszraczylo/go-telegram) — official Go package documentation
+16100
View File
File diff suppressed because it is too large Load Diff
+579
View File
@@ -0,0 +1,579 @@
<!-- Code generated by gomarkdoc. DO NOT EDIT -->
# client
```go
import "github.com/lukaszraczylo/go-telegram/client"
```
Package client provides HTTP client primitives for the Telegram Bot API.
## Index
- [Variables](<#variables>)
- [func Call\[Req any, Resp any\]\(ctx context.Context, b \*Bot, method string, req Req\) \(Resp, error\)](<#Call>)
- [func CallRaw\[Req any\]\(ctx context.Context, b \*Bot, method string, req Req\) \(json.RawMessage, error\)](<#CallRaw>)
- [func NewDefaultHTTPDoer\(\) \*http.Client](<#NewDefaultHTTPDoer>)
- [type APIError](<#APIError>)
- [func \(e \*APIError\) Error\(\) string](<#APIError.Error>)
- [func \(e \*APIError\) IsRetryable\(\) bool](<#APIError.IsRetryable>)
- [func \(e \*APIError\) RetryAfter\(\) time.Duration](<#APIError.RetryAfter>)
- [func \(e \*APIError\) Unwrap\(\) error](<#APIError.Unwrap>)
- [type Bot](<#Bot>)
- [func New\(token string, opts ...Option\) \*Bot](<#New>)
- [func \(b \*Bot\) BaseURL\(\) string](<#Bot.BaseURL>)
- [func \(b \*Bot\) Codec\(\) Codec](<#Bot.Codec>)
- [func \(b \*Bot\) HTTP\(\) HTTPDoer](<#Bot.HTTP>)
- [func \(b \*Bot\) Logger\(\) Logger](<#Bot.Logger>)
- [func \(b \*Bot\) Token\(\) string](<#Bot.Token>)
- [type Codec](<#Codec>)
- [type DefaultCodec](<#DefaultCodec>)
- [func \(DefaultCodec\) Marshal\(v any\) \(\[\]byte, error\)](<#DefaultCodec.Marshal>)
- [func \(DefaultCodec\) Unmarshal\(data \[\]byte, v any\) error](<#DefaultCodec.Unmarshal>)
- [type HTTPDoer](<#HTTPDoer>)
- [type Logger](<#Logger>)
- [type MultipartFile](<#MultipartFile>)
- [type NetworkError](<#NetworkError>)
- [func \(e \*NetworkError\) Error\(\) string](<#NetworkError.Error>)
- [func \(e \*NetworkError\) Unwrap\(\) error](<#NetworkError.Unwrap>)
- [type NoopLogger](<#NoopLogger>)
- [func \(NoopLogger\) Debug\(string, ...any\)](<#NoopLogger.Debug>)
- [func \(NoopLogger\) Error\(string, ...any\)](<#NoopLogger.Error>)
- [func \(NoopLogger\) Info\(string, ...any\)](<#NoopLogger.Info>)
- [func \(NoopLogger\) Warn\(string, ...any\)](<#NoopLogger.Warn>)
- [type Option](<#Option>)
- [func WithBaseURL\(url string\) Option](<#WithBaseURL>)
- [func WithCodec\(c Codec\) Option](<#WithCodec>)
- [func WithHTTPClient\(c HTTPDoer\) Option](<#WithHTTPClient>)
- [func WithLogger\(l Logger\) Option](<#WithLogger>)
- [type ParseError](<#ParseError>)
- [func \(e \*ParseError\) Error\(\) string](<#ParseError.Error>)
- [func \(e \*ParseError\) Unwrap\(\) error](<#ParseError.Unwrap>)
- [type ResponseParameters](<#ResponseParameters>)
- [type Result](<#Result>)
- [type RetryDoer](<#RetryDoer>)
- [func NewRetryDoer\(inner HTTPDoer, opts ...RetryOption\) \*RetryDoer](<#NewRetryDoer>)
- [func \(d \*RetryDoer\) Do\(req \*http.Request\) \(\*http.Response, error\)](<#RetryDoer.Do>)
- [type RetryOption](<#RetryOption>)
- [func WithBackoffFactor\(f float64\) RetryOption](<#WithBackoffFactor>)
- [func WithBaseBackoff\(d time.Duration\) RetryOption](<#WithBaseBackoff>)
- [func WithJitter\(j float64\) RetryOption](<#WithJitter>)
- [func WithMaxAttempts\(n int\) RetryOption](<#WithMaxAttempts>)
- [func WithMaxBackoff\(d time.Duration\) RetryOption](<#WithMaxBackoff>)
## Variables
<a name="ErrUnauthorized"></a>Sentinel errors returned via APIError.Unwrap when the description matches. Compare with errors.Is.
```go
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")
ErrUserNotFound = errors.New("telegram: user not found")
ErrMessageNotFound = errors.New("telegram: message not found")
)
```
<a name="Call"></a>
## func [Call](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/call.go#L25>)
```go
func Call[Req any, Resp any](ctx context.Context, b *Bot, method string, req Req) (Resp, error)
```
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.
<a name="CallRaw"></a>
## func [CallRaw](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/call.go#L74>)
```go
func CallRaw[Req any](ctx context.Context, b *Bot, method string, req Req) (json.RawMessage, error)
```
CallRaw is like Call but returns the raw JSON of the result field instead of decoding it into a typed value. Generated method wrappers for sealed\-interface return types \(ChatMember, MenuButton, etc.\) use this helper, then dispatch through the union's UnmarshalXxx function.
CallRaw still translates non\-OK responses into \*APIError just like Call.
<a name="NewDefaultHTTPDoer"></a>
## func [NewDefaultHTTPDoer](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/httpclient.go#L22>)
```go
func NewDefaultHTTPDoer() *http.Client
```
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\).
<a name="APIError"></a>
## type [APIError](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/errors.go#L13-L21>)
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.
```go
type APIError struct {
Code int
Description string
Parameters *ResponseParameters
// contains filtered or unexported fields
}
```
<a name="APIError.Error"></a>
### func \(\*APIError\) [Error](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/errors.go#L24>)
```go
func (e *APIError) Error() string
```
Error implements error.
<a name="APIError.IsRetryable"></a>
### func \(\*APIError\) [IsRetryable](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/errors.go#L32>)
```go
func (e *APIError) IsRetryable() bool
```
IsRetryable returns true for transient HTTP statuses \(429, 5xx\).
<a name="APIError.RetryAfter"></a>
### func \(\*APIError\) [RetryAfter](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/errors.go#L38>)
```go
func (e *APIError) RetryAfter() time.Duration
```
RetryAfter returns the recommended back\-off duration. It honours the Telegram\-supplied retry\_after parameter; if absent, returns 0.
<a name="APIError.Unwrap"></a>
### func \(\*APIError\) [Unwrap](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/errors.go#L29>)
```go
func (e *APIError) Unwrap() error
```
Unwrap returns the matched sentinel error, if any.
<a name="Bot"></a>
## type [Bot](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/client.go#L7-L13>)
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.
```go
type Bot struct {
// contains filtered or unexported fields
}
```
<a name="New"></a>
### func [New](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/client.go#L36>)
```go
func New(token string, opts ...Option) *Bot
```
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.
<a name="Bot.BaseURL"></a>
### func \(\*Bot\) [BaseURL](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/client.go#L20>)
```go
func (b *Bot) BaseURL() string
```
BaseURL returns the configured Telegram API base URL.
<a name="Bot.Codec"></a>
### func \(\*Bot\) [Codec](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/client.go#L27>)
```go
func (b *Bot) Codec() Codec
```
Codec returns the configured Codec.
<a name="Bot.HTTP"></a>
### func \(\*Bot\) [HTTP](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/client.go#L24>)
```go
func (b *Bot) HTTP() HTTPDoer
```
HTTP returns the underlying HTTPDoer. Exposed for adapters that need to share connection pools or for diagnostic checks.
<a name="Bot.Logger"></a>
### func \(\*Bot\) [Logger](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/client.go#L30>)
```go
func (b *Bot) Logger() Logger
```
Logger returns the configured Logger.
<a name="Bot.Token"></a>
### func \(\*Bot\) [Token](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/client.go#L17>)
```go
func (b *Bot) Token() string
```
Token returns the bot token. Exposed for advanced use cases \(custom transports, manual URL building\); ordinary code does not need it.
<a name="Codec"></a>
## type [Codec](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/codec.go#L10-L13>)
Codec encodes/decodes JSON payloads exchanged with the Telegram Bot API. The default implementation wraps goccy/go\-json. Users may plug in bytedance/sonic or any compatible encoder by passing WithCodec to New.
```go
type Codec interface {
Marshal(v any) ([]byte, error)
Unmarshal(data []byte, v any) error
}
```
<a name="DefaultCodec"></a>
## type [DefaultCodec](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/codec.go#L16>)
DefaultCodec wraps goccy/go\-json. It is the zero\-value safe default.
```go
type DefaultCodec struct{}
```
<a name="DefaultCodec.Marshal"></a>
### func \(DefaultCodec\) [Marshal](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/codec.go#L19>)
```go
func (DefaultCodec) Marshal(v any) ([]byte, error)
```
Marshal calls json.Marshal.
<a name="DefaultCodec.Unmarshal"></a>
### func \(DefaultCodec\) [Unmarshal](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/codec.go#L22>)
```go
func (DefaultCodec) Unmarshal(data []byte, v any) error
```
Unmarshal calls json.Unmarshal.
<a name="HTTPDoer"></a>
## type [HTTPDoer](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/httpclient.go#L13-L15>)
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.
```go
type HTTPDoer interface {
Do(req *http.Request) (*http.Response, error)
}
```
<a name="Logger"></a>
## type [Logger](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/logger.go#L6-L11>)
Logger is a slog\-shaped logging interface. Users pass any compatible implementation via WithLogger. The default is NoopLogger, which discards everything.
```go
type Logger interface {
Debug(msg string, attrs ...any)
Info(msg string, attrs ...any)
Warn(msg string, attrs ...any)
Error(msg string, attrs ...any)
}
```
<a name="MultipartFile"></a>
## type [MultipartFile](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/multipart.go#L27-L31>)
MultipartFile describes a single file part in a multipart upload.
```go
type MultipartFile struct {
FieldName string
Filename string
Reader io.Reader
}
```
<a name="NetworkError"></a>
## type [NetworkError](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/errors.go#L47>)
NetworkError wraps a transport\-level failure \(DNS, TCP, TLS, timeout short of an HTTP response\).
```go
type NetworkError struct{ Err error }
```
<a name="NetworkError.Error"></a>
### func \(\*NetworkError\) [Error](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/errors.go#L49>)
```go
func (e *NetworkError) Error() string
```
<a name="NetworkError.Unwrap"></a>
### func \(\*NetworkError\) [Unwrap](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/errors.go#L51>)
```go
func (e *NetworkError) Unwrap() error
```
<a name="NoopLogger"></a>
## type [NoopLogger](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/logger.go#L14>)
NoopLogger discards all log records. It is the zero\-value safe default.
```go
type NoopLogger struct{}
```
<a name="NoopLogger.Debug"></a>
### func \(NoopLogger\) [Debug](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/logger.go#L16>)
```go
func (NoopLogger) Debug(string, ...any)
```
<a name="NoopLogger.Error"></a>
### func \(NoopLogger\) [Error](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/logger.go#L19>)
```go
func (NoopLogger) Error(string, ...any)
```
<a name="NoopLogger.Info"></a>
### func \(NoopLogger\) [Info](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/logger.go#L17>)
```go
func (NoopLogger) Info(string, ...any)
```
<a name="NoopLogger.Warn"></a>
### func \(NoopLogger\) [Warn](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/logger.go#L18>)
```go
func (NoopLogger) Warn(string, ...any)
```
<a name="Option"></a>
## type [Option](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/options.go#L5>)
Option configures a Bot at construction time. Per\-call configuration is expressed via typed parameter structs \(e.g. SendMessageParams\), not options.
```go
type Option func(*Bot)
```
<a name="WithBaseURL"></a>
### func [WithBaseURL](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/options.go#L18>)
```go
func WithBaseURL(url string) Option
```
WithBaseURL overrides the API base URL. Useful for testing against a local httptest.Server, or for self\-hosted Bot API servers.
<a name="WithCodec"></a>
### func [WithCodec](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/options.go#L14>)
```go
func WithCodec(c Codec) Option
```
WithCodec overrides the JSON codec. Pass goccy/go\-json, sonic, or any type implementing Codec to swap out encoding/json.
<a name="WithHTTPClient"></a>
### func [WithHTTPClient](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/options.go#L10>)
```go
func WithHTTPClient(c HTTPDoer) Option
```
WithHTTPClient overrides the HTTP transport. Pass any HTTPDoer implementation \(e.g. an \*http.Client wrapping a custom RoundTripper, or a fasthttp adapter\).
<a name="WithLogger"></a>
### func [WithLogger](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/options.go#L22>)
```go
func WithLogger(l Logger) Option
```
WithLogger sets the logger used for diagnostic events. Passing nil silently disables logging.
<a name="ParseError"></a>
## type [ParseError](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/errors.go#L55-L58>)
ParseError wraps a JSON decode failure on a response body. Body is retained \(truncated to 4 KiB\); Error\(\) displays up to 256 bytes for diagnostics.
```go
type ParseError struct {
Err error
Body []byte
}
```
<a name="ParseError.Error"></a>
### func \(\*ParseError\) [Error](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/errors.go#L60>)
```go
func (e *ParseError) Error() string
```
<a name="ParseError.Unwrap"></a>
### func \(\*ParseError\) [Unwrap](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/errors.go#L68>)
```go
func (e *ParseError) Unwrap() error
```
<a name="ResponseParameters"></a>
## type [ResponseParameters](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/result.go#L24-L27>)
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\).
```go
type ResponseParameters struct {
MigrateToChatID int64 `json:"migrate_to_chat_id,omitempty"`
RetryAfter int `json:"retry_after,omitempty"`
}
```
<a name="Result"></a>
## type [Result](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/result.go#L11-L17>)
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.
```go
type Result[T any] struct {
OK bool `json:"ok"`
Result T `json:"result,omitempty"`
ErrorCode int `json:"error_code,omitempty"`
Description string `json:"description,omitempty"`
Parameters *ResponseParameters `json:"parameters,omitempty"`
}
```
<a name="RetryDoer"></a>
## type [RetryDoer](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/retry.go#L23-L30>)
RetryDoer is an HTTPDoer that retries transient failures \(429, 5xx, and network errors\) with exponential backoff. It honours the retry\_after value Telegram supplies on rate\-limit responses.
Wrap any HTTPDoer to add retry behaviour:
```
bot := client.New(token, client.WithHTTPClient(
client.NewRetryDoer(client.NewDefaultHTTPDoer())))
```
```go
type RetryDoer struct {
// contains filtered or unexported fields
}
```
<a name="NewRetryDoer"></a>
### func [NewRetryDoer](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/retry.go#L63>)
```go
func NewRetryDoer(inner HTTPDoer, opts ...RetryOption) *RetryDoer
```
NewRetryDoer wraps inner with retry behaviour.
<a name="RetryDoer.Do"></a>
### func \(\*RetryDoer\) [Do](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/retry.go#L80>)
```go
func (d *RetryDoer) Do(req *http.Request) (*http.Response, error)
```
Do dispatches via the inner HTTPDoer and retries on transient failures. The request body is buffered on first attempt so it can be replayed.
<a name="RetryOption"></a>
## type [RetryOption](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/retry.go#L33>)
RetryOption configures a RetryDoer.
```go
type RetryOption func(*RetryDoer)
```
<a name="WithBackoffFactor"></a>
### func [WithBackoffFactor](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/retry.go#L52>)
```go
func WithBackoffFactor(f float64) RetryOption
```
WithBackoffFactor sets the exponential growth factor. Default 2.0.
<a name="WithBaseBackoff"></a>
### func [WithBaseBackoff](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/retry.go#L42>)
```go
func WithBaseBackoff(d time.Duration) RetryOption
```
WithBaseBackoff sets the initial backoff duration. Default 500ms.
<a name="WithJitter"></a>
### func [WithJitter](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/retry.go#L58>)
```go
func WithJitter(j float64) RetryOption
```
WithJitter sets the jitter fraction \(0..1\) applied to each backoff. Default 0.2.
<a name="WithMaxAttempts"></a>
### func [WithMaxAttempts](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/retry.go#L37>)
```go
func WithMaxAttempts(n int) RetryOption
```
WithMaxAttempts sets the maximum number of attempts \(including the initial one\). Default 4 \(one initial \+ three retries\).
<a name="WithMaxBackoff"></a>
### func [WithMaxBackoff](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/retry.go#L47>)
```go
func WithMaxBackoff(d time.Duration) RetryOption
```
WithMaxBackoff caps the backoff at max. Default 30s.
Generated by [gomarkdoc](<https://github.com/princjef/gomarkdoc>)
+668
View File
@@ -0,0 +1,668 @@
<!-- Code generated by gomarkdoc. DO NOT EDIT -->
# dispatch
```go
import "github.com/lukaszraczylo/go-telegram/dispatch"
```
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.
## Index
- [Variables](<#variables>)
- [type Context](<#Context>)
- [func NewContext\(ctx context.Context, b \*client.Bot, u \*api.Update\) \*Context](<#NewContext>)
- [type Filter](<#Filter>)
- [func All\[T any\]\(filters ...Filter\[T\]\) Filter\[T\]](<#All>)
- [func Any\[T any\]\(filters ...Filter\[T\]\) Filter\[T\]](<#Any>)
- [func \(f Filter\[T\]\) And\(others ...Filter\[T\]\) Filter\[T\]](<#Filter[T].And>)
- [func \(f Filter\[T\]\) Not\(\) Filter\[T\]](<#Filter[T].Not>)
- [func \(f Filter\[T\]\) Or\(others ...Filter\[T\]\) Filter\[T\]](<#Filter[T].Or>)
- [type Handler](<#Handler>)
- [type Middleware](<#Middleware>)
- [func Chain\[T any\]\(mws ...Middleware\[T\]\) Middleware\[T\]](<#Chain>)
- [func Recovery\(\) Middleware\[\*api.Update\]](<#Recovery>)
- [type NamedHandlers](<#NamedHandlers>)
- [func NewNamedHandlers\[T any\]\(\) \*NamedHandlers\[T\]](<#NewNamedHandlers>)
- [func \(n \*NamedHandlers\[T\]\) Handler\(\) Handler\[T\]](<#NamedHandlers[T].Handler>)
- [func \(n \*NamedHandlers\[T\]\) Has\(name string\) bool](<#NamedHandlers[T].Has>)
- [func \(n \*NamedHandlers\[T\]\) Names\(\) \[\]string](<#NamedHandlers[T].Names>)
- [func \(n \*NamedHandlers\[T\]\) Remove\(name string\) bool](<#NamedHandlers[T].Remove>)
- [func \(n \*NamedHandlers\[T\]\) Set\(name string, h Handler\[T\]\)](<#NamedHandlers[T].Set>)
- [type Router](<#Router>)
- [func New\(b \*client.Bot, opts ...RouterOption\) \*Router](<#New>)
- [func \(r \*Router\) Group\(group int\) \*RouterScope](<#Router.Group>)
- [func \(r \*Router\) OnBusinessConnection\(h Handler\[\*api.BusinessConnection\]\)](<#Router.OnBusinessConnection>)
- [func \(r \*Router\) OnCallback\(pattern string, h Handler\[\*api.CallbackQuery\]\)](<#Router.OnCallback>)
- [func \(r \*Router\) OnCallbackFilter\(f Filter\[\*api.CallbackQuery\], h Handler\[\*api.CallbackQuery\]\)](<#Router.OnCallbackFilter>)
- [func \(r \*Router\) OnChannelPost\(h Handler\[\*api.Message\]\)](<#Router.OnChannelPost>)
- [func \(r \*Router\) OnChatBoost\(h Handler\[\*api.ChatBoostUpdated\]\)](<#Router.OnChatBoost>)
- [func \(r \*Router\) OnChatJoinRequest\(h Handler\[\*api.ChatJoinRequest\]\)](<#Router.OnChatJoinRequest>)
- [func \(r \*Router\) OnChatJoinRequestFilter\(f Filter\[\*api.ChatJoinRequest\], h Handler\[\*api.ChatJoinRequest\]\)](<#Router.OnChatJoinRequestFilter>)
- [func \(r \*Router\) OnChatMember\(h Handler\[\*api.ChatMemberUpdated\]\)](<#Router.OnChatMember>)
- [func \(r \*Router\) OnChatMemberFilter\(f Filter\[\*api.ChatMemberUpdated\], h Handler\[\*api.ChatMemberUpdated\]\)](<#Router.OnChatMemberFilter>)
- [func \(r \*Router\) OnChosenInlineResult\(h Handler\[\*api.ChosenInlineResult\]\)](<#Router.OnChosenInlineResult>)
- [func \(r \*Router\) OnCommand\(cmd string, h Handler\[\*api.Message\]\)](<#Router.OnCommand>)
- [func \(r \*Router\) OnEditedChannelPost\(h Handler\[\*api.Message\]\)](<#Router.OnEditedChannelPost>)
- [func \(r \*Router\) OnEditedMessage\(h Handler\[\*api.Message\]\)](<#Router.OnEditedMessage>)
- [func \(r \*Router\) OnInlineQuery\(h Handler\[\*api.InlineQuery\]\)](<#Router.OnInlineQuery>)
- [func \(r \*Router\) OnInlineQueryFilter\(f Filter\[\*api.InlineQuery\], h Handler\[\*api.InlineQuery\]\)](<#Router.OnInlineQueryFilter>)
- [func \(r \*Router\) OnMessageFilter\(f Filter\[\*api.Message\], h Handler\[\*api.Message\]\)](<#Router.OnMessageFilter>)
- [func \(r \*Router\) OnMessageReaction\(h Handler\[\*api.MessageReactionUpdated\]\)](<#Router.OnMessageReaction>)
- [func \(r \*Router\) OnMessageReactionCount\(h Handler\[\*api.MessageReactionCountUpdated\]\)](<#Router.OnMessageReactionCount>)
- [func \(r \*Router\) OnMyChatMember\(h Handler\[\*api.ChatMemberUpdated\]\)](<#Router.OnMyChatMember>)
- [func \(r \*Router\) OnMyChatMemberFilter\(f Filter\[\*api.ChatMemberUpdated\], h Handler\[\*api.ChatMemberUpdated\]\)](<#Router.OnMyChatMemberFilter>)
- [func \(r \*Router\) OnPoll\(h Handler\[\*api.Poll\]\)](<#Router.OnPoll>)
- [func \(r \*Router\) OnPollAnswer\(h Handler\[\*api.PollAnswer\]\)](<#Router.OnPollAnswer>)
- [func \(r \*Router\) OnPreCheckoutQuery\(h Handler\[\*api.PreCheckoutQuery\]\)](<#Router.OnPreCheckoutQuery>)
- [func \(r \*Router\) OnPreCheckoutQueryFilter\(f Filter\[\*api.PreCheckoutQuery\], h Handler\[\*api.PreCheckoutQuery\]\)](<#Router.OnPreCheckoutQueryFilter>)
- [func \(r \*Router\) OnPurchasedPaidMedia\(h Handler\[\*api.PaidMediaPurchased\]\)](<#Router.OnPurchasedPaidMedia>)
- [func \(r \*Router\) OnRemovedChatBoost\(h Handler\[\*api.ChatBoostRemoved\]\)](<#Router.OnRemovedChatBoost>)
- [func \(r \*Router\) OnShippingQuery\(h Handler\[\*api.ShippingQuery\]\)](<#Router.OnShippingQuery>)
- [func \(r \*Router\) OnText\(pattern string, h Handler\[\*api.Message\]\)](<#Router.OnText>)
- [func \(r \*Router\) Run\(ctx context.Context, u transport.Updater\) error](<#Router.Run>)
- [func \(r \*Router\) Use\(mw Middleware\[\*api.Update\]\)](<#Router.Use>)
- [type RouterOption](<#RouterOption>)
- [func WithMaxConcurrency\(n int\) RouterOption](<#WithMaxConcurrency>)
- [type RouterScope](<#RouterScope>)
- [func \(s \*RouterScope\) OnCommand\(cmd string, h Handler\[\*api.Message\]\)](<#RouterScope.OnCommand>)
- [func \(s \*RouterScope\) OnMessageFilter\(f Filter\[\*api.Message\], h Handler\[\*api.Message\]\)](<#RouterScope.OnMessageFilter>)
- [func \(s \*RouterScope\) OnText\(pattern string, h Handler\[\*api.Message\]\)](<#RouterScope.OnText>)
## Variables
<a name="ErrContinueGroups"></a>ErrContinueGroups signals that this group's handler should be treated as not\-matching when returned by a handler: dispatch moves on to the next handler in the same group, then to subsequent groups.
Without ErrContinueGroups, a non\-error return from a matched handler stops dispatch \(default first\-match\-wins semantics\).
```go
var ErrContinueGroups = errors.New("dispatch: continue groups")
```
<a name="ErrEndGroups"></a>ErrEndGroups stops dispatch from running any further handlers in any group for this update when returned by a handler. Use it to indicate the update has been definitively handled.
errors.Is\(err, ErrEndGroups\) is the canonical check, though dispatch itself recognises it by exact identity.
```go
var ErrEndGroups = errors.New("dispatch: end groups")
```
<a name="Context"></a>
## type [Context](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/context.go#L29-L34>)
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
```
```go
type Context struct {
Ctx context.Context
Bot *client.Bot
Update *api.Update
Values map[string]any
}
```
<a name="NewContext"></a>
### func [NewContext](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/context.go#L38>)
```go
func NewContext(ctx context.Context, b *client.Bot, u *api.Update) *Context
```
NewContext constructs a Context. Used by Router internally; exposed for custom test harnesses.
<a name="Filter"></a>
## type [Filter](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filter.go#L9>)
Filter is a predicate over a typed payload \(e.g. \*api.Message\). Filters compose via And/Or/Not for multi\-condition matching.
Example:
```
f := message.HasPhoto().And(message.InChat(-100123456789))
```
```go
type Filter[T any] func(payload T) bool
```
<a name="All"></a>
### func [All](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filter.go#L48>)
```go
func All[T any](filters ...Filter[T]) Filter[T]
```
All combines filters with AND. Returns a Filter that matches when all match. Returns a filter that always matches when filters is empty.
<a name="Any"></a>
### func [Any](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filter.go#L61>)
```go
func Any[T any](filters ...Filter[T]) Filter[T]
```
Any combines filters with OR. Returns a Filter that matches when at least one matches. Returns a filter that never matches when filters is empty.
<a name="Filter[T].And"></a>
### func \(Filter\[T\]\) [And](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filter.go#L12>)
```go
func (f Filter[T]) And(others ...Filter[T]) Filter[T]
```
And returns a Filter that matches iff f and every one of others matches.
<a name="Filter[T].Not"></a>
### func \(Filter\[T\]\) [Not](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filter.go#L42>)
```go
func (f Filter[T]) Not() Filter[T]
```
Not returns a Filter that inverts f.
<a name="Filter[T].Or"></a>
### func \(Filter\[T\]\) [Or](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filter.go#L27>)
```go
func (f Filter[T]) Or(others ...Filter[T]) Filter[T]
```
Or returns a Filter that matches iff f matches OR any of others matches.
<a name="Handler"></a>
## type [Handler](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/handler.go#L6>)
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.
```go
type Handler[T any] func(ctx *Context, payload T) error
```
<a name="Middleware"></a>
## type [Middleware](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/handler.go#L11>)
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\)\)\).
```go
type Middleware[T any] func(Handler[T]) Handler[T]
```
<a name="Chain"></a>
### func [Chain](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/handler.go#L14>)
```go
func Chain[T any](mws ...Middleware[T]) Middleware[T]
```
Chain composes a slice of middleware into a single Middleware\[T\].
<a name="Recovery"></a>
### func [Recovery](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/middleware.go#L13>)
```go
func Recovery() Middleware[*api.Update]
```
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.
<a name="NamedHandlers"></a>
## type [NamedHandlers](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/named.go#L15-L19>)
NamedHandlers manages handlers by string name, allowing runtime registration, replacement, and removal. This complements the Router's registration methods: each registration via Named\*\(\) also gets a name for later lookup.
Use case: a plugin system that loads/unloads command handlers without restarting the bot.
```go
type NamedHandlers[T any] struct {
// contains filtered or unexported fields
}
```
<a name="NewNamedHandlers"></a>
### func [NewNamedHandlers](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/named.go#L22>)
```go
func NewNamedHandlers[T any]() *NamedHandlers[T]
```
NewNamedHandlers returns a new, empty NamedHandlers\[T\].
<a name="NamedHandlers[T].Handler"></a>
### func \(\*NamedHandlers\[T\]\) [Handler](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/named.go#L81>)
```go
func (n *NamedHandlers[T]) Handler() Handler[T]
```
Handler returns a single Handler\[T\] that runs each registered handler in registration order, first non\-nil error stops the chain. Use this to wire NamedHandlers into a Router.OnXxx call:
```
names := dispatch.NewNamedHandlers[*api.Message]()
names.Set("logger", loggingHandler)
names.Set("audit", auditHandler)
router.OnCommand("/admin", names.Handler())
```
Subsequent Set/Remove calls take effect on the next dispatch.
<a name="NamedHandlers[T].Has"></a>
### func \(\*NamedHandlers\[T\]\) [Has](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/named.go#L55>)
```go
func (n *NamedHandlers[T]) Has(name string) bool
```
Has reports whether name is registered.
<a name="NamedHandlers[T].Names"></a>
### func \(\*NamedHandlers\[T\]\) [Names](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/named.go#L63>)
```go
func (n *NamedHandlers[T]) Names() []string
```
Names returns the registered names in registration order.
<a name="NamedHandlers[T].Remove"></a>
### func \(\*NamedHandlers\[T\]\) [Remove](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/named.go#L38>)
```go
func (n *NamedHandlers[T]) Remove(name string) bool
```
Remove unregisters the handler under name. Returns true if it existed.
<a name="NamedHandlers[T].Set"></a>
### func \(\*NamedHandlers\[T\]\) [Set](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/named.go#L28>)
```go
func (n *NamedHandlers[T]) Set(name string, h Handler[T])
```
Set registers or replaces the handler under name. If name is new, it is appended to the end of the registration order.
<a name="Router"></a>
## type [Router](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L19-L64>)
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.
```go
type Router struct {
// contains filtered or unexported fields
}
```
<a name="New"></a>
### func [New](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L128>)
```go
func New(b *client.Bot, opts ...RouterOption) *Router
```
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\).
<a name="Router.Group"></a>
### func \(\*Router\) [Group](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/groups.go#L40>)
```go
func (r *Router) Group(group int) *RouterScope
```
Group returns a RouterScope that registers handlers in the given group. Group 0 \(the default\) runs first, then group 1, etc. Within a group, handlers run in registration order; the first non\-skipped match terminates dispatch unless the handler returns ErrContinueGroups.
<a name="Router.OnBusinessConnection"></a>
### func \(\*Router\) [OnBusinessConnection](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L285>)
```go
func (r *Router) OnBusinessConnection(h Handler[*api.BusinessConnection])
```
OnBusinessConnection registers a handler for business connection updates.
<a name="Router.OnCallback"></a>
### func \(\*Router\) [OnCallback](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L161>)
```go
func (r *Router) OnCallback(pattern string, h Handler[*api.CallbackQuery])
```
OnCallback registers a handler for callback queries whose Data matches the regex.
Panics at registration time if pattern is not a valid regular expression.
<a name="Router.OnCallbackFilter"></a>
### func \(\*Router\) [OnCallbackFilter](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L194>)
```go
func (r *Router) OnCallbackFilter(f Filter[*api.CallbackQuery], h Handler[*api.CallbackQuery])
```
OnCallbackFilter registers a typed callback\-query handler gated by filter f. Filter routes are checked after pattern\-based OnCallback routes; first match wins.
<a name="Router.OnChannelPost"></a>
### func \(\*Router\) [OnChannelPost](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L177>)
```go
func (r *Router) OnChannelPost(h Handler[*api.Message])
```
OnChannelPost registers a handler for channel post updates.
<a name="Router.OnChatBoost"></a>
### func \(\*Router\) [OnChatBoost](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L275>)
```go
func (r *Router) OnChatBoost(h Handler[*api.ChatBoostUpdated])
```
OnChatBoost registers a handler for chat boost updates.
<a name="Router.OnChatJoinRequest"></a>
### func \(\*Router\) [OnChatJoinRequest](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L225>)
```go
func (r *Router) OnChatJoinRequest(h Handler[*api.ChatJoinRequest])
```
OnChatJoinRequest registers a handler for chat join requests.
<a name="Router.OnChatJoinRequestFilter"></a>
### func \(\*Router\) [OnChatJoinRequestFilter](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L230>)
```go
func (r *Router) OnChatJoinRequestFilter(f Filter[*api.ChatJoinRequest], h Handler[*api.ChatJoinRequest])
```
OnChatJoinRequestFilter registers a filtered handler for chat join requests.
<a name="Router.OnChatMember"></a>
### func \(\*Router\) [OnChatMember](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L215>)
```go
func (r *Router) OnChatMember(h Handler[*api.ChatMemberUpdated])
```
OnChatMember registers a handler for chat member status changes.
<a name="Router.OnChatMemberFilter"></a>
### func \(\*Router\) [OnChatMemberFilter](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L220>)
```go
func (r *Router) OnChatMemberFilter(f Filter[*api.ChatMemberUpdated], h Handler[*api.ChatMemberUpdated])
```
OnChatMemberFilter registers a filtered handler for chat member status changes.
<a name="Router.OnChosenInlineResult"></a>
### func \(\*Router\) [OnChosenInlineResult](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L260>)
```go
func (r *Router) OnChosenInlineResult(h Handler[*api.ChosenInlineResult])
```
OnChosenInlineResult registers a handler for chosen inline results.
<a name="Router.OnCommand"></a>
### func \(\*Router\) [OnCommand](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L146>)
```go
func (r *Router) OnCommand(cmd string, h Handler[*api.Message])
```
OnCommand registers a handler for a slash command. The command string includes the leading slash \(e.g. "/start"\). Matching strips an optional "@BotName" suffix.
<a name="Router.OnEditedChannelPost"></a>
### func \(\*Router\) [OnEditedChannelPost](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L182>)
```go
func (r *Router) OnEditedChannelPost(h Handler[*api.Message])
```
OnEditedChannelPost registers a handler for edited channel post updates.
<a name="Router.OnEditedMessage"></a>
### func \(\*Router\) [OnEditedMessage](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L172>)
```go
func (r *Router) OnEditedMessage(h Handler[*api.Message])
```
OnEditedMessage registers a handler for edited message updates.
<a name="Router.OnInlineQuery"></a>
### func \(\*Router\) [OnInlineQuery](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L167>)
```go
func (r *Router) OnInlineQuery(h Handler[*api.InlineQuery])
```
OnInlineQuery registers a handler for inline queries \(one matcher only; inline queries are not partitioned by content here\).
<a name="Router.OnInlineQueryFilter"></a>
### func \(\*Router\) [OnInlineQueryFilter](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L200>)
```go
func (r *Router) OnInlineQueryFilter(f Filter[*api.InlineQuery], h Handler[*api.InlineQuery])
```
OnInlineQueryFilter registers an inline\-query handler gated by filter f. Filter routes are checked after bare OnInlineQuery handlers; first match wins.
<a name="Router.OnMessageFilter"></a>
### func \(\*Router\) [OnMessageFilter](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L188>)
```go
func (r *Router) OnMessageFilter(f Filter[*api.Message], h Handler[*api.Message])
```
OnMessageFilter registers a typed message handler gated by filter f. Filter routes are checked after command and text routes; first match wins.
<a name="Router.OnMessageReaction"></a>
### func \(\*Router\) [OnMessageReaction](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L265>)
```go
func (r *Router) OnMessageReaction(h Handler[*api.MessageReactionUpdated])
```
OnMessageReaction registers a handler for message reaction updates.
<a name="Router.OnMessageReactionCount"></a>
### func \(\*Router\) [OnMessageReactionCount](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L270>)
```go
func (r *Router) OnMessageReactionCount(h Handler[*api.MessageReactionCountUpdated])
```
OnMessageReactionCount registers a handler for anonymous message reaction count updates.
<a name="Router.OnMyChatMember"></a>
### func \(\*Router\) [OnMyChatMember](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L205>)
```go
func (r *Router) OnMyChatMember(h Handler[*api.ChatMemberUpdated])
```
OnMyChatMember registers a handler for bot's own chat member status changes.
<a name="Router.OnMyChatMemberFilter"></a>
### func \(\*Router\) [OnMyChatMemberFilter](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L210>)
```go
func (r *Router) OnMyChatMemberFilter(f Filter[*api.ChatMemberUpdated], h Handler[*api.ChatMemberUpdated])
```
OnMyChatMemberFilter registers a filtered handler for bot's own chat member status changes.
<a name="Router.OnPoll"></a>
### func \(\*Router\) [OnPoll](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L250>)
```go
func (r *Router) OnPoll(h Handler[*api.Poll])
```
OnPoll registers a handler for poll state updates.
<a name="Router.OnPollAnswer"></a>
### func \(\*Router\) [OnPollAnswer](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L255>)
```go
func (r *Router) OnPollAnswer(h Handler[*api.PollAnswer])
```
OnPollAnswer registers a handler for poll answer updates.
<a name="Router.OnPreCheckoutQuery"></a>
### func \(\*Router\) [OnPreCheckoutQuery](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L235>)
```go
func (r *Router) OnPreCheckoutQuery(h Handler[*api.PreCheckoutQuery])
```
OnPreCheckoutQuery registers a handler for pre\-checkout queries.
<a name="Router.OnPreCheckoutQueryFilter"></a>
### func \(\*Router\) [OnPreCheckoutQueryFilter](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L240>)
```go
func (r *Router) OnPreCheckoutQueryFilter(f Filter[*api.PreCheckoutQuery], h Handler[*api.PreCheckoutQuery])
```
OnPreCheckoutQueryFilter registers a filtered handler for pre\-checkout queries.
<a name="Router.OnPurchasedPaidMedia"></a>
### func \(\*Router\) [OnPurchasedPaidMedia](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L290>)
```go
func (r *Router) OnPurchasedPaidMedia(h Handler[*api.PaidMediaPurchased])
```
OnPurchasedPaidMedia registers a handler for purchased paid media updates.
<a name="Router.OnRemovedChatBoost"></a>
### func \(\*Router\) [OnRemovedChatBoost](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L280>)
```go
func (r *Router) OnRemovedChatBoost(h Handler[*api.ChatBoostRemoved])
```
OnRemovedChatBoost registers a handler for removed chat boost updates.
<a name="Router.OnShippingQuery"></a>
### func \(\*Router\) [OnShippingQuery](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L245>)
```go
func (r *Router) OnShippingQuery(h Handler[*api.ShippingQuery])
```
OnShippingQuery registers a handler for shipping queries.
<a name="Router.OnText"></a>
### func \(\*Router\) [OnText](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L153>)
```go
func (r *Router) OnText(pattern string, h Handler[*api.Message])
```
OnText registers a handler for messages whose Text matches the regex.
Panics at registration time if pattern is not a valid regular expression.
<a name="Router.Run"></a>
### func \(\*Router\) [Run](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L303>)
```go
func (r *Router) Run(ctx context.Context, u transport.Updater) error
```
Run consumes the Updater and dispatches each update. It blocks until the Updater's channel is closed or ctx is cancelled.
By default updates are processed concurrently \(up to WithMaxConcurrency\(50\) goroutines\). Handlers for different updates may therefore run simultaneously; shared state must be protected. Pass WithMaxConcurrency\(0\) to New to restore serial \(legacy\) behaviour.
Run waits for all in\-flight handlers to finish before returning.
<a name="Router.Use"></a>
### func \(\*Router\) [Use](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L141>)
```go
func (r *Router) Use(mw Middleware[*api.Update])
```
Use registers a global middleware applied to every Update dispatch.
<a name="RouterOption"></a>
## type [RouterOption](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L97>)
RouterOption configures a Router at construction time.
```go
type RouterOption func(*Router)
```
<a name="WithMaxConcurrency"></a>
### func [WithMaxConcurrency](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/router.go#L106>)
```go
func WithMaxConcurrency(n int) RouterOption
```
WithMaxConcurrency sets the maximum number of updates processed in parallel. Default is 50. Pass 0 to dispatch serially \(one update at a time, in the calling goroutine — the legacy behaviour before v1.1.0\).
Note: concurrent dispatch means handlers for different updates may run simultaneously. Handlers that mutate shared state must be safe for concurrent access.
<a name="RouterScope"></a>
## type [RouterScope](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/groups.go#L31-L34>)
RouterScope registers handlers into a specific priority group on its parent Router. Group 0 runs first, then group 1, etc. Within a group, handlers run in registration order; the first non\-skipped match terminates dispatch unless the handler returns ErrContinueGroups.
```go
type RouterScope struct {
// contains filtered or unexported fields
}
```
<a name="RouterScope.OnCommand"></a>
### func \(\*RouterScope\) [OnCommand](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/groups.go#L45>)
```go
func (s *RouterScope) OnCommand(cmd string, h Handler[*api.Message])
```
OnCommand registers a command handler in this group.
<a name="RouterScope.OnMessageFilter"></a>
### func \(\*RouterScope\) [OnMessageFilter](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/groups.go#L60>)
```go
func (s *RouterScope) OnMessageFilter(f Filter[*api.Message], h Handler[*api.Message])
```
OnMessageFilter registers a filter\-based message handler in this group.
<a name="RouterScope.OnText"></a>
### func \(\*RouterScope\) [OnText](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/groups.go#L53>)
```go
func (s *RouterScope) OnText(pattern string, h Handler[*api.Message])
```
OnText registers a regex text handler in this group. Panics at registration time if pattern is not a valid regular expression.
Generated by [gomarkdoc](<https://github.com/princjef/gomarkdoc>)
+243
View File
@@ -0,0 +1,243 @@
<!-- Code generated by gomarkdoc. DO NOT EDIT -->
# conversation
```go
import "github.com/lukaszraczylo/go-telegram/dispatch/conversation"
```
Package conversation implements a stateful conversation handler for the go\-telegram dispatch router. It provides a state\-machine abstraction over multi\-step Telegram bot interactions, with pluggable storage and flexible key strategies.
## Index
- [Variables](<#variables>)
- [func End\(\) error](<#End>)
- [func Next\(s State\) error](<#Next>)
- [type Conversation](<#Conversation>)
- [func \(c \*Conversation\) Dispatch\(next dispatch.Handler\[\*api.Update\]\) dispatch.Handler\[\*api.Update\]](<#Conversation.Dispatch>)
- [type Handler](<#Handler>)
- [type KeyStrategy](<#KeyStrategy>)
- [type MemoryStorage](<#MemoryStorage>)
- [func NewMemoryStorage\(\) \*MemoryStorage](<#NewMemoryStorage>)
- [func \(s \*MemoryStorage\) Delete\(\_ context.Context, key string\) error](<#MemoryStorage.Delete>)
- [func \(s \*MemoryStorage\) Get\(\_ context.Context, key string\) \(State, error\)](<#MemoryStorage.Get>)
- [func \(s \*MemoryStorage\) Set\(\_ context.Context, key string, state State\) error](<#MemoryStorage.Set>)
- [type State](<#State>)
- [type Step](<#Step>)
- [type Storage](<#Storage>)
## Variables
<a name="ErrKeyNotFound"></a>ErrKeyNotFound is returned by Storage.Get when no conversation is active for the given key.
```go
var ErrKeyNotFound = errors.New("conversation: key not found")
```
<a name="End"></a>
## func [End](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/conversation/handler.go#L34>)
```go
func End() error
```
End signals the conversation has finished and state should be cleared. Conversation handlers return End\(\) to terminate.
<a name="Next"></a>
## func [Next](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/conversation/handler.go#L28>)
```go
func Next(s State) error
```
Next signals the conversation should advance to the given state. Conversation handlers return Next\("state\_name"\) to transition.
<a name="Conversation"></a>
## type [Conversation](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/conversation/handler.go#L55-L79>)
Conversation is a stateful handler with entry, per\-state, exit and fallback steps. A conversation is keyed by KeyStrategy \(default KeyByUserAndChat\) and persisted by Storage \(default in\-memory\).
```go
type Conversation struct {
// EntryPoints starts a new conversation when a matching filter fires
// and no conversation is already active for the key.
EntryPoints []Step
// States maps each state to the steps that handle it.
States map[State][]Step
// Exits, if any match, end the active conversation early. Useful for
// /cancel-style commands.
Exits []Step
// Fallbacks run when no state step matches the current update.
Fallbacks []Step
// Storage persists conversation state. Defaults to NewMemoryStorage.
Storage Storage
// KeyStrategy derives the persistence key. Defaults to KeyByUserAndChat.
KeyStrategy KeyStrategy
// AllowReEntry, when true, lets entry-point steps fire even while a
// conversation is already active for the key (effectively restarting it).
AllowReEntry bool
}
```
<a name="Conversation.Dispatch"></a>
### func \(\*Conversation\) [Dispatch](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/conversation/handler.go#L87>)
```go
func (c *Conversation) Dispatch(next dispatch.Handler[*api.Update]) dispatch.Handler[*api.Update]
```
Dispatch is a global middleware\-shaped Handler that consumes updates and routes them through the conversation graph. Register via router.Use\(conv.Dispatch\).
If the conversation claims an update, downstream handlers are skipped. If the conversation does not claim it, downstream handlers run as normal.
<a name="Handler"></a>
## type [Handler](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/conversation/handler.go#L44>)
Handler defines a step in the conversation. Receives the dispatch context and the raw update. Returns:
- nil to stay in the current state
- Next\("state"\) to transition to a different state
- End\(\) to end the conversation
- any other non\-nil error to surface to the dispatcher \(state unchanged\)
```go
type Handler func(ctx *dispatch.Context, u *api.Update) error
```
<a name="KeyStrategy"></a>
## type [KeyStrategy](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/conversation/key.go#L16>)
KeyStrategy derives a persistence key from an update. Strategies determine how conversation scope works — per\-user, per\-chat, or per\-user\-and\-chat. Implementations must return a stable string for the same logical scope across updates.
Returns the empty string if the update doesn't have enough context to derive a key \(in which case the conversation handler skips it\).
```go
type KeyStrategy func(u *api.Update) string
```
<a name="KeyByChat"></a>KeyByChat derives a key from the chat ID. Useful for group flows where any user in the chat can drive the conversation.
```go
var KeyByChat KeyStrategy = func(u *api.Update) string {
if cid := chatID(u); cid != 0 {
return fmt.Sprintf("c:%d", cid)
}
return ""
}
```
<a name="KeyByUser"></a>KeyByUser derives a key from the sending user's ID. Useful for DM conversations and any flow that should follow the user across chats.
```go
var KeyByUser KeyStrategy = func(u *api.Update) string {
if uid := userID(u); uid != 0 {
return fmt.Sprintf("u:%d", uid)
}
return ""
}
```
<a name="KeyByUserAndChat"></a>KeyByUserAndChat derives a key from both user and chat IDs. The most common strategy: each user has their own conversation per chat.
```go
var KeyByUserAndChat KeyStrategy = func(u *api.Update) string {
uid := userID(u)
cid := chatID(u)
if uid == 0 || cid == 0 {
return ""
}
return fmt.Sprintf("uc:%d:%d", cid, uid)
}
```
<a name="MemoryStorage"></a>
## type [MemoryStorage](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/conversation/in_memory.go#L11-L14>)
MemoryStorage is the default in\-process Storage. It is safe for concurrent use. Conversation state is lost on process restart; use a custom Storage backed by a database for persistent flows.
```go
type MemoryStorage struct {
// contains filtered or unexported fields
}
```
<a name="NewMemoryStorage"></a>
### func [NewMemoryStorage](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/conversation/in_memory.go#L17>)
```go
func NewMemoryStorage() *MemoryStorage
```
NewMemoryStorage constructs an empty in\-memory storage.
<a name="MemoryStorage.Delete"></a>
### func \(\*MemoryStorage\) [Delete](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/conversation/in_memory.go#L38>)
```go
func (s *MemoryStorage) Delete(_ context.Context, key string) error
```
<a name="MemoryStorage.Get"></a>
### func \(\*MemoryStorage\) [Get](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/conversation/in_memory.go#L21>)
```go
func (s *MemoryStorage) Get(_ context.Context, key string) (State, error)
```
<a name="MemoryStorage.Set"></a>
### func \(\*MemoryStorage\) [Set](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/conversation/in_memory.go#L31>)
```go
func (s *MemoryStorage) Set(_ context.Context, key string, state State) error
```
<a name="State"></a>
## type [State](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/conversation/state.go#L9>)
State is a label identifying a node in the conversation graph. The empty string is the implicit "no active conversation" state.
```go
type State string
```
<a name="Step"></a>
## type [Step](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/conversation/handler.go#L47-L50>)
Step pairs a filter with a handler for one conversation step.
```go
type Step struct {
Filter dispatch.Filter[*api.Update]
Handler Handler
}
```
<a name="Storage"></a>
## type [Storage](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/conversation/storage.go#L16-L20>)
Storage persists per\-user \(or per\-chat, per\-message — depending on the KeyStrategy in use\) conversation state across update deliveries.
Implementations must be safe for concurrent use.
```go
type Storage interface {
Get(ctx context.Context, key string) (State, error)
Set(ctx context.Context, key string, state State) error
Delete(ctx context.Context, key string) error
}
```
Generated by [gomarkdoc](<https://github.com/princjef/gomarkdoc>)
@@ -0,0 +1,55 @@
<!-- Code generated by gomarkdoc. DO NOT EDIT -->
# callback
```go
import "github.com/lukaszraczylo/go-telegram/dispatch/filters/callback"
```
Package callback provides Filter helpers for \*api.CallbackQuery payloads.
## Index
- [func Data\(pattern string\) dispatch.Filter\[\*api.CallbackQuery\]](<#Data>)
- [func DataEquals\(s string\) dispatch.Filter\[\*api.CallbackQuery\]](<#DataEquals>)
- [func DataPrefix\(prefix string\) dispatch.Filter\[\*api.CallbackQuery\]](<#DataPrefix>)
- [func FromUser\(userID int64\) dispatch.Filter\[\*api.CallbackQuery\]](<#FromUser>)
<a name="Data"></a>
## func [Data](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/callback/callback.go#L14>)
```go
func Data(pattern string) dispatch.Filter[*api.CallbackQuery]
```
Data returns a Filter that matches callback queries whose Data matches pattern \(regex\). Panics at registration time on an invalid pattern.
<a name="DataEquals"></a>
## func [DataEquals](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/callback/callback.go#L23>)
```go
func DataEquals(s string) dispatch.Filter[*api.CallbackQuery]
```
DataEquals returns a Filter that matches callback queries whose Data equals s exactly.
<a name="DataPrefix"></a>
## func [DataPrefix](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/callback/callback.go#L31>)
```go
func DataPrefix(prefix string) dispatch.Filter[*api.CallbackQuery]
```
DataPrefix returns a Filter that matches callback queries whose Data starts with prefix.
<a name="FromUser"></a>
## func [FromUser](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/callback/callback.go#L39>)
```go
func FromUser(userID int64) dispatch.Filter[*api.CallbackQuery]
```
FromUser returns a Filter that matches callback queries whose From.ID equals userID.
Generated by [gomarkdoc](<https://github.com/princjef/gomarkdoc>)
@@ -0,0 +1,35 @@
<!-- Code generated by gomarkdoc. DO NOT EDIT -->
# chatjoinrequest
```go
import "github.com/lukaszraczylo/go-telegram/dispatch/filters/chatjoinrequest"
```
Package chatjoinrequest provides Filter helpers for \*api.ChatJoinRequest payloads.
## Index
- [func FromUser\(uid int64\) dispatch.Filter\[\*api.ChatJoinRequest\]](<#FromUser>)
- [func InChat\(cid int64\) dispatch.Filter\[\*api.ChatJoinRequest\]](<#InChat>)
<a name="FromUser"></a>
## func [FromUser](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/chatjoinrequest/chatjoinrequest.go#L11>)
```go
func FromUser(uid int64) dispatch.Filter[*api.ChatJoinRequest]
```
FromUser returns a Filter that matches join requests where the requesting user's ID equals uid.
<a name="InChat"></a>
## func [InChat](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/chatjoinrequest/chatjoinrequest.go#L19>)
```go
func InChat(cid int64) dispatch.Filter[*api.ChatJoinRequest]
```
InChat returns a Filter that matches join requests directed at the chat with the given chat ID.
Generated by [gomarkdoc](<https://github.com/princjef/gomarkdoc>)
@@ -0,0 +1,35 @@
<!-- Code generated by gomarkdoc. DO NOT EDIT -->
# chatmember
```go
import "github.com/lukaszraczylo/go-telegram/dispatch/filters/chatmember"
```
Package chatmember provides Filter helpers for \*api.ChatMemberUpdated payloads.
## Index
- [func FromUser\(uid int64\) dispatch.Filter\[\*api.ChatMemberUpdated\]](<#FromUser>)
- [func NewStatus\(s string\) dispatch.Filter\[\*api.ChatMemberUpdated\]](<#NewStatus>)
<a name="FromUser"></a>
## func [FromUser](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/chatmember/chatmember.go#L37>)
```go
func FromUser(uid int64) dispatch.Filter[*api.ChatMemberUpdated]
```
FromUser returns a Filter that matches updates where the acting user \(From.ID\) equals uid.
<a name="NewStatus"></a>
## func [NewStatus](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/chatmember/chatmember.go#L11>)
```go
func NewStatus(s string) dispatch.Filter[*api.ChatMemberUpdated]
```
NewStatus returns a Filter that matches updates where the new chat member status equals s \(e.g. "member", "administrator", "kicked", "left"\).
Generated by [gomarkdoc](<https://github.com/princjef/gomarkdoc>)
+45
View File
@@ -0,0 +1,45 @@
<!-- Code generated by gomarkdoc. DO NOT EDIT -->
# inline
```go
import "github.com/lukaszraczylo/go-telegram/dispatch/filters/inline"
```
Package inline provides Filter helpers for \*api.InlineQuery payloads.
## Index
- [func Query\(pattern string\) dispatch.Filter\[\*api.InlineQuery\]](<#Query>)
- [func QueryEquals\(s string\) dispatch.Filter\[\*api.InlineQuery\]](<#QueryEquals>)
- [func QueryPrefix\(prefix string\) dispatch.Filter\[\*api.InlineQuery\]](<#QueryPrefix>)
<a name="Query"></a>
## func [Query](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/inline/inline.go#L14>)
```go
func Query(pattern string) dispatch.Filter[*api.InlineQuery]
```
Query returns a Filter that matches inline queries whose Query field matches pattern \(regex\). Panics at registration time on an invalid pattern.
<a name="QueryEquals"></a>
## func [QueryEquals](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/inline/inline.go#L23>)
```go
func QueryEquals(s string) dispatch.Filter[*api.InlineQuery]
```
QueryEquals returns a Filter that matches inline queries whose Query equals s exactly.
<a name="QueryPrefix"></a>
## func [QueryPrefix](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/inline/inline.go#L31>)
```go
func QueryPrefix(prefix string) dispatch.Filter[*api.InlineQuery]
```
QueryPrefix returns a Filter that matches inline queries whose Query starts with prefix.
Generated by [gomarkdoc](<https://github.com/princjef/gomarkdoc>)
+155
View File
@@ -0,0 +1,155 @@
<!-- Code generated by gomarkdoc. DO NOT EDIT -->
# message
```go
import "github.com/lukaszraczylo/go-telegram/dispatch/filters/message"
```
Package message provides Filter helpers for \*api.Message payloads.
## Index
- [func AnyCommand\(\) dispatch.Filter\[\*api.Message\]](<#AnyCommand>)
- [func ChatType\(t api.ChatType\) dispatch.Filter\[\*api.Message\]](<#ChatType>)
- [func Command\(name string\) dispatch.Filter\[\*api.Message\]](<#Command>)
- [func FromUser\(userID int64\) dispatch.Filter\[\*api.Message\]](<#FromUser>)
- [func HasDocument\(\) dispatch.Filter\[\*api.Message\]](<#HasDocument>)
- [func HasEntity\(t api.MessageEntityType\) dispatch.Filter\[\*api.Message\]](<#HasEntity>)
- [func HasPhoto\(\) dispatch.Filter\[\*api.Message\]](<#HasPhoto>)
- [func InChat\(chatID int64\) dispatch.Filter\[\*api.Message\]](<#InChat>)
- [func IsForward\(\) dispatch.Filter\[\*api.Message\]](<#IsForward>)
- [func IsReply\(\) dispatch.Filter\[\*api.Message\]](<#IsReply>)
- [func Text\(pattern string\) dispatch.Filter\[\*api.Message\]](<#Text>)
- [func TextContains\(sub string\) dispatch.Filter\[\*api.Message\]](<#TextContains>)
- [func TextEquals\(s string\) dispatch.Filter\[\*api.Message\]](<#TextEquals>)
- [func TextPrefix\(prefix string\) dispatch.Filter\[\*api.Message\]](<#TextPrefix>)
<a name="AnyCommand"></a>
## func [AnyCommand](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/message/message.go#L69>)
```go
func AnyCommand() dispatch.Filter[*api.Message]
```
AnyCommand returns a Filter that matches any message starting with a bot\_command entity at offset 0.
<a name="ChatType"></a>
## func [ChatType](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/message/message.go#L124>)
```go
func ChatType(t api.ChatType) dispatch.Filter[*api.Message]
```
ChatType returns a Filter that matches messages whose Chat.Type equals t.
<a name="Command"></a>
## func [Command](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/message/message.go#L44>)
```go
func Command(name string) dispatch.Filter[*api.Message]
```
Command returns a Filter that matches messages whose first entity is a bot\_command equal to "/\<name\>" \(with or without "@BotName" suffix\).
<a name="FromUser"></a>
## func [FromUser](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/message/message.go#L131>)
```go
func FromUser(userID int64) dispatch.Filter[*api.Message]
```
FromUser returns a Filter that matches messages whose From.ID equals userID.
<a name="HasDocument"></a>
## func [HasDocument](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/message/message.go#L101>)
```go
func HasDocument() dispatch.Filter[*api.Message]
```
HasDocument returns a Filter that matches messages with a Document attachment.
<a name="HasEntity"></a>
## func [HasEntity](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/message/message.go#L109>)
```go
func HasEntity(t api.MessageEntityType) dispatch.Filter[*api.Message]
```
HasEntity returns a Filter that matches messages whose Entities contain at least one entity of type t \(e.g. api.MessageEntityTypeBotCommand\).
<a name="HasPhoto"></a>
## func [HasPhoto](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/message/message.go#L94>)
```go
func HasPhoto() dispatch.Filter[*api.Message]
```
HasPhoto returns a Filter that matches messages with a Photo attachment.
<a name="InChat"></a>
## func [InChat](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/message/message.go#L138>)
```go
func InChat(chatID int64) dispatch.Filter[*api.Message]
```
InChat returns a Filter that matches messages whose Chat.ID equals chatID.
<a name="IsForward"></a>
## func [IsForward](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/message/message.go#L87>)
```go
func IsForward() dispatch.Filter[*api.Message]
```
IsForward returns a Filter that matches messages that have ForwardOrigin set.
<a name="IsReply"></a>
## func [IsReply](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/message/message.go#L80>)
```go
func IsReply() dispatch.Filter[*api.Message]
```
IsReply returns a Filter that matches messages that have ReplyToMessage set.
<a name="Text"></a>
## func [Text](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/message/message.go#L14>)
```go
func Text(pattern string) dispatch.Filter[*api.Message]
```
Text returns a Filter that matches messages whose Text matches pattern \(regex\). Panics at registration time on an invalid pattern.
<a name="TextContains"></a>
## func [TextContains](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/message/message.go#L36>)
```go
func TextContains(sub string) dispatch.Filter[*api.Message]
```
TextContains returns a Filter that matches messages whose Text contains sub.
<a name="TextEquals"></a>
## func [TextEquals](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/message/message.go#L22>)
```go
func TextEquals(s string) dispatch.Filter[*api.Message]
```
TextEquals returns a Filter that matches messages whose Text equals s exactly.
<a name="TextPrefix"></a>
## func [TextPrefix](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/message/message.go#L29>)
```go
func TextPrefix(prefix string) dispatch.Filter[*api.Message]
```
TextPrefix returns a Filter that matches messages whose Text starts with prefix.
Generated by [gomarkdoc](<https://github.com/princjef/gomarkdoc>)
@@ -0,0 +1,35 @@
<!-- Code generated by gomarkdoc. DO NOT EDIT -->
# precheckoutquery
```go
import "github.com/lukaszraczylo/go-telegram/dispatch/filters/precheckoutquery"
```
Package precheckoutquery provides Filter helpers for \*api.PreCheckoutQuery payloads.
## Index
- [func Currency\(c string\) dispatch.Filter\[\*api.PreCheckoutQuery\]](<#Currency>)
- [func FromUser\(uid int64\) dispatch.Filter\[\*api.PreCheckoutQuery\]](<#FromUser>)
<a name="Currency"></a>
## func [Currency](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/precheckoutquery/precheckoutquery.go#L11>)
```go
func Currency(c string) dispatch.Filter[*api.PreCheckoutQuery]
```
Currency returns a Filter that matches pre\-checkout queries with the given ISO 4217 currency code \(e.g. "USD", "EUR", "XTR"\).
<a name="FromUser"></a>
## func [FromUser](<https://github.com/lukaszraczylo/go-telegram/blob/main/dispatch/filters/precheckoutquery/precheckoutquery.go#L19>)
```go
func FromUser(uid int64) dispatch.Filter[*api.PreCheckoutQuery]
```
FromUser returns a Filter that matches pre\-checkout queries sent by the user with the given ID.
Generated by [gomarkdoc](<https://github.com/princjef/gomarkdoc>)
+241
View File
@@ -0,0 +1,241 @@
<!-- Code generated by gomarkdoc. DO NOT EDIT -->
# transport
```go
import "github.com/lukaszraczylo/go-telegram/transport"
```
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.
## Index
- [type BackoffStrategy](<#BackoffStrategy>)
- [type ExponentialBackoff](<#ExponentialBackoff>)
- [func DefaultBackoff\(\) \*ExponentialBackoff](<#DefaultBackoff>)
- [func \(b \*ExponentialBackoff\) NextDelay\(attempt int\) time.Duration](<#ExponentialBackoff.NextDelay>)
- [type LongPoller](<#LongPoller>)
- [func NewLongPoller\(b \*client.Bot\) \*LongPoller](<#NewLongPoller>)
- [func \(p \*LongPoller\) Run\(ctx context.Context\) error](<#LongPoller.Run>)
- [func \(p \*LongPoller\) Stop\(ctx context.Context\) error](<#LongPoller.Stop>)
- [func \(p \*LongPoller\) Updates\(\) \<\-chan api.Update](<#LongPoller.Updates>)
- [type Updater](<#Updater>)
- [type WebhookOption](<#WebhookOption>)
- [func WithBufferSize\(n int\) WebhookOption](<#WithBufferSize>)
- [type WebhookServer](<#WebhookServer>)
- [func NewWebhookServer\(b \*client.Bot, opts ...WebhookOption\) \*WebhookServer](<#NewWebhookServer>)
- [func \(w \*WebhookServer\) ListenAndServe\(ctx context.Context, addr string\) error](<#WebhookServer.ListenAndServe>)
- [func \(w \*WebhookServer\) Run\(ctx context.Context\) error](<#WebhookServer.Run>)
- [func \(w \*WebhookServer\) ServeHTTP\(rw http.ResponseWriter, r \*http.Request\)](<#WebhookServer.ServeHTTP>)
- [func \(w \*WebhookServer\) Stop\(ctx context.Context\) error](<#WebhookServer.Stop>)
- [func \(w \*WebhookServer\) Updates\(\) \<\-chan api.Update](<#WebhookServer.Updates>)
<a name="BackoffStrategy"></a>
## type [BackoffStrategy](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/backoff.go#L12-L14>)
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.
```go
type BackoffStrategy interface {
NextDelay(attempt int) time.Duration
}
```
<a name="ExponentialBackoff"></a>
## type [ExponentialBackoff](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/backoff.go#L18-L23>)
ExponentialBackoff implements capped exponential back\-off with jitter. Defaults: Base=500ms, Max=30s, Factor=2.0, Jitter=0.2.
```go
type ExponentialBackoff struct {
Base time.Duration
Max time.Duration
Factor float64
Jitter float64 // 0..1; fraction of computed delay added/subtracted at random
}
```
<a name="DefaultBackoff"></a>
### func [DefaultBackoff](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/backoff.go#L26>)
```go
func DefaultBackoff() *ExponentialBackoff
```
DefaultBackoff returns an ExponentialBackoff with library defaults.
<a name="ExponentialBackoff.NextDelay"></a>
### func \(\*ExponentialBackoff\) [NextDelay](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/backoff.go#L36>)
```go
func (b *ExponentialBackoff) NextDelay(attempt int) time.Duration
```
NextDelay implements BackoffStrategy.
<a name="LongPoller"></a>
## type [LongPoller](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/longpoll.go#L21-L31>)
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\).
At\-least\-once semantics on shutdown: when ctx is cancelled or Stop is called mid\-batch, any updates already fetched but not yet dispatched are dropped without advancing the offset. On the next restart those updates will be re\-delivered by Telegram.
```go
type LongPoller struct {
Bot *client.Bot
Timeout int // seconds, default 30
Limit int // 1..100, default 100
AllowedTypes []api.UpdateType
Backoff BackoffStrategy
// contains filtered or unexported fields
}
```
<a name="NewLongPoller"></a>
### func [NewLongPoller](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/longpoll.go#L34>)
```go
func NewLongPoller(b *client.Bot) *LongPoller
```
NewLongPoller constructs a LongPoller with sensible defaults.
<a name="LongPoller.Run"></a>
### func \(\*LongPoller\) [Run](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/longpoll.go#L51>)
```go
func (p *LongPoller) Run(ctx context.Context) error
```
Run implements Updater. It blocks until ctx is cancelled, Stop is called, or a fatal error occurs \(e.g. unauthorized\). See LongPoller for at\-least\-once delivery semantics on shutdown.
<a name="LongPoller.Stop"></a>
### func \(\*LongPoller\) [Stop](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/longpoll.go#L126>)
```go
func (p *LongPoller) Stop(ctx context.Context) error
```
Stop implements Updater.
<a name="LongPoller.Updates"></a>
### func \(\*LongPoller\) [Updates](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/longpoll.go#L46>)
```go
func (p *LongPoller) Updates() <-chan api.Update
```
Updates implements Updater.
<a name="Updater"></a>
## type [Updater](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/updater.go#L13-L23>)
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.
```go
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
}
```
<a name="WebhookOption"></a>
## type [WebhookOption](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/webhook.go#L38>)
WebhookOption configures a WebhookServer at construction time.
```go
type WebhookOption func(*webhookOptions)
```
<a name="WithBufferSize"></a>
### func [WithBufferSize](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/webhook.go#L46>)
```go
func WithBufferSize(n int) WebhookOption
```
WithBufferSize sets the size of the updates channel buffer. Default is 64.
<a name="WebhookServer"></a>
## type [WebhookServer](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/webhook.go#L24-L35>)
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\).
```go
type WebhookServer struct {
Bot *client.Bot
SecretToken string // verify X-Telegram-Bot-Api-Secret-Token; empty disables
// contains filtered or unexported fields
}
```
<a name="NewWebhookServer"></a>
### func [NewWebhookServer](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/webhook.go#L52>)
```go
func NewWebhookServer(b *client.Bot, opts ...WebhookOption) *WebhookServer
```
NewWebhookServer constructs a WebhookServer with default buffer size \(64\). Use WithBufferSize to override.
<a name="WebhookServer.ListenAndServe"></a>
### func \(\*WebhookServer\) [ListenAndServe](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/webhook.go#L150>)
```go
func (w *WebhookServer) ListenAndServe(ctx context.Context, addr string) error
```
ListenAndServe starts an HTTP server on addr and blocks until Stop is called \(which triggers Shutdown with the caller's context\) or the server returns an error other than http.ErrServerClosed. Callers must invoke Stop\(ctx\) to cleanly shut down the server; the ctx passed here is only used as the server's base context for incoming requests.
<a name="WebhookServer.Run"></a>
### func \(\*WebhookServer\) [Run](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/webhook.go#L71>)
```go
func (w *WebhookServer) Run(ctx context.Context) error
```
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.
<a name="WebhookServer.ServeHTTP"></a>
### func \(\*WebhookServer\) [ServeHTTP](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/webhook.go#L97>)
```go
func (w *WebhookServer) ServeHTTP(rw http.ResponseWriter, r *http.Request)
```
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.
<a name="WebhookServer.Stop"></a>
### func \(\*WebhookServer\) [Stop](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/webhook.go#L83>)
```go
func (w *WebhookServer) Stop(ctx context.Context) error
```
Stop implements Updater.
<a name="WebhookServer.Updates"></a>
### func \(\*WebhookServer\) [Updates](<https://github.com/lukaszraczylo/go-telegram/blob/main/transport/webhook.go#L65>)
```go
func (w *WebhookServer) Updates() <-chan api.Update
```
Updates implements Updater.
Generated by [gomarkdoc](<https://github.com/princjef/gomarkdoc>)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,499 +0,0 @@
# go-telegram — Design Spec
- **Date:** 2026-05-08
- **Module:** `github.com/lukaszraczylo/go-telegram`
- **License:** MIT
- **Status:** Approved for planning
- **Author:** Lukasz Raczylo (with Claude assistance)
## 1. Purpose & scope
A Go library for the Telegram Bot API, primarily a portfolio piece showcasing:
- Codegen-driven full API coverage (parsed from `https://core.telegram.org/bots/api`)
- Pragmatic Go generics
- Pluggable HTTP transport and JSON codec for resource-conscious deployments
- Long-poll and webhook update delivery behind a unified interface
- A typed dispatcher/router for handlers, commands, and callbacks
- Comprehensive testify-based unit tests with golden fixtures for codegen
Out of scope for v1: the daily auto-regen GitHub Action (deferred — see §11).
## 2. Non-goals
- Competing on completeness/maturity with `mymmrac/telego` or `go-telegram-bot-api/telegram-bot-api`. We optimise for clarity and design.
- Bot-framework features beyond the dispatcher (no plugin marketplace, no FSM, no scenes).
- A documentation website. `pkg.go.dev` + README is sufficient.
## 3. Requirements
### Functional
1. Cover all Telegram Bot API methods and types via codegen.
2. Support long-poll and webhook update delivery, both implementing one `Updater` interface.
3. Allow the user to swap the HTTP client (e.g. `valyala/fasthttp`) and JSON codec (e.g. `goccy/go-json`).
4. Provide a typed dispatcher with command, text-regex, callback, and inline-query matching plus generic middleware.
5. Provide unit tests using `stretchr/testify` covering happy paths and explicit edge cases.
6. Provide an optional integration test suite gated by build tag and env vars.
### Non-functional
- Lean dependency footprint (stdlib + `golang.org/x/net/html` + testify).
- Deterministic, reproducible codegen (`go test ./... -run TestGen` is hermetic).
- Generated files committed to the repo so consumers do not need to run codegen.
- Doc comments on every exported symbol; generated types carry Telegram's verbatim prose.
## 4. Architecture
Two-stage codegen with a JSON intermediate representation:
```
HTML page cmd/scrape api.json (IR) cmd/genapi api/*.gen.go
```
The IR is committed; PRs from a future regen workflow show the diff against the previous IR, providing a readable changelog of Telegram-side changes.
### 4.1 Repository layout
```
go-telegram/
├── api/ GENERATED types + method wrappers (typed param structs)
│ ├── types.gen.go
│ ├── methods.gen.go
│ └── enums.gen.go
├── client/ HAND Bot client, request building, error handling
│ ├── client.go
│ ├── codec.go Codec interface + encoding/json default
│ ├── httpclient.go HTTPDoer interface + net/http default
│ ├── errors.go Typed APIError, NetworkError, ParseError
│ └── result.go generic Result[T] decode
├── transport/ HAND Updater abstraction
│ ├── updater.go Updater interface
│ ├── longpoll.go LongPoller
│ └── webhook.go WebhookServer
├── dispatch/ HAND Handler router
│ ├── router.go Router, OnCommand/OnCallback/OnText
│ ├── middleware.go Generic Middleware[T]
│ └── context.go Per-update context
├── internal/
│ └── spec/ Shared IR types
│ ├── ir.go Types describing parsed Telegram API
│ └── api.json Committed golden IR (regenerated by scraper)
├── cmd/
│ ├── scrape/ HTML → api.json
│ └── genapi/ api.json → api/*.gen.go
├── examples/
│ ├── echo/ Long-poll echo bot
│ └── webhook/ Webhook bot with command router
├── testdata/
│ ├── html/ Golden HTML snapshots for scraper
│ ├── golden/ Expected api.json + emitted Go for codegen tests
│ └── responses/ Canned Telegram JSON responses
├── .github/workflows/
│ └── ci.yml lint + test + codegen-clean check
├── Makefile regen, test, lint targets
├── go.mod module github.com/lukaszraczylo/go-telegram
├── LICENSE MIT
└── README.md
```
## 5. Core types and client (`client/`)
### 5.1 Pluggability interfaces
```go
// Codec is the JSON encoder/decoder. Default impl wraps encoding/json.
type Codec interface {
Marshal(v any) ([]byte, error)
Unmarshal(data []byte, v any) error
}
// HTTPDoer is the HTTP transport. Default is *http.Client.
type HTTPDoer interface {
Do(req *http.Request) (*http.Response, error)
}
// Logger is a slog-shaped interface; nil-safe default writes nowhere.
type Logger interface {
Debug(msg string, attrs ...any)
Info(msg string, attrs ...any)
Warn(msg string, attrs ...any)
Error(msg string, attrs ...any)
}
```
### 5.2 Bot client
```go
type Bot struct {
token string
base string // https://api.telegram.org
http HTTPDoer
codec Codec
logger Logger
}
type Option func(*Bot)
func WithHTTPClient(c HTTPDoer) Option
func WithCodec(c Codec) Option
func WithBaseURL(url string) Option
func WithLogger(l Logger) Option
func New(token string, opts ...Option) *Bot
```
Constructor-level functional options only; per-call params are typed structs (codegen-friendly).
### 5.3 Result envelope and call helper
```go
type Result[T any] struct {
OK bool `json:"ok"`
Result T `json:"result,omitempty"`
ErrorCode int `json:"error_code,omitempty"`
Description string `json:"description,omitempty"`
Parameters *ResponseParameters `json:"parameters,omitempty"`
}
// Single point for marshalling, URL signing, decoding, error mapping.
// Used by every generated method wrapper.
func call[Req, Resp any](ctx context.Context, b *Bot, method string, req Req) (Resp, error)
```
Generated wrappers stay thin:
```go
func (b *Bot) SendMessage(ctx context.Context, p *SendMessageParams) (*Message, error) {
return call[*SendMessageParams, *Message](ctx, b, "sendMessage", p)
}
```
### 5.4 Errors
```go
type APIError struct {
Code int
Description string
Parameters *ResponseParameters // retry_after, migrate_to_chat_id
}
func (e *APIError) Error() string
func (e *APIError) IsRetryable() bool // 429, 5xx
func (e *APIError) RetryAfter() time.Duration
type NetworkError struct{ Err error }
type ParseError struct{ Err error; Body []byte }
```
Sentinel errors via `errors.Is`: `ErrUnauthorized`, `ErrChatNotFound`, `ErrMessageNotModified`, `ErrTooManyRequests`. Mapped from `error_code` plus description-prefix matching.
## 6. Codegen pipeline
### 6.1 Stage 1 — `cmd/scrape/`
- Input: live URL `https://core.telegram.org/bots/api` (default) or a local HTML fixture (`-input` flag).
- Parser: `golang.org/x/net/html` (no goquery).
- Walk strategy: traverse `<h4>` headings sequentially. Lowercase first letter → method. Uppercase → type.
- Following `<p>` until next heading → description.
- Following `<table>` → fields/params (columns: Field|Type|Required|Description for types; Parameter|Type|Required|Description for methods).
- Return type extracted by regex on description: `Returns *X* on success`, `Returns an Array of X`, `Returns True on success`.
- Italic markers in the type column denote optional, array depth, and union-member candidates.
- "Recent changes" section parsed for current API version.
### 6.2 Intermediate representation (`internal/spec/ir.go`)
```go
type API struct {
Version string
Types []TypeDecl
Methods []MethodDecl
}
type TypeDecl struct {
Name string
Doc string
Fields []Field
OneOf []string // unions (InputMedia, ChatMember, …)
}
type MethodDecl struct {
Name string // sendMessage
Doc string
Params []Field
Returns TypeRef
HasFiles bool // forces multipart
}
type Field struct {
Name string
JSONName string
Type TypeRef
Required bool
Doc string
}
type Kind int
const (
KindPrimitive Kind = iota
KindNamed
KindArray
KindOneOf
)
type TypeRef struct {
Kind Kind
Name string
ElemType *TypeRef
Variants []string
}
```
`internal/spec/api.json` is committed. Marshalling is stable (sorted fields, deterministic JSON output) so diffs read as a Telegram changelog.
### 6.3 Stage 2 — `cmd/genapi/`
- Reads `api.json`.
- Emits Go via `text/template`, finalised with `go/format`.
- Templates:
- `types.tmpl` → struct per `TypeDecl`. Optional fields are pointers (or `omitempty` for slices/maps). Doc comments verbatim from API.
- `enums.tmpl` → string consts for known enumerations (parse modes, chat types, etc., extracted from doc prose).
- `oneof.tmpl` → union types as `interface { isFooBar() }` plus concrete impls and a `UnmarshalJSON` that switches on a discriminator field (typically `type` or `source`).
- `methods.tmpl` → param struct + thin `Bot.<MethodName>` wrapper using `call[…]`.
- `multipart.tmpl` → for methods with `HasFiles`, custom request builder using `mime/multipart`.
- Header on every emitted file: `// Code generated by cmd/genapi. DO NOT EDIT.` + `//go:build !ignore_autogenerated`.
### 6.4 Makefile contract
The Makefile owns the codegen entry points; tools and CI call `make`, never raw `go run`:
| Target | What it does |
|---|---|
| `make snapshot` | `curl -fsSL https://core.telegram.org/bots/api > testdata/html/snapshot_<date>.html` and update a `latest.html` symlink. |
| `make regen` | Run scraper against `testdata/html/latest.html`, then run emitter. Writes `internal/spec/api.json` and `api/*.gen.go`. |
| `make regen-from-fixture` | Same as `make regen` but pinned to `testdata/html/snapshot_2026-05-08.html` for deterministic CI checks. |
| `make test` | `go test -race ./...` |
| `make test-update-golden` | `go test -run TestGen -update ./...` to refresh golden fixtures. |
| `make lint` | `go vet` + `staticcheck`. |
| `make integration` | `go test -tags=integration ./test/integration/...` (requires env). |
## 7. Transport (`transport/`)
```go
type Updater interface {
Updates() <-chan api.Update
Run(ctx context.Context) error
Stop(ctx context.Context) error
}
```
### 7.1 LongPoller
```go
type LongPoller struct {
Bot *client.Bot
Timeout int // seconds, default 30
Limit int // 1..100, default 100
AllowedTypes []api.UpdateType
Backoff BackoffStrategy
}
```
Calls `getUpdates` in a loop, tracks `offset`, applies exponential backoff on transient errors via `BackoffStrategy`.
### 7.2 WebhookServer
```go
type WebhookServer struct {
Bot *client.Bot
SecretToken string // verify X-Telegram-Bot-Api-Secret-Token
BufferSize int
}
func (w *WebhookServer) ServeHTTP(rw http.ResponseWriter, r *http.Request)
func (w *WebhookServer) ListenAndServe(ctx context.Context, addr string) error
```
`ServeHTTP` lets users mount on their own router. `ListenAndServe` is a convenience for standalone use.
## 8. Dispatcher (`dispatch/`)
```go
type Context struct {
Ctx context.Context
Bot *client.Bot
Update *api.Update
Values map[string]any // matched groups, command args
}
type Handler[T any] func(ctx *Context, payload T) error
type Middleware[T any] func(Handler[T]) Handler[T]
type Router struct{ /* … */ }
func New(bot *client.Bot) *Router
func (r *Router) OnCommand(cmd string, h Handler[*api.Message])
func (r *Router) OnText(pattern string, h Handler[*api.Message])
func (r *Router) OnCallback(pattern string, h Handler[*api.CallbackQuery])
func (r *Router) OnInlineQuery(h Handler[*api.InlineQuery])
func (r *Router) OnEditedMessage(h Handler[*api.Message])
func (r *Router) Use(mw Middleware[*api.Update])
func (r *Router) Run(ctx context.Context, u transport.Updater) error
```
Matchers run in registration order; first match wins. A panic-recovery middleware is registered automatically. Generic `Handler[T]` keeps payloads precisely typed in user code.
## 9. Testing strategy
### 9.1 Unit tests (every package, fast, no network)
- `testify/require` for assertions, `testify/mock` on `client.HTTPDoer`.
- Edge cases explicitly covered:
- API error decode for `429` with `retry_after``*APIError.IsRetryable()=true`, `RetryAfter()=N`.
- Network error from transport → wrapped `*NetworkError`.
- Malformed JSON → `*ParseError`.
- Void-result methods (`setWebhook` returning `bool`).
- Optional pointer fields nil round-trip.
- OneOf union types unmarshal via discriminator.
- Multipart upload path for methods with `InputFile`.
- Context cancellation mid-call returns `ctx.Err()`.
- Long-poller backoff after transient error.
- Webhook secret-token mismatch → 401.
- Webhook handles oversized body, malformed JSON, wrong content-type.
- Router: command match with/without bot mention (`/start@MyBot`), regex matchers, panic recovery, middleware ordering, no-match update.
### 9.2 Codegen golden tests
```
testdata/html/
├── snapshot_2026-05-08.html full Bots API page snapshot
└── small_fixture.html hand-crafted minimal page (1 type, 1 method)
testdata/golden/
├── api.json expected IR from snapshot
└── *.gen.go expected emitted Go
```
- `cmd/scrape` test: parse fixture → compare to golden `api.json`.
- `cmd/genapi` test: read golden `api.json` → compare emitted Go to golden `*.gen.go`.
- `-update` flag (custom in `internal/testutil`) regenerates goldens deliberately.
### 9.3 Optional integration suite
- Build tag `//go:build integration`.
- Skipped by default `go test ./...`.
- Activated by `go test -tags=integration ./test/integration/...`.
- Requires `TELEGRAM_BOT_TOKEN` (and `TELEGRAM_TEST_CHAT_ID` where applicable).
- Covers `getMe`, `sendMessage`, `setWebhook`/`deleteWebhook`, `getUpdates` loop with short timeout.
- Not part of default CI to avoid flakes.
## 10. CI
`.github/workflows/ci.yml` (every push and PR):
- `actions/setup-go` matrix: 1.23, 1.24
- `go vet ./...`
- `staticcheck ./...`
- `go test -race -coverprofile=coverage.out ./...`
- Codegen-clean check: `make regen-from-fixture` + `git diff --exit-code` to assert generated files match the committed IR for the snapshot fixture (deterministic).
- Upload coverage artifact.
## 11. Handling API changes & test maintenance
Telegram ships changes to the Bot API roughly every 13 months: new methods, new types, added optional fields, occasional removals or renames, occasional union-variant additions. The design must absorb these with minimum manual work.
### 11.1 Test-suite invariants under change
Tests are layered so that the cost of an API change is bounded.
| Test layer | Affected by API change? | Cost |
|---|---|---|
| `client/` unit tests (call helper, error mapping, codec, multipart builder) | No — they target `call[Req,Resp]`, not specific methods. | Zero. |
| `transport/` unit tests (long-poll loop, webhook server) | No — they target update plumbing, not payload fields. | Zero. |
| `dispatch/` unit tests (matchers, middleware, router) | No — generic over `*api.Update`. | Zero. |
| Codegen golden tests (`cmd/scrape`, `cmd/genapi`) | Yes — golden `api.json` and `*.gen.go` will diff. | Refresh goldens deliberately (`go test -run TestGen -update`). |
| Codegen "shape" smoke tests | Only if a code-generation pattern changes. | One test per pattern, not per method. |
| Examples (`examples/echo`, `examples/webhook`) | Only if they reference a removed/renamed symbol. | Hand-fix; rare. |
| Integration suite (build tag `integration`) | Only for the ~5 methods it touches. | Hand-fix on removal/rename; rare. |
The deliberate invariant: **we do not write a test per generated method.** All ~100+ generated wrappers funnel through `call[Req,Resp]` and the multipart builder; coverage of those two paths covers them all. Each generated method is then sanity-checked by being type-checked at compile time against the IR.
### 11.2 Shape smoke tests
In `api/api_test.go`, one test per code-generation pattern, hitting a representative method through a mocked `HTTPDoer`:
- **Simple**`getMe` (no params, scalar response).
- **Typed-struct param**`sendMessage` (struct in, object out).
- **Optional fields**`sendMessage` with only required fields set; verify omitted fields do not appear in the request body.
- **Array result**`getUpdates` (array of `Update`).
- **Bool result**`setWebhook`.
- **Multipart upload**`sendDocument` with an `InputFile` (verify content-type, boundary, field names).
- **OneOf union response**`getChatMember` (returns `ChatMember` union).
- **OneOf union request**`sendMediaGroup` (accepts `[]InputMedia` union).
If new code-generation patterns appear (Telegram introducing a new shape we have not seen), one new shape test is added — not one per affected method.
### 11.3 Categories of change and how each is absorbed
| Change | Pipeline effect | Test effect |
|---|---|---|
| New type | Appears in `api.json`, emitted into `types.gen.go`. | Golden diff only. Refresh. |
| New optional field | Same. | Golden diff only. |
| New required field | Same. Breaking for users who construct that struct literally. | Golden diff only; example code may need update. |
| Removed type/method/field | Disappears from emitted Go. Breaking for users referring to it. | Golden diff. Integration test or example may break — fix or skip. |
| Renamed field | Old name disappears, new appears. | Same as above; no automatic rename (we treat as remove + add). |
| New method | Wrapper generated; no new test required (shape tests cover the call path). | Golden diff. |
| Return type changed for an existing method | Wrapper signature changes. Breaking. | Golden diff; integration test for that method may break. |
| New OneOf variant | `UnmarshalJSON` switch grows a case. | Golden diff. If a brand-new variant style appears, may require scraper work and a new shape test. |
| Telegram doc layout change | Scraper may misparse. | Scraper unit test against the new HTML fixture should be added before regenerating. |
### 11.4 The change procedure
When the scraper output changes:
1. Run `make regen-from-fixture` against the current `testdata/html/snapshot_*.html` (deterministic check) — confirm zero unrelated diffs.
2. Capture a fresh HTML snapshot: `make snapshot` (writes `testdata/html/snapshot_<date>.html`).
3. Run `make regen` against the new snapshot → IR diff appears in `internal/spec/api.json`.
4. Review the IR diff as a Telegram changelog. This is the human read-through; it is the entire point of having an IR.
5. Run `go test ./...` → expect golden codegen diffs.
6. Refresh goldens: `go test -run TestGen -update`.
7. Re-run `go test ./...` → green.
8. If shape tests reveal a new code-generation pattern (e.g. a never-before-seen union shape), extend templates and add a shape test before refreshing goldens.
9. If `examples/` or integration tests reference a removed symbol, fix them.
10. Commit with a clear message: `chore(api): regenerate from Telegram Bot API vX.Y` plus a bullet list extracted from the IR diff (added/changed/removed types and methods).
This is the same procedure the future auto-regen workflow will run; doing it by hand first ensures the workflow has nothing surprising to do.
### 11.5 Versioning policy
- Library SemVer is decoupled from Telegram's API version.
- Telegram-side additions → minor bump.
- Telegram-side removals or signature changes → major bump (we do not preserve removed symbols as deprecated stubs; the breaking change ships).
- Bug fixes in hand-written code → patch bump.
- Each release records the Telegram API version it was generated against in the release notes and in a `// Generated from Bot API vX.Y` constant in `api/version.gen.go`.
## 12. Future work (deferred)
- **Auto-regen workflow** — daily cron + `workflow_dispatch` that runs `cmd/scrape` against the live URL, regenerates code, opens a PR with a diff summary, and auto-merges on green CI. Implementation sketch retained in design discussion; not part of v1 acceptance.
- **Release workflow** — tag-triggered `goreleaser` pipeline producing GH Releases. SemVer for the library; Telegram's API version recorded in release notes.
- **Additional codecs/HTTP adapters** as separate sub-packages or contrib modules so users can opt in without bringing transitive deps into the core.
## 13. Dependency policy
Production:
- Go standard library
- `golang.org/x/net/html` (scraper only)
Test-only:
- `github.com/stretchr/testify`
Explicit non-deps: `goquery`, `cobra`, third-party logging, third-party HTTP clients, third-party JSON codecs (these are user-supplied via `HTTPDoer` and `Codec`).
## 14. Acceptance criteria
v1 is done when:
1. `go test ./...` passes on a clean checkout with no env vars.
2. `make regen` produces zero diff against the committed `api.json` and `api/*.gen.go` when run against the committed HTML fixture.
3. `examples/echo` and `examples/webhook` build and the echo example runs end-to-end against a real bot when `TELEGRAM_BOT_TOKEN` is set.
4. `go vet`, `staticcheck`, and `go test -race` are clean.
5. Every exported symbol in hand-written packages has a doc comment.
6. README covers Why, Install, Quick Start, Custom HTTP/JSON, Webhooks, Dispatcher, Updating, Contributing.
7. The integration test suite (`-tags=integration`) runs cleanly when env is provided.
## 15. Open questions
None at sign-off. (Auto-regen behaviour intentionally deferred.)
+2 -2
View File
@@ -68,7 +68,7 @@ func TestHandleStart_SendsInitialKeyboard(t *testing.T) {
bot := client.New("test:token", client.WithHTTPClient(m))
msg := &api.Message{
MessageID: 1,
Chat: api.Chat{ID: 42, Type: string(api.ChatTypePrivate)},
Chat: api.Chat{ID: 42, Type: api.ChatTypePrivate},
From: &api.User{ID: 7, FirstName: "Alice"},
Text: "/start",
}
@@ -88,7 +88,7 @@ func callbackCtx(bot *client.Bot, q *api.CallbackQuery, groups []string) *dispat
func callbackQuery(data string, msgID int64, chatID int64) *api.CallbackQuery {
msg := &api.Message{
MessageID: msgID,
Chat: api.Chat{ID: chatID, Type: string(api.ChatTypePrivate)},
Chat: api.Chat{ID: chatID, Type: api.ChatTypePrivate},
}
return &api.CallbackQuery{
ID: "cb1",
+2 -2
View File
@@ -46,7 +46,7 @@ func msgUpd(userID, chatID int64, text string) api.Update {
}
}
entities = append(entities, api.MessageEntity{
Type: string(api.EntityBotCommand),
Type: api.MessageEntityTypeBotCommand,
Offset: 0,
Length: int64(end),
})
@@ -56,7 +56,7 @@ func msgUpd(userID, chatID int64, text string) api.Update {
Message: &api.Message{
MessageID: 1,
From: &api.User{ID: userID},
Chat: api.Chat{ID: chatID, Type: string(api.ChatTypePrivate)},
Chat: api.Chat{ID: chatID, Type: api.ChatTypePrivate},
Text: text,
Entities: entities,
},
+2 -2
View File
@@ -55,7 +55,7 @@ func TestHandleStart_GreetsUser(t *testing.T) {
bot := client.New("test:token", client.WithHTTPClient(m))
msg := &api.Message{
MessageID: 1,
Chat: api.Chat{ID: 42, Type: string(api.ChatTypePrivate)},
Chat: api.Chat{ID: 42, Type: api.ChatTypePrivate},
From: &api.User{ID: 7, FirstName: "Alice"},
Text: "/start",
}
@@ -82,7 +82,7 @@ func TestHandleEcho_RepliesWithSameText(t *testing.T) {
bot := client.New("test:token", client.WithHTTPClient(m))
msg := &api.Message{
MessageID: 5,
Chat: api.Chat{ID: 42, Type: string(api.ChatTypePrivate)},
Chat: api.Chat{ID: 42, Type: api.ChatTypePrivate},
From: &api.User{ID: 7, FirstName: "Alice"},
Text: "hello echo",
}
-2
View File
@@ -35,7 +35,6 @@ func main() {
// Echo the query as article results.
results := []api.InlineQueryResult{
&api.InlineQueryResultArticle{
Type: "article",
ID: "echo",
Title: "Echo: " + q.Query,
InputMessageContent: &api.InputTextMessageContent{
@@ -43,7 +42,6 @@ func main() {
},
},
&api.InlineQueryResultArticle{
Type: "article",
ID: "upper",
Title: "UPPER: " + strings.ToUpper(q.Query),
InputMessageContent: &api.InputTextMessageContent{
+11 -14
View File
@@ -96,11 +96,10 @@ func kickHandler(c *dispatch.Context, m *api.Message) error {
}); err != nil {
return handleAdminErr(c, m.Chat.ID, err)
}
truVal := true
if _, err := api.UnbanChatMember(c.Ctx, c.Bot, &api.UnbanChatMemberParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
UserID: target,
OnlyIfBanned: &truVal,
OnlyIfBanned: api.Ptr(true),
}); err != nil {
log.Printf("unban after kick: %v", err)
}
@@ -130,23 +129,21 @@ func muteHandler(c *dispatch.Context, m *api.Message) error {
reply(c, m.Chat.ID, "Reply to a user's message with /mute to silence them for 1 hour.")
return nil
}
until := time.Now().Add(time.Hour).Unix()
falseVal := false
if _, err := api.RestrictChatMember(c.Ctx, c.Bot, &api.RestrictChatMemberParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
UserID: target,
Permissions: api.ChatPermissions{
CanSendMessages: &falseVal,
CanSendAudios: &falseVal,
CanSendDocuments: &falseVal,
CanSendPhotos: &falseVal,
CanSendVideos: &falseVal,
CanSendVideoNotes: &falseVal,
CanSendVoiceNotes: &falseVal,
CanSendPolls: &falseVal,
CanSendOtherMessages: &falseVal,
CanSendMessages: api.Ptr(false),
CanSendAudios: api.Ptr(false),
CanSendDocuments: api.Ptr(false),
CanSendPhotos: api.Ptr(false),
CanSendVideos: api.Ptr(false),
CanSendVideoNotes: api.Ptr(false),
CanSendVoiceNotes: api.Ptr(false),
CanSendPolls: api.Ptr(false),
CanSendOtherMessages: api.Ptr(false),
},
UntilDate: &until,
UntilDate: api.Ptr(time.Now().Add(time.Hour).Unix()),
}); err != nil {
return handleAdminErr(c, m.Chat.ID, err)
}
+1 -2
View File
@@ -94,12 +94,11 @@ func main() {
})
return nil
}
isAnon := false
msg, err := api.SendPoll(c.Ctx, c.Bot, &api.SendPollParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Question: question,
Options: pollOptions,
IsAnonymous: &isAnon,
IsAnonymous: api.Ptr(false),
})
if err != nil {
return err
+1 -1
View File
@@ -49,7 +49,7 @@ func TestHandlePing_RepliesWithPong(t *testing.T) {
bot := client.New("test:token", client.WithHTTPClient(m))
msg := &api.Message{
MessageID: 1,
Chat: api.Chat{ID: 42, Type: string(api.ChatTypePrivate)},
Chat: api.Chat{ID: 42, Type: api.ChatTypePrivate},
From: &api.User{ID: 7, FirstName: "Alice"},
Text: "/ping",
}
+919 -178
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -45,6 +45,11 @@ type Field struct {
Type TypeRef `json:"type"`
Required bool `json:"required,omitempty"`
Doc string `json:"doc,omitempty"`
// EnumValues, when non-empty, lists the wire-level string values the
// scraper detected for an enum-like description ("can be A, B or C",
// "always X", parse_mode special-case). Order is doc order, deduped.
// Emitted as a typed Go enum that replaces the field's string type.
EnumValues []string `json:"enum_values,omitempty"`
}
// Kind enumerates TypeRef shapes.
+6 -1
View File
@@ -98,7 +98,12 @@
"kind": "primitive",
"name": "string"
},
"doc": "Mode for parsing entities in the message text."
"doc": "Mode for parsing entities in the message text.",
"enum_values": [
"Markdown",
"MarkdownV2",
"HTML"
]
}
],
"returns": {
+1 -48
View File
@@ -4,57 +4,10 @@
package api
// ParseMode controls how Telegram interprets formatting in message text.
type ParseMode string
const (
ParseModeMarkdown ParseMode = "Markdown" // legacy
ParseModeMarkdown ParseMode = "Markdown"
ParseModeMarkdownV2 ParseMode = "MarkdownV2"
ParseModeHTML ParseMode = "HTML"
)
// ChatType is the type of a Telegram chat.
type ChatType string
const (
ChatTypePrivate ChatType = "private"
ChatTypeGroup ChatType = "group"
ChatTypeSupergroup ChatType = "supergroup"
ChatTypeChannel ChatType = "channel"
)
// 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"
)
// 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"
)
+1 -1
View File
@@ -37,7 +37,7 @@ type SendMessageParams struct {
// Text of the message to be sent.
Text string `json:"text"`
// Mode for parsing entities in the message text.
ParseMode string `json:"parse_mode,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
}
// SendMessage calls the sendMessage Telegram Bot API method.