* ci(workflows): migrate to shared go-pr and go-release workflows
Replace the generic vet, staticcheck, govulncheck, gosec, test and release jobs with the shared go-pr and go-release workflows.
Keep the codegen-clean and audit jobs in ci.yml. Add an examples build job and the ci-success aggregator there.
Add autoupdate.yaml from the shared go-autoupdate workflow.
The release job now runs only on Go source changes and on manual dispatch.
The dry-run input and the Bot API note in the tag message no longer exist.
* ci(release): gate release on repo checks and export Bot API version
Move the codegen-clean, audit and examples jobs into the reusable checks.yml workflow. The ci.yml and release.yml workflows both call it.
The release job now needs the checks job. A failed or cancelled check blocks the release.
Add workflow-prepare.sh. It appends BOT_API_VERSION to GITHUB_ENV so the GoReleaser header shows the Bot API version.
The script does nothing when GITHUB_ENV is unset.
* ci(regen): name release.yml in the merge step comments
The release now runs from release.yml. The comments still named ci.yml as the workflow that cuts the release.
govulncheck@latest resolves to v1.8.0, which declares go 1.26.0 in its
module file. Every job in this workflow pins go-version 1.25.x, so the
install step fails before any scanning runs, which fails ci-success and
skips the release job.
v1.7.0 declares go 1.25.0 and installs on the pinned toolchain. This also
matches the convention used for staticcheck, which is already pinned to
an exact version. Pinning the binary does not freeze the vulnerability
database, which govulncheck fetches at run time.
Claude-Session: https://claude.ai/code/session_01Fu2wXqGUrfVBi5aLafKk8U
The generator gated all union decoder emission behind knownDiscriminators, a
hand-curated map of 13 entries. Any sealed union absent from that map received
no decoder, so decoding must fail. Bot API v10 added RichBlock and RichText,
which were absent. Roughly 8.6 percent of Telegram updates failed with HTTP 400.
The generator now derives discriminators from the specification instead of a
fixed list. A gate check fails the build when a union resolves no discriminator.
Eleven unions gain decoders that never had one: BotCommandScope, InputMedia,
InputPaidMedia, InputPollMedia, InputPollOptionMedia, InputProfilePhoto,
InputRichBlock, InputStoryContent, PassportElementError, RichBlock and RichText.
RichText can also appear as a bare string or an array of RichText. The scraper
now extracts these alternate shapes into the intermediate representation. The
emitter adds RichTextPlain and RichTextSequence. The decoder dispatches on the
first JSON byte before it reads any discriminator.
Unions without alternates must regenerate byte for byte. The golden test proves
this.
Claude-Session: https://claude.ai/code/session_01Fu2wXqGUrfVBi5aLafKk8U
The Telegram page ends with a "page generated in NN.NNms" comment that
changes on every fetch. Change detection saw it, so the run dispatched after
the codegen fix opened and merged a PR whose only content was that comment.
With the merge step in place, that becomes a pointless release every week.
Strip the comment on capture, and gate change detection on the generated
surface instead of testdata/html/, because the snapshot file name and the
latest.html symlink change every run by design. A fresh fetch now matches the
committed snapshot byte for byte while the API is unchanged.
The weekly job already gates the update. `make regen` fails on any audit
fallback and the test step runs the full race suite, so a PR only opens when
both pass. Merge it from the same job.
A PR opened with GITHUB_TOKEN does not trigger ci.yml, so GitHub auto-merge
would wait for checks that never start. The push to main triggers ci.yml,
which cuts the release.
Snapshot taken 2026-07-21. Notable shape change: ReplyParameters.MessageID
becomes optional (*int64) because Bot API 10.2 allows a reply to target
ephemeral_message_id instead. This breaks callers that set MessageID by value,
so examples/echo passes the address now.
New surface includes the ephemeral message methods, sendRichMessage with
InputRichMessageMedia, and the Community service messages.
Telegram rewrote the forwardMessages and copyMessages return sentence from
"an array of MessageId" to "an Array of MessageId". The pattern that handles
the "... of the sent messages is returned" shape matched lowercase only, so
both methods fell back to bool and the weekly regen job failed at the audit
step.
Add approved_any_locations to overrides.json so a union that renders as `any`
by design can be approved as data instead of a code change. Approve
InputRichMessageMedia.Media, a five-variant InputMedia union added in Bot
API 10.2 that has no declared parent type.
Make the drift test compare HEAD against itself. It compared the working tree
against HEAD, so it failed whenever a regen run added a method.
Bot API update added RichBlockListItem.type values (a/A/i/I/1) that
Pascal-case to identical const idents, and answerGuestQuery's non-enum
'result' param picked up answerChatJoinRequestQuery's Result enum via
the shared parent="" enum-plan key.
- key method params as method:<name> in the enum plan byField map
- enumDecl.ConstName resolves case collisions with Lower/Upper prefix
- regenerate api/ from 2026-06-11 snapshot
- regen docs/reference/client.md after the Version docstring rewrite
in the previous SendForModule commit (gomarkdoc output drifted).
- bump golang.org/x/net 0.54.0 -> 0.55.0 for the govulncheck fixes
(GO-2026-5027/5028/5029/5030 — html.Parse XSS / DoS chain reachable
from cmd/scrape).
Replaces the hand-bumped client.Version="0.7.11" with
telemetry.SendForModule which reads the actual module version from
runtime/debug.BuildInfo.Deps at the consumer's build time. This
means future releases (v0.7.12, v0.8.0, …) will be reported by the
running bot WITHOUT requiring a manual edit of client/version.go on
every tag — consumers just `go get -u` and rebuild.
client.Version is preserved as a fallback for replace directives /
detached `go run`, and remains overridable via -ldflags.
Bumps oss-telemetry to v0.2.1.
Replaces the duplicated opt-out table with a link to the canonical
oss-telemetry README section. Keeps the project-specific env var name
and the local source pointer; removes drift risk for the rest.
Sends one fire-and-forget POST per process the first time a consumer
constructs a Bot via client.New. Helps track real-world adoption and
version spread of the library. No identifiers, no API contents.
Implementation:
- new client/version.go: exported Version var (currently 0.7.11)
- new client/telemetry.go: sync.Once gate + telemetry.Send call
- client.go New(): single fireTelemetryOnce() line at function entry
- client/telemetry_test.go: TestMain disables outgoing pings during the
library's own test suite
README §Telemetry documents the payload, the opt-out env vars, and links
to the upstream oss-telemetry source so consumers can audit what is sent.
Opt out via any of:
DO_NOT_TRACK=1
OSS_TELEMETRY_DISABLED=1
GO_TELEGRAM_DISABLE_TELEMETRY=1
osstelemetry.Disable() before the first client.New
actions/cache v4 -> v5 (Node 24 runtime; no API change)
actions/upload-artifact v4 -> v7 (ESM + Node 24; existing name/path usage unaffected)
Clears the last Node 20 deprecation warnings surfaced during the
release run in d6ecbde.
regen-from-fixture (used by ci.yml codegen-clean gate) hardcoded
testdata/html/snapshot_2026-05-08.html. After regen.yml committed a
newer snapshot via PR, the pinned path drifted from committed api/
output and codegen-clean failed on main.
Resolving SCRAPE_INPUT via `readlink testdata/html/latest.html` lets
each regen automatically advance the fixture pointer without a manual
Makefile bump, while preserving the override (`SCRAPE_INPUT=path \
make regen-from-fixture`) for ad-hoc replays of historical snapshots.
actions/checkout@v4 -> @v6, actions/setup-go@v5 -> @v6,
peter-evans/create-pull-request@v7 -> @v8. Pinned major
versions; all three were flagged by the runner deprecation
notice (Node 20 forced off June 2 2026, removed Sept 16 2026).
Adds an alternative HTTPDoer backed by valyala/fasthttp for high-throughput
bots. Cuts per-call allocs from 102 to 56 in the cross-library bench
(within 8 of telego, which uses fasthttp by default), and per-call bytes
from 11.1 KiB to 6.6 KiB.
bot := client.New(token,
client.WithHTTPClient(client.NewFastHTTPDoer()),
)
Implementation notes:
- Wraps *fasthttp.Client behind the existing HTTPDoer (Do *http.Request)
interface, so RetryDoer, custom transports, observability middleware,
and the 1428 generated tests all keep working as-is.
- Translates *http.Request -> fasthttp.Request once per call and
returns a *http.Response whose Body releases the pooled fasthttp
response on Close (net/http contract).
- Recognises the bufferReadCloser / readerReadCloser shapes produced
by buildRequest and passes their underlying bytes straight to
SetBodyRaw -- no io.ReadAll, no copy.
- Honours ctx.Deadline via DoDeadline, falls back to WithFastHTTPReadTimeout
when no deadline is set. fasthttp.ErrTimeout maps to
context.DeadlineExceeded for errors.Is compatibility.
Default stays net/http: fasthttp is HTTP/1.1 only, doesn't compose with
the http.RoundTripper middleware ecosystem, and most users don't have
the throughput to notice. Bots making thousands of API calls/sec should
opt in.
Multipart/file-upload path remains on net/http per the agreed scope --
the perf bottleneck was JSON-method round-trip, not file uploads.
Time numbers in the report deferred until a quiet-system bench run;
allocs/bytes numbers (which are deterministic per code path) are
already updated.
Two changes that together cut allocs/call from 15 to 13 (client-internal
bench) and per-call CPU from 600ns to 455ns (-24%) on the no-HTTP path:
1. Codec gets an optional BodyEncoder extension (MarshalTo io.Writer).
When present, encodeJSONBody stream-encodes the request directly into
a pooled *bytes.Buffer instead of allocating a [2-step] Marshal+Reader
pair. DefaultCodec implements it via goccy/go-json.NewEncoder.
2. *Bot caches the parsed base URL on construction. buildRequest skips
net/http.NewRequestWithContext for the common case and constructs
*http.Request manually — clones the URL by value, sets the method
path, and populates ContentLength + GetBody from the body's concrete
type so RetryDoer's body-replay across attempts still works.
Cross-library bench (sendMessage round-trip vs httptest.Server): -2
allocs/call (104 -> 102), bytes -1.2%, time within noise (real HTTP
plumbing dominates). The CPU win is visible on synthetic stub-doer
benches and translates to lower GC pressure on sustained-throughput
workloads.
Slow-path fallback preserved for codecs that don't implement BodyEncoder
and for *Bot instances where url.Parse on the configured base failed —
they take the original NewRequestWithContext path.
Adds test/benchmarks/ as a separate Go module so competitor deps
(go-telegram-bot-api/v5, telebot.v3, go-telegram/bot, telego,
echotron/v3) stay out of the root go.mod.
Hot paths covered:
- Webhook decode (small Update -> typed Update struct)
- Large unmarshal (Update with entities + reply markup + photo array)
- API round-trip (sendMessage against httptest.Server)
- Dispatch route (20 handlers, last-registered matches)
Results on Apple M4 Max / go1.26.2: ours wins 3 of 4 paths and is
2nd of 5 in the round-trip path. Full report at
docs/benchmarks/2026-05-10-comparison.md, raw output committed under
test/benchmarks/results/.
Caveats called out in the report:
- codec asymmetry (we ship goccy/go-json; competitors mostly stdlib)
- echotron call bench skipped — built-in rate limiter not externally
configurable; would measure throttling, not the library
- dispatch bench limited to libs with a public sync entry point
(ours, telebot, gobot); gotba has no dispatcher, telego/echotron
use channel/per-chat paradigms not directly comparable
Also gitignores docs/superpowers/ (local brainstorm/spec scratch)
and regenerates docs/reference/dispatch.md after the new
Router.Process method.
Adds a public synchronous entry point that runs a single update through
the global middleware chain and the dispatch table. Useful for callers
that source updates outside the standard transport.Updater flow:
custom webhook frameworks, message-bus consumers, and cross-library
benchmarks driving the router without spinning up Run.
Honours global middleware (Use); bypasses Run's concurrency semaphore
since the caller controls parallelism.
Replace the hand-rolled make([]byte, 0, 1024) + make([]byte, 4096) read loop in WebhookServer.ServeHTTP with a sync.Pool-backed bytes.Buffer drained via ReadFrom, fronted by http.MaxBytesReader for the 1 MiB body cap.
putWebhookBuf caps Cap() at 256 KiB before returning to the pool so a rare oversized update (max body is 1 MiB) doesn't permanently bloat the pool.
Bench delta on Webhook_ServeHTTP: 2564ns -> 2020ns (-21%), 12707B -> 7648B (-40%), 24 -> 23 allocs. The big byte saving is the 4 KiB tmp buffer + 1 KiB initial buf cap, replaced by one reused buffer across requests. The remaining alloc count is dominated by codec.Unmarshal decoding Update's pointer fields (*string, *int64), which is downstream of this change.
Replace io.ReadAll(resp.Body) on the typed Call/callMultipart paths with a sync.Pool-backed bytes.Buffer + ReadFrom. Saves the 512B initial allocation that ReadAll grows from on every successful call.
The pool only covers paths whose decoder copies strings out of the input (decodeResult delegates to goccy/go-json, which copies). CallRaw and callMultipartRaw return slices that alias the buffer storage, so they keep the io.ReadAll path; pooling there would need a defensive copy that defeats the saving.
putRespBuf caps Cap() at 64 KiB before returning to the pool so a single oversized response (e.g. large getFile metadata) doesn't bloat the pool for the rest of the process.
Bench delta on Call_BoolResponse: 14 allocs -> 13 allocs, 1842B -> 1331B, 526ns -> 479ns. Same shape on Call_StructResponse (16 -> 15, 1973B -> 1462B).
Move the three conventional Values keys ("command", "command_args", "regex_match") to typed fields on Context. Router and group routing write the fields directly; the Values map is allocated lazily via the new Set method and reserved for user-defined custom keys.
Allocation impact (M4 Max, b.Loop()):
DispatchCommand: 5 allocs/op -> 1, 153ns -> 69ns (-55%)
DispatchTextRegex: 5 allocs/op -> 2, 181ns -> 107ns (-41%)
DispatchFilter: 2 allocs/op -> 1, 32ns -> 19ns (-41%)
NewContext: 5.79ns -> 1.60ns
Trade-off: Context struct grew from ~48B to ~96B (three new fields), so filter-only paths pay ~50B more per dispatch. Command/regex paths save ~320B + 4 allocs each, which dominates for typical bot workloads.
Handlers reading c.Values["command"], c.Values["command_args"], or c.Values["regex_match"] now get nil; the typed fields c.Command, c.CommandArgs, c.RegexMatch are the new accessors. Custom keys still work via c.Set(k, v) and c.Values[k].
Two changes on the Call hot path:
* Replace httpReq.Header.Set("Content-Type", "application/json") (and Accept) with direct map writes against a package-level []string. Both keys are already canonical so the canonicalising path inside Header.Set was pure overhead; saves the per-call []string{val} allocation x2.
* Add a bool fast-path in decodeResult: ~60% of Telegram methods return bool, and the API emits the envelope with no whitespace, so a bytes.Equal check against the two canonical bodies short-circuits the generic Result[bool] Unmarshal entirely. any(true).(Resp) does not box thanks to Go's static bool interface values.
Combined effect on Call_BoolResponse: 18 -> 14 allocs/op, 634ns -> 526ns. DecodeResult_Bool isolation bench: 50ns / 2 allocs -> 2.87ns / 0 allocs.
Hermetic benchmarks (no network) covering Call encode+decode, webhook ServeHTTP body parse, and Router dispatch (command/regex/filter). Use Go 1.24+ b.Loop() idiom. .benchstats/baseline.txt pins the pre-optimisation numbers for benchstat comparisons.
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.
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.
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.
Both SetWebhookParams.AllowedUpdates and GetUpdatesParams.AllowedUpdates
(plus the WebhookInfo.AllowedUpdates field on the response side) were
typed []string, forcing callers to cast every typed constant:
AllowedUpdates: []string{
string(api.UpdateMessage),
string(api.UpdateMyChatMember),
...
}
Switch to []UpdateType so the same call site is typo-safe end-to-end:
AllowedUpdates: []UpdateType{
api.UpdateMessage,
api.UpdateMyChatMember,
...
}
Wire format is unchanged — UpdateType is type UpdateType string, marshals
identically as JSON strings. The MultipartFields()/runtime.go encoding
paths likewise continue to work via json.Marshal on the typed slice.
Implementation note: api/methods.gen.go and api/types.gen.go are
generated by cmd/genapi from internal/spec/api.json, where the field is
described as Array of String. The Telegram docs do not enumerate the
allowed_updates values inline, so the scraper cannot synthesise the
enum and UpdateType lives hand-curated in api/enums.go (see existing
doc comment there). The retype is therefore done as a small pinned
override inside cmd/genapi/emitter.go's goField — keyed on the wire
field name allowed_updates with elem type string — so the change
survives a future regeneration of the .gen.go files. transport/longpoll
drops the now-unnecessary []string conversion.
Backward incompatibility: callers passing untyped []string variables
will need to convert; callers using untyped string literals inside the
slice ARE fine because Go's untyped-literal rule auto-converts.
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.
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.
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.