v2: runtime-agnostic TypeScript rewrite#1318
Conversation
|
Code review against Findings[P2]
|
[ P2 ] Context.chat / Context.from exhaustive over Update variants
Replace the hand-written if-chains with a `satisfies Record<UpdateType, …>`
resolver table per getter, so a newly-generated Update variant becomes a
compile error instead of silently returning undefined. Adds the 7 chat
variants and 4 from variants the chain used to miss (business_message,
edited_business_message, deleted_business_messages, guest_message,
message_reaction_count, chat_boost, removed_chat_boost,
business_connection, managed_bot) - ctx.reply no longer throws for
handlers on those update types. Regression tests added.
[ P3 ] webhook earlyAck no longer drops handler errors silently
When no bot.catch() boundary is registered, handleUpdate rethrows and
the defensive .catch used to swallow it with no trace. Now logs the
message + stack via the debug sink (rendering the Error explicitly,
since %o would JSON-stringify an Error to "{}"). Test added.
[ P3 ] TokenBucket.take closes the concurrent-oversell race
Post-wait recheck loop: two takes that both see an empty bucket no
longer both decrement on wake; the second sees the bucket drained
again and waits another cycle. Aborted takes still consume no token.
Regression test added.
[ P3 ] RateLimiter per-chat map is now a bounded LRU
maxChatBuckets option (default 10_000); the least-recently-used chat's
bucket is evicted on insert, bounding memory for long-lived bots that
talk to many chats. LRU ordering test added.
[ P3 ] poll_answer.from documents the voter_chat case
fromOf.poll_answer resolves to .user with an inline note that
voter_chat (a Chat, not a User) carries anonymous chat votes.
No public API breakage from these fixes. typecheck, lint:core, check:edge,
and the full unit suite (86 tests) pass on both Bun and Node.
[ P2 ] Context.chat / Context.from exhaustive over Update variants
Replace the hand-written if-chains with a `satisfies Record<UpdateType, …>`
resolver table per getter, so a newly-generated Update variant becomes a
compile error instead of silently returning undefined. Adds the 7 chat
variants and 4 from variants the chain used to miss (business_message,
edited_business_message, deleted_business_messages, guest_message,
message_reaction_count, chat_boost, removed_chat_boost,
business_connection, managed_bot) - ctx.reply no longer throws for
handlers on those update types. Regression tests added.
[ P3 ] webhook earlyAck no longer drops handler errors silently
When no bot.catch() boundary is registered, handleUpdate rethrows and
the defensive .catch used to swallow it with no trace. Now logs the
message + stack via the debug sink (rendering the Error explicitly,
since %o would JSON-stringify an Error to "{}"). Test added.
[ P3 ] TokenBucket.take closes the concurrent-oversell race
Post-wait recheck loop: two takes that both see an empty bucket no
longer both decrement on wake; the second sees the bucket drained
again and waits another cycle. Aborted takes still consume no token.
Regression test added.
[ P3 ] RateLimiter per-chat map is now a bounded LRU
maxChatBuckets option (default 10_000); the least-recently-used chat's
bucket is evicted on insert, bounding memory for long-lived bots that
talk to many chats. LRU ordering test added.
[ P3 ] poll_answer.from documents the voter_chat case
fromOf.poll_answer resolves to .user with an inline note that
voter_chat (a Chat, not a User) carries anonymous chat votes.
No public API breakage from these fixes. typecheck, lint:core, check:edge,
and the full unit suite (86 tests) pass on both Bun and Node.
…al helpers, fixtures; bring redesign docs
…errors, curated types
… adapters, tests - single Api class (ADR-001), encodeForm 3-branch / no serialization (ADR-002/010/011) - Json<T> builders: InlineKeyboard, fmt()/EntityBuilder, mediaGroup() FormPart - dispatch: compose, Context, longPoll generator, webhookCallback - adapters: Express/Next mounts (core), fromPath + createWebhookServer (node subpath) - subpath exports ./ ./node ./types (ADR-009) - bonus: scripts/schemas-to-v2.ts emits generated-v2.ts (180 methods, discriminated Update) - 15 bun unit tests green; tsc + build clean
From-scratch v2 of the library, executing the architecture design. Breaking,
no backward compatibility with the v1 surface.
Core (src/core, zero node: imports — CI-enforced):
- Transport: injectable fetch, AbortSignal merge + timeout, {ok,result}
envelope unwrap, 429 retry honoring retry_after (ADR-005/008)
- encodeForm: three-branch encoder (file part / form-part composite / string),
urlencoded unless an InputFile is present (ADR-002/010/011); library
serializes nothing in the pipeline
- Errors: TelegramBotError base + NetworkError/TimeoutError/ParseError/
TelegramApiError with structured fields (ADR-008)
- Single generated Api class: one concrete single-argument method per Bot API
method, no Proxy, no Raw/Api split (ADR-001)
- Builders: json(), InlineKeyboard/ReplyKeyboard, EntityType + fmt(), and
mediaGroup() form-part builder minting attach:// refs at the call site
- Dispatch: typed koa-compose, per-update Context, Bot (use/on/command/hears/
catch/start/stop), longPoll async generator, webhookCallback as a pure
(Request)=>Response, plus Express/Next.js adapters (ADR-003/004/005)
Types (src/types): generator (scripts/api-parser.ts) reworked to emit a
discriminated Update union (ADR-007), branded Json<T> structured params
(ADR-002), InputFile|string file params (ADR-006), expanded MessageEntity, and
the generated Api class. 327 objects / 25 unions / 180 methods.
Node helpers (src/node, only folder allowed node:*): fromPath (fs uploads),
createWebhookServer (node:http -> core callback), run (managed polling).
Packaging: ESM-only, subpath exports (./, ./node, ./types); tsconfig (strict).
Tests: 43 unit tests (injected fetch, no network). check script gates on
tsc --strict + a no-node:-in-src/core lint + the suite. Docs: README usage
examples and redesign/MIGRATION.md (v1->v2 cheatsheet).
Address the redesign/ACTIONABLES.md red-team list. Decision on A1: keep serialization model E and document the justification (no model switch). Code: - A2: restore the "Json<T> is always a string at runtime" invariant. Nested-file params (sendMediaGroup/sendPaidMedia/editMessageMedia .media) are typed `Json<X> | FormPart`; mediaGroup().build() returns a real FormPart (no cast-through-Json); FormPart.writeTo(sink, key) receives the bound field key (no hardcoded "media"). Generator updated + schemas regenerated. - M2: transport retries network/timeout/5xx (not just 429) with exponential backoff + jitter (retryBackoffMs, bounded by maxRetries); isTransientError() classifier; longPoll resilient loop (retry/maxBackoffMs/onError) that backs off and resumes on transient errors, rethrows fatal 4xx, returns on abort. - M3: opt-in token-bucket rate limiting (TokenBucket, RateLimiter; global + per-chat) via TransportOptions.rateLimit; default off, zero overhead. - M6: webhook early-ACK (fastAck / waitUntil) and constant-time secret compare (safeEqual); update-dedup guidance. - M5: core-isolation lint now also flags Node-only globals; new check:edge bundles src/core for a browser/edge target and fails on any reachable Node builtin; wired into `npm run check`. Docs: - A1 "Why E over D" (serialize-once/reuse-many axis); A3 drop false tree-shake-per-method claim; A4 single-params-arg + optional AbortSignal; M1 §6.8 real signal mechanism + runtime support matrix; M4 fix ADR-010 rationale; L1 qualify "serializes nothing"; L2 json() brand limitation; L3 Update-union caveats; §10 open questions updated. - MIGRATION.md: ESM-only / name-retention / CJS interop (L5). - README: runtime matrix, resilience + rate-limit + fastAck docs. - Add redesign/RESEARCH-go-rust-clients.md (L4); tick redesign/ACTIONABLES.md. Verified: tsc --strict + lint:core (imports + globals) + check:edge + 62 unit tests, all green.
Per request, undo only the A1 and A2 changes from commit e9328cc; keep A3, A4, and all Medium/Low items. A2 (code): the generator emits structured params as `Json<T>` only — no `Json<X> | FormPart` union. `mediaGroup().build()` returns its value via the `as unknown as Json<…>` cast again, and `FormPart.writeTo(sink)` drops the field-key parameter (back to the hardcoded "media"). Restored scripts/api-parser.ts, src/core/{files,encode,media}.ts from 8bff718 and regenerated src/types/schemas.ts. A1/A2 (docs): removed the ADR-002 "Why E over D" subsection and the "serialization frequency" table note (A1); reverted the `Json<T>`-always-a- string / distinct-`FormPart` wording in §6.2, §6.4, ADR-002 and ADR-011 (A2). Kept A3 (no per-method tree-shaking), A4 (single params arg + optional AbortSignal), M1/M4 and L1/L2/L3 doc edits, and the §10 updates. Verified: tsc --strict + lint:core (imports + globals) + check:edge + 62 unit tests, all green.
- e2e (bun, live-gated on NODE_TELEGRAM_TOKEN): test/e2e/ adds an attempt-all
wiring matrix — one entry per generated Api method (180), each called
arg-less and asserted to produce a real round-trip (resolve OR
TelegramApiError), failing on NetworkError/ParseError/other (a client-side
wiring bug). 10 dangerous arg-less mutators (logOut/close/deleteWebhook/
getUpdates/deleteMyCommands/setMy{Name,Description,ShortDescription,
DefaultAdministratorRights}/setChatMenuButton) are skipped with reasons. Plus
a happy-path suite (send→edit→delete, chat action, inline keyboard, dice)
against TEST_GROUP_ID with cleanup. Live run: matrix 170 pass / 10 skip / 0
fail; happy-path 6/6.
- examples/: 12 runnable, idiomatic .ts examples (polling, API client, webhook
Workers/Express/Next, keyboards, formatting, uploads+media, middleware,
resilience+rate-limit, longPoll stream, conversation) + README index. They
import the package name and typecheck against src via tsconfig.examples.json
(path map).
- Removed redesign/RESEARCH-go-rust-clients.md and its links/citations in
ARCHITECTURE.md (§6.1, ADR-001, ADR-010); gotgbot/teloxide mentions kept.
- package.json: add test:e2e + typecheck:examples; check now also runs the
examples typecheck.
Static gate green: tsc --strict (src + examples) + lint:core + check:edge + 62
unit tests.
- e2e: replace the multi-file suite (matrix/happy-path/_env) with ONE test/e2e/methods.test.ts — a describe per Bot API method (all 180) + a coverage parity test, logOut/close last. Strict model: no roundTrip, no shared beforeAll; each test does any setup inline and calls the method for real — a rejection FAILS the test (env-limited methods will legitimately fail live; that is intended). No DANGEROUS_ARGLESS denylist (droppable test bot). - api: remove the fmt() and mediaGroup() factory functions; the EntityBuilder and MediaGroup classes are the public surface (use `new EntityBuilder()` / `new MediaGroup()`). Updated all consumers: unit tests, examples, README, ARCHITECTURE, MIGRATION, and src doc comments. - tsconfig: move tsconfig.test.json -> test/tsconfig.json and tsconfig.examples.json -> examples/tsconfig.json; add typecheck:test + typecheck:examples scripts; `check` now runs both. test:e2e uses --timeout 300000. - remove redesign/ACTIONABLES.md (no longer needed). Static gate green: tsc (src node-only) + typecheck:test + typecheck:examples + lint:core + check:edge + 62 unit tests. E2E not run live (bot flood-limited; run `npm run test:e2e` after it idles ~30 min per the run-tests skill).
Replace every U+2014 "—" with "-" and U+2026 "…" with "..." across all tracked files (source, tests, examples, docs, scripts). Add an ASCII sanitizer to scripts/api-parser.ts so a future `generate:types` can't reintroduce them into the generated src/types/schemas.ts and src/core/api.ts. (The requested webhook unit test - wrong secret token -> 401 with handleUpdate not invoked - already exists in test/unit/webhook.test.ts, so no duplicate was added.) Gate green: tsc + typecheck:test + typecheck:examples + lint:core + check:edge + 62 unit tests.
…mics - files/media: generalize FormPart.writeTo(sink, key); add per-structure attach:// builders (StickerSetBuilder, inputSticker, profilePhoto, storyContent); drop the inputFile() factory for `new InputFile()` - transport: bound the retry loop to maxRetries+1 (behavior unchanged) - debug: DEBUG="node-telegram-bot-api:*" tracing to stderr - edge-safe pluggable core sink wired to env/stderr by the /node entry; traces in transport, longpoll, webhook; bun test preload so `DEBUG=... bun test` prints - bot: rename start() -> startPolling(); add /node startWebhook() runner and a raw node:http webhook example (13); document webhook auth limits - types: prefer `satisfies` over `as` where it applies (bot.ts + e2e literals) - e2e: always run (drop the live skip guard); forge real minimal media fixtures (gif/mp4/mp3/ogg/jpeg) so uploads are well-formed; sticker/profile/story tests use the new builders Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace placeholder/invalid params that could never pass live with valid
data, cross-referenced against the v1 integration suite:
- getStickerSet: assert stickers.length > 0, not the case-sensitive name
(Telegram echoes canonical casing, e.g. "Pusheen").
- setMyName: capture + restore a real name (empty {} -> BOT_TITLE_INVALID).
- setMyProfilePhoto: real 640px JPEG (1x1 PNG -> PHOTO_CROP_SIZE_SMALL).
- editMessageMedia: start from a different image so the edit is a real
change (identical media -> "message is not modified").
- savePreparedInlineMessage: add allow_user_chats.
- setPassportDataErrors: base64 element_hash sized to 32 bytes (SHA-256).
- answerShippingQuery: add required error_message (stays env-limited).
- sticker-set lifecycle: valid `_by_<username>` set names + a withOwnedSet
helper (regular/mask/custom_emoji) that creates a real owned set, runs the
op, and deletes it. Kept self-contained per method; dense runs flood
Telegram's set-creation cap (429), an accepted env limit.
Fixtures: add Telegram-blue STICKER_PNG (512x512), STICKER_EMOJI_PNG
(100x100), PROFILE_JPEG (640x640) at the exact dimensions Telegram requires.
Also skip logOut (terminates the session, ~10 min token lockout); the
describe stays registered so the coverage guard still sees it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Skip the `close` terminator too (alongside the already-skipped `logOut`): both end the bot session, so running the suite never bricks the shared test bot. Both stay registered so the coverage guard still sees them. - Consolidate the sticker-set lifecycle onto a few SHARED owned sets (one each of regular/mask/custom_emoji), created lazily and deleted in afterAll, rather than one fresh set per test. createNewStickerSet is flood-capped per bot (429 retry-after ~176s); creating ~13 sets per run hit the cap and the tail tests blew the per-test timeout. Now ~5 creations total. Verified live: 13/13 lifecycle tests pass in ~31s (was 644s + 2 timeouts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dia engine; PaidMediaGroup
The nested-file builders used to cast their FormPart output through
`as unknown as Json<T>` - a lie: build() returned an object, not a branded
string, and the encoder only worked by sniffing isFormPart first. Replace
that with honest types:
- FilePart<T> = FormPart & { __files: T } - what build() actually returns
(a FormPart at runtime; the only cast now is attaching that phantom brand).
- JsonWithInputFiles<T> = Json<T> | FilePart<T> - the honest type for a
structured field that may carry uploads. The encoder already dispatches
string | FormPart, so no pipeline change.
- CarriedBy<J> extracts a field's payload type so a builder returns the exact
FilePart<T> without respelling the wire union (e.g. sendMediaGroup).
Generator (api-parser.ts) now emits JsonWithInputFiles<T> for fields whose
type reaches a file-bearing Input* type (FILE_BEARING_TYPES); schemas.ts has
the identical change hand-applied (the generator fetches live docs, so it
was not run). 10 fields widened; everything else stays a pure Json<T> string.
Builders build plain typed objects (no Record<string, unknown> bag); a single
AttachedMedia engine walks the graph and lets each InputFile.build(index)
mint its own attach://media_<index> ref while the bytes are collected as the
matching part.
Add PaidMediaGroup (sendPaidMedia): InputPaidMedia is file-bearing on the wire
exactly like InputMedia, so it now has a builder and JsonWithInputFiles field
rather than an arbitrary Json<T> exception. Named ...Group because PaidMedia
is already a Bot API response type.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both sendMediaGroup and sendPaidMedia accept a live_photo album item
(InputMediaLivePhoto / InputPaidMediaLivePhoto), which carries TWO uploadable
files - the live photo (`media`) and its still cover (`photo`). The builders
were missing it, an arbitrary gap. Add livePhoto(media, photo, options?); the
AttachedMedia walk already handles multi-file items, so each file takes the
next attach slot (media -> media_0, photo -> media_1). Unit test per builder.
Note: the wire `type` discriminant ("live_photo") follows the Bot API doc
convention; the generated types only carry `type: string`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…les + the generator allow-list
Simplify the structured-field type vocabulary (research: S2). Split the old
Json<T> brand into:
- JsonString<T> = string & { __json: T } - the JSON-string arm (json() and the
file-free builders InlineKeyboard/EntityBuilder produce it; still a string, so
JSON.parse() on a built value keeps compiling).
- Json<T> = JsonString<T> | FilePart<T> - what EVERY structured field accepts.
Because the file-carrying arm now lives inside Json<T>, every structured field
is just Json<T> - there is no separate "this field can upload" type. So:
- DELETE JsonWithInputFiles<T> (== Json<T> now).
- DELETE the generator's FILE_BEARING_TYPES allow-list and its membership scan
(transformParamType now always emits Json<T> for structured fields). This
removes the silent-under-widening maintenance footgun: a new upload type needs
only a builder, never an allow-list edit.
Json/JsonString/CarriedBy live in brand.ts (type-only import of FilePart from
core/files.js; erased, no runtime cycle - edge bundle stays Node-free). The
file-free builders and caption_entities return JsonString<T> (honest: they are
strings), which avoids the JSON.parse breakage a blanket Json<T> would cause.
Guarantees preserved (verified by @ts-expect-error probe): a bare string / plain
object in a structured field is still a type error, and cross-field precision
holds - a MediaGroup result still cannot be assigned to a sticker field (the
FilePart<T> brand survives the fold). Full check green; no test changes needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Structured request fields (reply_markup, entities, media, ...) are now plain typed objects/arrays instead of branded Json<T> wire-ready strings. A single serializeParams() (src/core/serialize.ts), called once in the generated Api.request before the retry loop, does the JSON.stringify plus the recursive attach:// walk. Nested InputFile is resolved at depth >= 1 (top-level files still attach under their field name), with one per-call counter so sendPoll's two file-capable fields mint media_0/media_1. Deletes the 4-construct brand vocabulary (Json<T>, JsonString<T>, FilePart<T>, CarriedBy<J>), the json() helper, src/types/brand.ts, src/core/json.ts, and the generator's param-only mapping path (transformParamType/isPrimitiveType). The generator now has one mapping path; InputFile maps to "InputFile | string" and the nested file-target members (media/thumbnail/photo/cover/animation/video/sticker on Input* types) widen string -> "InputFile | string", making nested uploads expressible without builder machinery. Builders (InlineKeyboard, EntityBuilder, MediaGroup, ...) are kept as optional sugar and now return plain shapes; the AttachedMedia walk was promoted into serialize.ts. Edge-neutral: serialize.ts is pure JS, core bundle stays Node-free. Gates: 0 Json< / 44 InputFile | string / 182 Result = in schemas; full npm run check green (tsc src+test+examples, core lint, edge bundle, 75 unit tests). Live wire smoke (8 methods) + live nested sendMediaGroup upload (plain literal InputFile -> attach://media_0) accepted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ADR-002 docs
Pipeline hardening (from the encodeForm/Transport.request review):
- Encode the request body ONCE, before the retry loop. Re-encoding per attempt
re-consumed a one-shot ReadableStream InputFile, so any retry (429/5xx/network)
of a streamed upload threw a raw TypeError out of request(). A FormData/
URLSearchParams body is safe to send repeatedly. Proven with a red/green test.
- Read response.text() inside the fetch try, so a mid-stream body-read failure is
classified (NetworkError) and retried as transient instead of escaping raw.
Simplify encodeForm + tighten types:
- New WireValue = string | number | boolean | InputFile | FormPart. serializeParams
-> Transport.request -> encodeForm now all speak Record<string, WireValue>; the
unknown is gone from the wire path.
- FormPart becomes a plain data record ({ json, files }) instead of a behavior
object; the FormSink interface and the writeTo indirection are deleted, so
encodeForm spreads a FormPart inline. null-stripping is owned solely by
serializeParams (its rightful place); URLSearchParams built from entries.
Cleanup:
- Remove the dead InputProfilePhotoInput prelude type (generator + schemas); the
live type is InputProfilePhoto.
Docs:
- Migrate the ARCHITECTURE.md narrative tier (principles, goals, the diagram,
package layout, sec 6.3/6.4, breaking-changes, ADR-010/011 rationale, roadmap,
summary, risks) off the superseded Json<T> model to Option D. The deliberate
history blocks (sec 6.2, ADR-002, both banner'd) are left intact.
check green (tsc src+test+examples, core lint, edge bundle Node-free at 49908 B,
77 unit tests incl. 2 new transport regressions).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…params Every media.ts builder is now `new XBuilder(...).build()`: - Suffix all builders with `Builder` (MediaGroupBuilder, PaidMediaGroupBuilder, StaticProfilePhotoBuilder, AnimatedProfilePhotoBuilder, PhotoStoryBuilder, VideoStoryBuilder; StickerSetBuilder already suffixed). - Every method takes ONE object using the Bot API's own field names, typed straight from the generated types via Omit<Input*, "type"> - the builder only adds the `type` discriminant. Deletes ~13 hand-rolled option types (Media, PhotoOptions, PaidVideoOptions, StoryVideoOptions, GroupItem, ...). - Drop StickerBuilder: under API-exact field names it was pure identity, so addStickerToSet/replaceStickerInSet take the plain InputSticker object. media.ts 273 -> 193 lines. Call sites (e2e, examples, README, ARCHITECTURE, MIGRATION) updated. check green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ove WireValue - Cap 429 retry_after at maxRetryAfterMs (TransportOptions, default 60s; 0 disables). A longer flood-wait surfaces the TelegramApiError immediately (err.retryAfter intact) instead of hanging - the per-request timeout never bounded that sleep (review finding #2). New unit test (returns instantly, no retry). - serializeParams: replace the unchecked `as string|number|boolean` cast with a real typeof guard; a bigint/symbol/function (never emitted by the generated types) now throws a clear TypeError instead of corrupting the wire. - Relocate WireValue from files.ts (file primitives) to serialize.ts (its producer/owner); encode.ts and transport.ts import the type from there. check green (78 unit tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- add @biomejs/biome devDependency and a `biome` script running `biome check --write` with style flags (2-space, double quotes, semicolons, trailing commas, line-width 120) so no biome.json is needed - replace the vestigial `/* eslint-disable */` header in the two generated files with biome file-level suppression (lint + format + organizeImports), emitted from scripts/api-parser.ts so regen stays in sync Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apply `npm run biome` (biome check --write) across the hand-written sources. Mechanical only: 2-space indent, double quotes, semicolons, trailing commas, line-width 120, plus biome safe lint fixes and import sorting. The two generated files (src/core/api.ts, src/types/schemas.ts) are biome-suppressed and untouched. npm run check green: tsc (src/test/examples), lint:core, edge bundle, 78 unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- biome script targets ./src (was .) with explicit expand=auto, so it formats only sources and leaves examples/tests/scripts untouched - examples/06-keyboards.ts: inline the ReplyKeyboard builder into reply_markup, one call per line for readability Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The two timeout-classification tests built a fake fetch that settled only
when the request's AbortSignal.timeout() fired. That timer is unref'd, and
with no real I/O nothing kept the event loop alive, so Node 22's node:test
runner drained the loop with the test promise still pending ("Promise
resolution is still pending but the event loop has already resolved"),
cancelling the rest of the Transport suite (9 cancelled -> non-zero exit).
Node 24/26 hold the loop open and passed.
Reject directly with the same named errors the catch-block classification
keys on (AbortError, and the "TimeoutError" DOMException shape), matching
the network-failure/caller-abort tests. Deterministic on every Node
version; assertions unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
[ P2 ] Context.chat / Context.from exhaustive over Update variants
Replace the hand-written if-chains with a `satisfies Record<UpdateType, …>`
resolver table per getter, so a newly-generated Update variant becomes a
compile error instead of silently returning undefined. Adds the 7 chat
variants and 4 from variants the chain used to miss (business_message,
edited_business_message, deleted_business_messages, guest_message,
message_reaction_count, chat_boost, removed_chat_boost,
business_connection, managed_bot) - ctx.reply no longer throws for
handlers on those update types. Regression tests added.
[ P3 ] webhook earlyAck no longer drops handler errors silently
When no bot.catch() boundary is registered, handleUpdate rethrows and
the defensive .catch used to swallow it with no trace. Now logs the
message + stack via the debug sink (rendering the Error explicitly,
since %o would JSON-stringify an Error to "{}"). Test added.
[ P3 ] TokenBucket.take closes the concurrent-oversell race
Post-wait recheck loop: two takes that both see an empty bucket no
longer both decrement on wake; the second sees the bucket drained
again and waits another cycle. Aborted takes still consume no token.
Regression test added.
[ P3 ] RateLimiter per-chat map is now a bounded LRU
maxChatBuckets option (default 10_000); the least-recently-used chat's
bucket is evicted on insert, bounding memory for long-lived bots that
talk to many chats. LRU ordering test added.
[ P3 ] poll_answer.from documents the voter_chat case
fromOf.poll_answer resolves to .user with an inline note that
voter_chat (a Chat, not a User) carries anonymous chat votes.
No public API breakage from these fixes. typecheck, lint:core, check:edge,
and the full unit suite (86 tests) pass on both Bun and Node.
The v2 redesign squash (8eabd5c) deleted appveyor.yml, package-lock.json, and the Actions workflow. AppVeyor's GitHub integration can't be removed, so the MR ran without a config and fell back to stale project settings wired for the old JS/lockfile layout -> failure. Restore appveyor.yml as the Windows CI (Actions covers Linux on Bun + Node). The repo now ships bun.lock, not package-lock.json, so the historical 'npm ci' hard-errors; switch to 'npm install --no-audit --no-fund', matching the Actions unit-node job. Same gate: typecheck, then the Node (tsx) unit runner on Node 22 + 20. Verified locally: typecheck passes; test:node:unit (86 tests) passes; the test/unit/*.test.ts glob expands under node --test itself, so the lack of shell globbing on cmd.exe/PowerShell is a non-issue.
test/tsconfig.json declares "types": ["node", "bun"] but @types/bun was never a dependency (only the bun runtime), so 'npm run typecheck:test' failed with TS2688 'Cannot find type definition file for bun' on a fresh clone, breaking 'npm run check' - the gate CLAUDE.md tells contributors to run. Add @types/bun@^1.3.14 (matches the bun runtime) to devDependencies and regenerate bun.lock. Also run typecheck:test and typecheck:examples in the CI typecheck job so test/ and examples/ are actually type-checked (CI previously ran only 'npm run typecheck' over src, leaving test drift invisible - this is how the missing dep went unnoticed). Verified: 'npm run check' (typecheck src+test+examples, lint:core, check:edge, 86 unit tests) passes.
The v2 redesign squash (8eabd5c) deleted the community/health files that had survived every prior rewrite. Restore them so contributors and the release/update-bot-api skills (which depend on CHANGELOG.md) work again. - CHANGELOG.md: restored verbatim from master (the v1 history through 1.1.0, with the [Unreleased] section + link-def convention intact). - CODE_OF_CONDUCT.md: restored from 0c63283 (Contributor Covenant), the version the maintainer selected; it differs from master's copy.
The release skill described the v1 flow. Update it for the v2 repo: - Lockfile: v2 uses bun.lock, not package-lock.json. bun.lock pins deps only, not the root version, so 'npm version' changes package.json and nothing else -> the release commit is 2 files (CHANGELOG.md, package.json), with no lockfile to regenerate. Documented and verified. - Sanity gate: 'npm run check' (the real gate: typecheck src+test+examples, lint:core, check:edge, unit) + 'npm run build'; fix the stale test:bun:unit and 'prepare runs on publish' (it's prepublishOnly now). - Remove the 'Regenerate docs' step: generate:docs / doc/api.md / scripts/api-doc.ts were all deleted in the redesign; v2's API surface is the generated type defs, not a doc file. - CI checks: Node 22/24/26 + Bun (AppVeyor covers Windows 20/22), not the v1 20/22/24/26 matrix. - Example version 1.0.0 -> 2.0.0; add a prerelease-tagging note.
… + auto-serialize)
The update-bot-api skill described the v1 model ('types generated, methods
hand-written'). v2 inverts it: scripts/api-parser.ts now regenerates BOTH
the type surface (src/types/schemas.ts) AND the Api client (src/core/api.ts),
so adding a Bot API version is regenerate + test + CHANGELOG - no method
bodies to write and no per-field serialization to wire.
Rewrite the skill for the actual v2 mechanics (all verified against source):
- Methods are generated onto the Api class as a single params object
(sendMessage(params, signal?)) - no positional args, no Omit<> options
split. Bot holds this.api: Api (does not extend Api), so it never needs
per-method edits; new methods surface on bot.api.* automatically.
- Serialization is automatic and universal via serializeParams (called once
from Api.request): it walks every param, JSON-stringifies objects/arrays,
hoists nested InputFiles to attach://. The v1 _fix* pipeline
(_fixJsonFields/_fixReplyMarkup/...) is gone - a new structured field needs
zero work. Drop that whole step.
- Unit tests are per module (serialize.test.ts / encode.test.ts /
transport.test.ts), not per method; fetch is injected via
new Transport(token, { fetch }), not globalThis monkeypatching.
- E2e is test/e2e/methods.test.ts (not test/integration/), one describe per
method, strict 'call resolving is the assertion' model.
- Drop the docs/coverage step: generate:docs, doc/api.md, scripts/api-doc.ts,
scripts/coverage-audit.mjs were all deleted in the redesign.
- Gate is 'npm run check' (typecheck src+test+examples + lint:core + check:edge
+ unit), not 'npm run typecheck' over src+test.
- Keep the timeless advice: read the RAW changelog, RETURN_OVERRIDES /
mapScalar/mapType extensions, benign-deletion diff check, ASCII-only hook,
probe-before-assert for e2e. Note Context shortcuts (reply,
answerCallbackQuery) are curated - add one only for an ubiquitous per-update
helper.
Restore from master, then fix the instructions that the v2 redesign made stale: - Drop the 'Updating the API Reference' (doc/api.md) section: generate:docs, doc/api.md, and scripts/api-doc.ts were all deleted in the redesign. v2's API surface is the generated type defs, not a doc build. - 'Regenerating the types' -> 'Keeping up with Bot API releases': v2 generates BOTH src/types/schemas.ts and src/core/api.ts from one 'npm run generate:types' (bun scripts/api-parser.ts); note the strict generator (hard error on unmapped types, boolean fallback / RETURN_OVERRIDES) and point at the update-bot-api skill. - Rewrite 'Running tests' for v2: the gate is 'npm run check' (typecheck src+test+examples + lint:core + check:edge + unit), not src-only typecheck with 'no separate lint step' (v2 does have lint:core/check:edge). e2e is 'npm run test:e2e' reading NODE_TELEGRAM_TOKEN/TEST_GROUP_ID/ TEST_USER_ID from .env, not the deleted test/README.md / test:node:integration. Point at the run-tests skill for scoping. Verified: every command and file referenced resolves; no stale v1 references remain.
The v2 redesign squash (8eabd5c) deleted both .github templates. Restore from master, then fix every stale v1 instruction: PULL_REQUEST_TEMPLATE.md: - Checklist: 'npm run typecheck clean' + 'doc/api.md up to date (generate:docs)' -> 'npm run check clean' + 'npm run build clean' + generated-files note (schemas.ts AND api.ts come from generate:types; never hand-edit). generate:docs / doc/api.md / scripts/api-doc.ts were all deleted in v2. - Unit section: test:bun:unit -> 'npm test' (Bun) + test:node:unit; name 'npm run check' as the full local gate. - E2E section: test:node:integration / test:bun:integration -> 'npm run test:e2e'. Drop the v1 TEST_STICKER_SET_NAME / TEST_CUSTOM_EMOJI_ID vars (v2 generates synthetic fixtures, not a real public sticker set). Fix the skip list: logOut/close/forum-topic ops are test.skip'd; deleteStickerSet is NOT skipped (it self-reverts). Note the no-skip-if-no-token design. ISSUE_TEMPLATE.md: - Bug-report reading list: deleted doc/usage.md + doc/help.md -> README + redesign/MIGRATION.md (the v1->v2 guide, since many incoming bugs will be v1 users hitting the incompatibility). Verified: every npm script and linked doc resolves; no stale v1 references remain; the skipped-method examples match test/e2e/methods.test.ts.
The type generator now emits a TSDoc comment above every generated Api method
that links to the corresponding Telegram Bot API page, e.g.
/** {@link https://core.telegram.org/bots/api#sendmessage sendMessage} */
sendMessage(params, signal?) { ... }
The anchor is the method name lowercased (Telegram's docs convention). This
makes the source self-documenting and, via TypeDoc, becomes the 'Bot API' link
in the generated API reference (see scripts/generate-docs.ts).
Regenerated src/core/api.ts from the updated generator (180 link comments). The
regen is purely additive (0 deletions): it also reconciles a pre-existing
staleness where the committed api.ts was missing the ADR-002 comment block its
own generator already emits.
Add a doc generator (scripts/generate-docs.ts) and an npm script
'npm run generate:docs'. The pipeline has two stages:
Stage 1: TypeDoc -> doc/api.json (the full serialized project reflection,
booted on src/core/index.ts + src/node/index.ts; private, protected,
and @internal members excluded).
Stage 2: JSON -> doc/api.md (a hand-written renderer: a methods table per
class with params/returns and a per-method Bot API link, param tables
for functions, property tables for interfaces, and a section per type
alias / variable / enum). TypeDoc's ReflectionKind enum is imported so
the kind numbers track the installed version.
Why split: TypeDoc owns type-graph parsing; the script owns presentation. The
JSON stays on disk as a gitignored inspectable intermediate.
- typedoc added as a devDependency.
- doc/api.md (the reference) is checked in; doc/api.json is gitignored.
- Every in-page cross-reference resolves (verified: 1313 links, 0 dangling);
slug() matches GitHub's anchor algorithm (underscores preserved).
- CONTRIBUTING.md: add a 'Generating the API reference' section.
- .claude/skills/generate-docs/SKILL.md: rewritten for the new pipeline.
Verified: idempotent (re-run leaves no diff); npm run check (86 tests) and
npm run build both pass.
Adversarial review of scripts/generate-docs.ts found two real bugs and two
latent renderers that were wrong but unexercised.
Bug 1 (accessors render as 'void'): TypeDoc 0.28 stores an accessor's
.getSignature as a SINGLE signature object, not an array, and the accessor
has no own .type. emitClass read getSignature[0] (undefined) -> collapsed to
undefined -> 'void'. All 11 Context accessors were wrong in the doc:
callbackQuery/channelPost/chat/etc. showed 'void' instead of their real
'T | undefined'. Guard both shapes (single object + older array) and route
the result through escCell (union types carry a raw '|' that would split the
table cell).
Bug 2 (locale-dependent sort): byName used localeCompare, whose order varies
by runtime/locale (verified en-US puts 'apfel' first, sv-SE last). For a
committed generated file regenerated in CI incl. the AppVeyor Windows runner
(uncontrolled locale), two machines could emit declarations in different
orders -> spurious doc diffs and a flaky 'docs up to date' check. Switch to a
codepoint comparator for deterministic, environment-independent ordering.
Latent renderers (0 occurrences today, now correct + tested):
- mapped: was a hardcoded '{ [K in keyof T]: ... }' placeholder ignoring the
constraint/modifier/template. TypeDoc shape is { parameter, parameterType,
templateType, readonlyModifier?, optionalModifier?, nameType? }; render it
faithfully.
- template-literal: guessed the tail shape. TypeDoc shape is
{ head: string, tail: [[type, sep], ...] } (confirmed via the deserializer);
render substitutions + separators correctly.
Also: annotate the 4 .filter((m) => ...) callbacks (implicit any, surfaced now
that typecheck:test covers scripts/ via the new test import).
Add test/unit/generate-docs.test.ts covering mapped/template-literal/conditional
rendering, slug underscore-preservation, and codepoint sort order (6 tests,
pass under both Bun and Node). Export __test from the script for the tests.
Regenerated doc/api.md: 11 accessor rows corrected, plus locale->codepoint
reordering. All 1321 in-page links still resolve; idempotent.
…r prose
The 'Bot API' column only made sense for the Api class (it held the telegram.org
link). For every other class (Bot, Context, builders) it was always '-', and
worse: their hand-written prose descriptions (Bot.command, Context.reply, ...)
were silently dropped.
Switch the column to render the full comment summary via renderSummary:
- Api methods: the {@link} renders to the official Bot API link (unchanged
output, just under a 'Description' header). All 180 external links preserved.
- Library methods: now show their actual prose ('Match a message starting with
/name ...', 'Send a message to the inferred chat ...', etc.) instead of '-'.
Remove the now-dead telegramLink() helper and its BASE_URL constant.
Verified: 1321 in-page links resolve, 180 telegram links preserved, idempotent,
npm run check (92 tests) + build pass.
- webhookCallback now requires `secretToken` at setup unless the caller
passes `allowUnauthenticated: true` (auth enforced at another layer), so
an unauthenticated endpoint is never created by accident. A provided
secret is validated against Telegram's format (1-256 of [A-Za-z0-9_-]),
failing fast instead of 401-ing every real update.
- nodeFrameworkWebhook now prefers a pre-parsed `req.body` (object / string
/ Buffer) and only drains the raw stream when absent, so the Express
adapter works whether or not a body parser (e.g. express.json()) ran
upstream and consumed the stream.
- Docs: README upload examples carry an explicit InputFile `filename`
(the core does no content sniffing) plus a short note.
- Tests: new test/unit/adapters.test.ts covers the body-source branches;
webhook tests cover require/opt-out/format validation.'
The Windows job runs node-only (tsc + the tsx unit runner) and never invokes bun, but `bun` is a devDependency whose npm postinstall (`node install.js`) downloads a platform binary and hard-fails on the AppVeyor VM (Failed to find package @oven/bun-windows-x64-baseline), aborting the whole `npm install` and the build with it. Add `--ignore-scripts` to the install step. tsc is pure JS and tsx/esbuild still work because esbuild resolves its native binary from an optionalDependency package (@esbuild/win32-x64), which is installed regardless of lifecycle scripts -> only the failing bun postinstall is skipped. Also drop a stray non-ASCII em dash from the file comment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… Windows) `test:node:unit` was `node --test --import tsx test/unit/*.test.ts`. On Windows the shell does not expand the glob, so it relied on Node's own `--test` globbing - which only exists in Node 21+. Node 22 passed, but Node 20 received the literal `test/unit/*.test.ts`, matched nothing, and failed with "Could not find ...*.test.ts". Add scripts/run-node-unit.mjs: it expands the glob itself (readdirSync) and hands `node --test --import tsx` explicit file paths, which works on every supported Node and OS. Keeps Node 20 in the AppVeyor matrix; no new dependencies. The bun runner (`npm test`) is unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Revert the run-node-unit.mjs launcher: instead of expanding the glob in JS to support Node 20 on Windows, drop Node 20 from the AppVeyor matrix (now 22 + 24, matching the Actions 22/24/26 set) and run the test glob directly again. Every version in the matrix is now >=21, where Node's own `--test` globbing expands `test/unit/*.test.ts`, so no launcher is needed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build 294's Node 24 job failed at Install-Product with "Node.js is only supported on Windows 10, Windows Server 2016, or higher" - AppVeyor's default image is Windows Server 2012 R2, which Node 24 dropped. Node 22 still runs there, which is why only the 24 job broke. Pin the Visual Studio 2022 image (Server 2022) so both matrix versions install. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The typecheck job ran tsc x3 but not `lint:core` (src/core must not touch node:* or Node globals) or `check:edge` (src/core must bundle with no Node builtin reachable) - the two invariants the v2 redesign exists to uphold. They only ran in the local `npm run check` gate, so CI stayed green even if core regressed. Add both as steps to the typecheck job, which already has bun + node and installed deps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror the v1 zshy setup (now an ancestor after rebasing feat/v2-core onto master) for v2's three subpath exports (., ./node, ./types): - build via `zshy --project tsconfig.build.json`; it emits ESM (*.js/*.d.ts) and CJS (*.cjs/*.d.cts) for every entrypoint. `noEdit: true` keeps the hand-written `exports` map (condition-specific `types`) authoritative. - the `exports` map gains a `require` condition per subpath; add `module` and point `main` at the CJS entry. - wire the `postbuild` source-map repair (scripts/fix-cjs-sourcemaps.mjs, carried over from master). It is REQUIRED: stock zshy 0.7.3 mislabels every CJS artifact's source-map reference - the *.cjs / *.d.cts point at the ESM *.js.map / *.d.ts.map, and the companion maps carry the ESM `file` field - so the postbuild rewrites all of them (104 refs on a clean build) to point at their own .cjs.map / .d.cts.map. It is idempotent (re-running patches 0). src/core stays Node-free (lint:core + check:edge still pass), so the edge/runtime-agnostic story is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adding the zshy dual-module build means v2 now ships a CommonJS entry, but several docs still claimed "ESM-only, no CommonJS build" and told CJS users to fall back to a dynamic import(). Correct them: - MIGRATION.md: the v1->v2 table and the "Runtime & module format" section now state the module system is not a migration blocker - `require()` works - and the dynamic-import() workaround is dropped. The real break is the API surface. - ARCHITECTURE.md: breaking-change item 10 reworded from "ESM-only / no CommonJS build" to "web-standard core, dual ESM+CJS package" (core stays Node-free). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Fold the current-state architecture from redesign/ARCHITECTURE.md into CLAUDE.md (present tense; drop the ADRs and explicit breaking-changes list). - Move the v1->v2 migration cheatsheet from redesign/MIGRATION.md into the CHANGELOG [Unreleased] section; remove the redesign/ folder. - Restore the 1.1.1 and 1.1.2 release sections a stale restore had dropped, and fix the [Unreleased] compare link (v1.1.2...master). - Repoint redesign/ references in README, package.json, and the issue template; update CONTRIBUTING's build section to the zshy dual ESM+CJS build. - Correct the 'full e2e suite logs the bot out' claim (logOut/close are test.skip-ed) in CLAUDE.md, CONTRIBUTING.md, and the e2e file header. - Rewrite the run-tests skill for v2: test/e2e paths, commands, bun:test vs node:test, v2 error/transport names, core internals, in-suite coverage guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
longPoll previously rethrew a surfaced 429, killing the poll loop on a flood. Now a 429 is classified transient (isTransientError) and the loop waits the error's retry_after - or retryDelayMs (default 1s) when none is given - and re-polls WITHOUT advancing the offset. The outer exponential backoff is gone: the transport already owns the bounded per-request exponential retry, so the loop only needs a light wait (this also removes the two layers' compounding). Supporting changes: - LongPollOptions: drop maxBackoffMs, add retryDelayMs. - Extract the jittered backoff formula into delay.ts (transport is now its only consumer); rename longpoll's poll-timeout const to DEFAULT_POLL_TIMEOUT. - Extract HTTP_STATUS_TOO_MANY_REQUESTS (429) in errors.ts, used by errors + transport (mirrors node:http2's constant; src/core stays Node-free, so it cannot import it). - Tidy longpoll comments: concise function JSDoc, leading body comments. - Tests: longPoll resumes after a 429; isTransientError coverage. Regenerated doc/api.md for the new constant + option rename. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run `bun run format` (biome): import sort and minor formatting in context.ts, longpoll.ts, webhook.ts. Plus two non-cosmetic lint fixes so the run exits clean: - transport.ts: combineSignals cleanup uses `for...of` instead of a forEach callback that implicitly returned a value (useIterableCallbackReturn). - debug.ts: `active?.enabled(...)` optional chain (useOptionalChain). Remaining biome warnings (the Express-adapter `any`, keyboard `!` assertions, a useTemplate suggestion) are intentional and left as-is; biome is not part of the `npm run check` gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Draft WPI
redesign/ARCHITECTURE.md.redesign/MIGRATION.md.README.md.