Changelog
All notable changes are documented here. Add entries under Unreleased; bun run release moves them under the new version (see RELEASING.md).
Unreleased
0.4.0 (2026-10-03)
Added
- Ephemeral messages in groups (Bot API 10.2):
nav.button(…, { mode: 'ephemeral' })andapp.open(…, { mode: 'ephemeral' })open a page or start a dialogue for the presser only, in place of the shared menu on their screen while the others keep seeing it. Its buttons edit that copy, Back and Close work on it, and a dialogue's questions are removed withdeleteEphemeralMessagelike any prompt.app.command(…, { ephemeral: true })lists the command withis_ephemeral, and anything received ephemerally is answered ephemerally (as a reply, which a bot that isn't an admin may send too). See docs/groups.md. - Disabled buttons (Bot API 10):
nav.disabled(text)shows a button that can't be pressed. Calendars disable the month title, weekday names and days out of range (their numbers stay visible instead of·), andnumbersteps the value and an arrow at its limit. - Rich messages (Bot API 10.3): a page returns
richinstead oftext, and gets headings, tables, task lists, collapsible details, formulas, footnotes, pictures and buttons in one message. A string (or an array) is Telegram's Rich Markdown, onehtmlfragment is Rich HTML, and{ markdown | html | blocks }goes as it is.mdfragments (andt.md) escape their values for Rich Markdown too, andescapeRichMarkdownis exported. Rich and text pages edit into each other in place; ephemeral pages,sendTo,app.editand inline results can be rich. See docs/rich-messages.md. app.stream(ctx, source, { finish, stoppable, intervalMs, rich }): text shown while it is generated, as a Telegram draft in private chats ("Thinking…", then the growing text, optionally with ⏹ Stop that aborts the source'sAbortSignal) and as a growing message in groups, then sent as a formatted message. Withrich, the previews are rich drafts and the answer a rich message. See docs/streaming.md.- Time zones for
datesteps:dialogues.timeZoneand a step'stimeZone(a name or a function, e.g. per user) decide where "today" is and which day aDatefalls on. The calendar now works on plain days with UTC arithmetic, so neither the server's time zone nor daylight saving time can shift a day, and years below 100 stay as they are. Amin/max/initialthat isn't a real day,minaftermax, or an unknown time zone are clear errors instead of silently ignored limits. - Dialogue buttons carry a short hash of long step ids, so a calendar fits Telegram's 64 bytes without storing its buttons; buttons with the whole id (sent before an upgrade) keep working.
- Simulator: rich messages (
sendRichMessage,sendRichMessageDraft, rich edits and inline results) parsed into Telegram's blocks and drawn in the chat window, buttons inside them pressable;messageText,richPlainText,richButtons,richHtmlandsim.fileUrlfor tests. - Simulator: ephemeral messages with Telegram's rules (groups only, members only, 15 seconds for a bot that isn't an admin,
createGroup({ botAdmin }));messages(chatId, userId)/last(chatId, userId)are what one user sees, and the chat window shows each user's own view. Drafts (chat.draft,stopGeneration), disabled buttons,send(…, { ephemeral }). - Examples:
team.ts(ephemeral pages, a dialogue and commands,nav.disabled),ai-chat.ts(app.streamwith rich drafts and a stop button) andrich.ts(rich pages in all three forms), all in the playground.
Fixed
app.throttledelivered a burst to one chat out of order (waiting calls raced for each new window); waiting calls are now served first come, first served, and a call cancelled while waiting no longer uses up a slot.- Simulator:
editMessageMediarefused to turn a text message into a media message, which Telegram allows since Bot API 10 (so text → photo pages now edit in place there too, as on Telegram). app.flowchart()recorded one edge per button label, so labels with counters or names grew its memory without limit; it now keeps one edge per pair of screens with up to three labels.- Chat window: in group examples the chat picker was missing until something was sent in the private chat, and switching the typing user could show another user's private chat. The typing user's own private chat is now always listed and switched to.
datesteps in 0.3.2 took "today" andDatelimits from the server's time zone, and could show the wrong month or day when that zone or daylight saving shifted midnight; see the time zone entry above. Update if you use them.
0.3.2 (2026-10-01)
Added
- Dialogue steps
multiChoice(toggle options with ✅, then Done;min,max,initial),number(➖ / ➕ around the value,bigStep,format, or typed) anddate(an inline calendar in the user's language,min/maxas dates, strings or functions, or typed asYYYY-MM-DD). Answers are typed (string[]of the option values,number,string), and forged button values are refused. New built-in textschooseAtLeast,chooseAtMost,expectNumber,expectDateare optional inEasyTGTexts, so existing translations keep compiling. See docs/dialogues.md. - Relays carry replies and edits: a reply to a relayed message replies to its counterpart on the other side, and editing a message edits its copy. Service messages (Mini App data, payments) go on to their handlers instead of being relayed.
- Flow map:
app.flowchart({ direction, observed })draws the bot as a Mermaid diagram (pages, dialogues, commands, menu buttons, and the buttons, redirects and dialogue starts seen while it ran), marking targets that were never registered;mermaidLiveUrl(chart). See docs/flowchart.md. - Simulator:
rateLimitsanswers floods with Telegram's429 Too Many Requests(truefor Telegram's advised limits, or your own rules), to tryapp.throttleandautoRetry. - Preview: a Flow tab with the bot's map, Two chats side by side as two users, a phone layout, and the bot's process stops with the preview even when it is killed.
- CI runs
loadBoton Node.js 20 and 22 (scripts/node-smoke.mjs). - Examples:
booking.ts(calendar, number and multiChoice steps),support.ts(a support desk onapp.relay),flowchart.ts.
Fixed
- A step's
whenran for steps the dialogue hadn't reached yet, before their earlier answers existed (soanswers.x.includes(…)threw). It now runs once the dialogue gets to that step.
0.3.1 (2026-09-30)
Breaking
See docs/migration.md.
verifyInitDataSignatureis async (it uses Web Crypto's Ed25519 now):await verifyInitDataSignature(initData, botId).- Node.js 20 or newer (
engines). easytg uses the Web Crypto global (crypto.randomUUID, Ed25519) instead ofnode:crypto; Node 18 is past its end of life.
Added
- Runs in browsers. No Node-only APIs are left: hashing and signing use pure JavaScript and Web Crypto, and
AsyncLocalStorageis used only where it exists. Bundlers pick the browser build (dist/browser/) through thebrowserexport condition. Callback data, signatures and deep links are byte-for-byte the same as before. easytg/simulator:TelegramSimulator, an in-memory Telegram for grammY bots: chats, groups and users; messages, edits and deletes; media and albums; inline and reply keyboards; toasts and alerts; inline mode; invoices and payments; chat actions; members and admins. It parses HTML and MarkdownV2 into entities and fails with Telegram's errors (can't parse entities,message is not modified,bot was blocked by the user, …). Works withbot.handleUpdateand withbot.start(). See docs/simulator.md.easytg/simulator/element:<easytg-chat>/mountChat(), a Telegram-like chat window for a simulator (light and dark).easytg preview <file>(CLI, needs Bun): your bot file, unchanged, against a simulated Telegram with a chat window in the browser; no token, nothing reaches Telegram. The bot restarts on file changes, the chat stays; the page shows its output and API calls. Export test turns what you did into abun testfile. See docs/preview.md.easytg/simulator/load:loadBot(file)runs a bot file against a simulator in-process (for tests);interceptBotApi(target)sends grammY's calls to a simulator or another Bot API root.- Commands:
app.command(names, pageOrDialogue, { description, chats, params })andapp.syncCommands(bot), which sets Telegram's command menu per scope (all, private, groups) and per language. See docs/commands.md. - Relays:
app.relay.start / end / peerconnect two users who then talk through the bot (messages are copied, accounts stay hidden);relay.filteroption;relayMessageandrelayEndevents. See docs/relay.md. - Serverless and edge:
workerd/workerexport conditions (the browser build), a Cloudflare Worker example with Redis over HTTP and a Cron Trigger, and docs/serverless.md. - Simulator:
sim.tap(label),sim.handleRequest(request)(the Bot API over HTTP, multipart and gzip included),sim.notice(text),sim.waitForPolling(),sim.commandsFor(chat, user)with scoped and translated command lists, file downloads,storeFileoption, "Clear history" and START in the chat window. - Examples:
match.ts, a swipe-style "meet people" bot (profile cards, ❤️ 💌 👎 💤 reactions, likes, matches and anonymous chats through a relay);cloudflare-worker.ts. - Docs site (VitePress,
website/) on GitHub Pages, with a playground on the home page, one for every browser-friendly example, and runnable blocks in the feature docs (```ts playgroundfences, which GitHub shows as plain code).bun run docs:dev/docs:build.
0.3.0 (2026-09-29)
A large release. Breaking changes are listed first; see docs/migration.md for how to update.
Breaking
- All durations are milliseconds.
session.ttlSeconds→session.ttlMs,buttons.ttlSeconds→buttons.ttlMs,deepLinks.ttlSeconds→deepLinks.ttlMs,session.set(k, v, { ttlSeconds })→{ ttlMs }, and a customStorageAdapter'sset(key, value, ttl)now receives milliseconds (may be below one second). retryAfter(error)(seconds) →retryAfterMs(error)(ms);isBlockedByUser→isChatUnreachable.app.limitUser,app.releaseUserandapp.isLimitedreturn promises (limits can live in shared storage).- While a user is limited, all their updates are dropped (not only interactions); payments still pass.
exemptusers are never counted, but manual limits apply to them. The rate-limit window is fixed, starting with the first interaction. sequentialis on by default: one update per user and chat at a time (waits at most 8 s).- The example
examples/storage/sqlite.tsis replaced by the built-inSqliteStorage, which takes an open database:new SqliteStorage(new Database('bot.sqlite'))(same table, existing data stays readable). verifyStorageAdapter(adapter, { keyPrefix, ttlMs })takes options instead of a key prefix.MemoryStoragestores JSON like the real adapters: aDatecomes back as a string, aMapas{}, andset(key, undefined)throws.- Button params:
falseleaves the param out (was the truthy string"false");nav.startDialogueparams are strings like button params;nav.redirect/nav.startDialogueno longer take button options. - Code spans and blocks keep backslashes (only
\\and\`are escapes there):`\d+`shows\d+. - Calling
onText,steps,onFinishoronCanceltwice throws. app.cancelDialoguereportsreason: 'app'(was'command');DialogueCancelReasonadds'app'and'timeout'.app.broadcastsends up to 5 messages at once (concurrency, default 5).dialogue()without a type argument infers the answers from the steps;Dialogue's first type argument defaults toAuto(wasRecord<string, any>).texts.collectReceived/texts.collectLimitreceive the Done label (({ total, done }),({ max, done })); newtexts.adminOnly,texts.openWebApp,texts.expectWebApp.- Docs are one markdown file per feature under
docs/(index:docs/README.md);docs/guide.mdis gone.
Added
- Storage: built-in
SqliteStorage(bun:sqliteorbetter-sqlite3) andRedisStorage(any Redis client, no dependency), imported fromeasytg.StorageAdaptergains optional atomicincrementandsetIfAbsent;verifyStorageAdapterchecks them, andverifyTaskStorechecks task stores. - Several processes:
clustershares rate limits, mutes, double-tap and busy checks, per-user ordering and queues through the storage. - i18n for your messages:
i18n.messages,fallbackLocale,t()/t.md()/t.html()with placeholders and plural forms (in renders, middlewares, dialogues, menus;app.t(ctx)elsewhere).app.setLocale(ctx, locale)for language pickers; the language is remembered forsendTo,sendLaterand broadcasts.pt_BRandpt-brare the same. - Page text input:
page.onText(handler, { mode, deleteInput })for search boxes and the like. - Queues:
queuesoption,app.queue(name, job, { ctx }),app.enterQueue(ctx, name),perUser/maxWaiting/timeoutMs,QueueFullError,QueueTimeoutError,queueWaitevent. - Scheduled tasks saved in storage:
task<P>(id).run(fn),app.schedule(delayMs,at,everyMs,id,botId),app.cancelTask,app.startScheduler(...bots),app.runDueTasks(...bots), retries with backoff,taskErrorevent. Helpers:app.deleteLater(target, ids, { delayMs }),app.sendLater(target, page, { params, delayMs }),deleteAfterMsandrefreshEveryMson page content,app.broadcastLater(targets, page, options)with thebroadcastBatchevent. - Payments:
invoicecontent,nav.pay(),payments.preCheckout/payments.onSuccess,paymentevent. - Inline mode:
app.inlineResult(ctx, page, { params, title })turns pages into inline query results with working buttons. - Validation with schemas:
page.params(schema)and aschemaon text steps accept any Standard Schema (zod, valibot, arktype…). - Dialogue timeouts:
dialogue(id).timeout(ms)anddialogues.timeoutMs. - Sessions:
app.chatSession(ctx)for chat-wide state; typed keys by declaringinterface SessionDataindeclare module 'easytg';session.version+session.migratefor data migrations. - Groups:
requireChatAdmin()middleware. app.answer(ctx, toast)answers a button press early.- User sessions:
app.userSession(ctx)holds state that follows a user across all chats, next to the per-chatsession. User and chat sessions merge per key when saved, so concurrent updates (the same user in two chats, two users in a group) don't lose each other's changes. The language fromsetLocaleis kept per user. - Loading indicators:
page(...).loading({ text, action, toast, afterMs }),dialogue(...).loading(...)for a slowonFinish, aloadingdefault for all pages, andapp.withLoading(ctx, job, options)for handlers: a placeholder the result replaces, a repeated "typing…" action, or a toast. - Typed dialogue answers:
dialogue('x').steps([...])reads the answers type off the steps (ids, choice values, schema outputs; steps withwhenare optional), no generic needed.dialogue<{ … }>()still works;Autotypes only the params. - Conditional steps:
when(helpers)on any step. - Mini Apps:
verifyInitData/verifyInitDataSignature,miniAppLink,replyMenu.webAppwith thewebAppDataevent, awebAppdialogue step,app.answerWebAppQuery,app.prepareShare, andapp.withUserto act for a user outside an update (also for webhooks). - Throttling:
app.throttle(options)API transformer keeps outgoing messages within Telegram's limits (30/s overall, 20/min per group by default, own rules per chat), shared across processes withcluster. autoRetry()API transformer that waits out 429s on every call;isTransient(error).media.cacheFileIds: photos, videos, animations, documents and audio sent by URL are sent by their file id next time (keptmedia.cacheTtlMs, default 30 days).page.params(parse)may be async;withContext()also returnstask.updateevent withdurationMsandoutcome;pageViewhaschatId,userIdanddurationMs; dialogue events haveparams.appin dialogue step helpers andonFinish/onCancel.- Docs: API reference (
docs/app.md), migration guide, inline mode, Mini Apps, groups, scaling, queues, scheduler, payments, text input. - Examples:
search.ts,queue.ts,reminders.ts,payments.ts,production.ts,inline.ts,group.ts,sessions.ts,loading.ts,throttle.ts,mini-app.ts;notify.tsandcourse.tsuse the scheduler;broadcast.tsruns broadcasts in the background.
Fixed
app.editdeleted the target message (and sent nothing) when the page needed several messages, an album, a copy or an invoice.- A keyboard-only render on a long (split) message deleted its earlier parts.
sendTo/edit/broadcastfor a user whose update was being handled overwrote each other's session changes.- Menus with
allowedUsersbecame single-user after the first press. - Pages shown by a dialogue's
onFinish/onCancelor by a middleware redirect weren't recorded (no Back, noonText, nopageView). collectsteps lost their Done button after an error, and a failedvalidateleft the user stuck.nav.close()left the page's text input active.- Re-entering a queue from the same update waited forever.
- A broadcast retrying a 429 resent the parts of a long page that had gone out already.
- MarkdownV2 text could be split between a backslash and the character it escapes; splits could produce messages of only spaces.
- Code fences with languages like
c++,c#orobjective-cweren't recognised. - Scheduler: a task whose completion couldn't be saved ran again at once; one slow task held up all others;
stop()thenstart()ran two poll loops;deleteLaterdidn't retry rate limits and network errors;deleteAfterMsdeleted whatever page the message showed later. - SQLite:
safeIntegersdatabases returned bigint task fields; opening a new database from several processes at once could fail with "database is locked". - Redis:
incrementextended the TTL of existing keys; task keys share a hash tag for Redis Cluster. - Anti-spam: concurrent updates past the limit gave several
spamevents and strikes. - Group menu ownership expired 7 days after sending, even while in use; nav history evicted menus by message id instead of by last use.
easytg/testing: the fake API answeredsendChatActionwith a message instead oftrue.
0.2.1 (2026-09-28)
emitProactiveErrorsoption: errors ofsendToandeditare also emitted aserrorevents (still thrown). Theerrorevent now has asource:update,sendTooredit.- Double-tap, busy and anti-spam state is kept per bot, so one app instance can serve several bots.
limitUser,releaseUserandisLimitedtake an optional bot id (without it they cover every bot). - No "no user" warning for
sendTo/editto a group without auserId. - The Release workflow explains how to fix a refused npm publish.
0.2.0 (2026-09-27)
Breaking: storage keys include the bot id (
session:<bot>:…,cb:<bot>:…,msgowner:<bot>:…) so bots can share a storage. SetscopeKeysByBot: falseto keep the 0.1 format.Breaking: button param names starting with
_are reserved.Per-button delivery:
nav.button(…, { mode: 'send' })keeps the pressed message;buttons.mediaToText: 'keep'keeps media messages.copycontent (copyMessage) andprotectContent(per page or app-wide).app.edit(bot, { chatId, messageId }, page, params)re-renders into an existing message.sentevent with all message ids.prepareProactivehook forsendTo/edit/broadcastcontexts;allowedUserson proactive targets.page(...).params(parse)validates and converts params;InvalidParamsError.buttons.params: 'signed'withbuttons.secret: forge-proof inline params without storage writes.Error helpers
isBlockedByUser,isMessageNotFound,retryAfter.replyMenu.close(): a menu button that removes the menu.keyboardaccepts falsy values;nav.url/nav.webAppreject URLs Telegram doesn't accept.Testing:
createTestBot({ botInfo });copyMessage(s)/forwardMessage(s)answered like Telegram.Fix:
sendTomessages of every media type now go to the requested forum topic.Example
course.ts.Main menu on the reply keyboard:
replyMenu,app.showMenu/app.hideMenu.Dialogue steps
contactandlocation, andchoicewithreply: true, using reply-keyboard buttons.nav.back(): back to the previous page of a menu message, with its params.Media:
video,animation,document,audioandalbumbesidesphoto(ImageSourceis nowMediaSource).app.broadcast: paced sends to many chats, with retries, blocked-user reporting, progress and cancellation.Events
pageView,dialogueStart,dialogueFinish,dialogueCancel.Testing:
t.update(raw),telegramError(description, { code, retryAfter }), albums return one message per item.Examples:
shop.ts,media.ts,broadcast.ts,testing.test.ts.
0.1.0 (2026-09-27)
First release, grown out of the original telegram.ts concept.
- Pages:
page<P>(id).use(...).render(...)with type-checked navigation (nav.button,nav.self,nav.redirect); declared params must be strings. - Middlewares guard page renders and dialogue starts (
targetis the page or dialogue);dialogue(id).use(...). - Options grouped by feature:
session,buttons,deepLinks,dialogues,antiSpam,i18n. - One page, any delivery: send, reply, or edit in place, including text ↔ photo transitions.
- Dialogues:
text,file,choiceandcollectsteps, validation, Back/Cancel, actions,onFinish/onCancel. - Safe formatting:
md/htmltagged templates escape interpolated values; built-in Markdown dialect, no dependencies. - Sessions saved once per update, per-key TTL (
session.set(k, v, { ttlSeconds })), session TTL refreshed by activity; pluggableStorageAdapterper concern,keyPrefix, SQLite example adapter. - Button params inline, auto or stored server-side (
callbackParams), with tamper-proofstoredmode. sendTofor pages without an incoming update (notifications, cron, webhooks).- Deep links:
nav.deepLink/app.deepLink, opt-in per page with.allowDeepLink(). - Per-user language for built-in texts (
locales,locale); the library ships English only, seeexamples/locales/id.tsfor a translation. - Double-tap protection for buttons (
doubleTapMs). - Anti-spam rate limit per user (on by default),
spamevent with strikes,limitUser/releaseUser. - Events:
app.on('spam' | 'error', …);event.silence()skips the default spam warning. - Anti-spam only counts user interactions (
filterto customise); payments and other service updates always pass. - Long texts are split across messages (4096 / 1024 caption limits), tags kept balanced.
- Group menus can only be used by their owner (
restrictToOwner). easytg/testing: fake Telegram API,verifyStorageAdapter.