Streaming text
app.stream shows text while it is being generated, an AI model's answer for example, and sends it as a normal message when it is done:
bot.on('message:text', (ctx) => {
void app
.stream(ctx, (signal) => model.stream(ctx.message.text, { signal }), {
stoppable: true,
finish: (text, { stopped }) => ({ text: stopped ? `${text}\n\n_⏹ Stopped_` : text }),
})
.catch((error) => console.error('stream failed', error));
});- Private chats: the preview is a Telegram draft (Bot API 10,
sendMessageDraft): "Thinking…" until the first words arrive, then the text growing in an animated bubble. Drafts are previews only; they disappear when the final message is sent. - Groups: drafts are for private chats, so a "…" message is sent and edited as the text grows. The final text goes into that same message.
- The final message is
finish(text, { stopped }), a page's content: formatting, a keyboard, media. Withoutfinishit is the text, trimmed, as Markdown. Long text is split like any page. Previews are plain text, since half a Markdown answer (**bold) isn't valid formatting yet; withrichthey are formatted. intervalMsis the least time between previews: 300 ms for drafts, 1000 ms for edits in groups (Telegram limits edits per chat).
app.stream resolves with { text, stopped, result }, result being the final message as app.open returns it. An error from the source rejects it; the preview is left as it was.
The source
Anything that yields text chunks: an AsyncIterable<string>, or a function taking an AbortSignal and returning one. Pass the signal on to your model's API so that stopping cancels the request too:
// Anthropic's SDK
const source: StreamSource = async function* (signal) {
const stream = anthropic.messages.stream({ model, max_tokens: 1024, messages }, { signal });
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') yield event.delta.text;
}
};Rich answers
Models answer in Markdown: headings, lists, tables, code. With rich: true the text is Rich Markdown: previews are rich drafts (sendRichMessageDraft; rich edits in groups), so the table shows as a table while it grows, and the final message is a rich message:
void app.stream(ctx, source, {
rich: true,
stoppable: true,
// An empty line first: "Stopped" must not continue a table cut off mid-way.
finish: (text, { stopped }) => ({ rich: [text.trim(), stopped ? '\n_⏹ Stopped_' : ''] }),
});A preview Telegram refuses (half-written HTML, say) is skipped and the next one shows; previews go up to 32768 characters. Put user input into the answer with escapeRichMarkdown. examples/ai-chat.ts does this.
The stop button
stoppable: true puts a ⏹ Stop button on the draft (private chats). Pressing it sends the bot a stopped_message_generation update; easytg aborts the source's signal, and the text so far becomes the final message (stopped: true).
The stop arrives as an update, so the handler that streams must not hold up the next updates. With bot.start(), grammY handles one update at a time: don't await the stream in the handler (void app.stream(...).catch(...) as above), or the stop waits until the answer is complete. With webhooks or @grammyjs/runner, updates run concurrently and await is fine.
Try it
examples/ai-chat.ts is an AI chat with a fake model, so it runs without an API key. Ask anything, then press ⏹ Stop while it writes:
In the simulator, sim.chat(userId).draft is the draft on screen and sim.stopGeneration(chatId) presses ⏹ Stop.