24 KiB
Telegram message context, grouping and presence β design
Date: 2026-09-26
Status: implemented
Depends on: 2026-09-26-telegram-attachments-design.md, which plugs its content steps (files, speech) into
InboundComposer and uses the renderer's RunInOrderAsync for notices and the π€ echo.
Also 2026-09-26-scheduler-design.md Β§7 (ChatSettingsStore, the chat's time zone for the timestamp header; the
header's zone comes from ChatRegistry.TimeZoneFor, which returns ChatSettingsStore.TimeZoneFor(chatId)).
Problem
UpdateRouter.OnMessageAsync turns every Telegram message into its own prompt with only Text ?? Caption and an
optional photo. That drops context the user already gave:
- Replies. When the user replies to one of the bot's older answers (or to their own message), or highlights part of
it ("reply with quote"), the agent does not see which message "this" means.
Message.ReplyToMessage,Message.QuoteandMessage.ExternalReplyare ignored. - Forwards. A forwarded post arrives as bare text. The agent doesn't know it was written by someone else, or who.
- Split turns. Telegram delivers "forward with a comment" as two messages (the comment first, then the forward
a few ms later), and delivers an album as one message per item. Each one currently becomes its own prompt. The first
starts a run and the rest steer it, so the agent answers the comment before it has seen what the comment is about.
Voice transcription (the sibling spec
2026-09-26-telegram-attachments-design.md) makes this worse: a transcript finishes seconds after the comment. - Time of day. The system prompt carries only the date in the chat's zone (scheduler spec Β§8: a clock time there would break the prompt cache). The agent can't tell whether "this evening" was said at 9 in the morning or at 11 at night, or that two messages are hours apart.
- Feedback. A message sent during a run steers it silently, so the user can't tell it arrived. After a restart there is no sign that the bot is back.
The TypeScript reference (VolcanicSecretary, src/telegram/inbound.ts, debouncer.ts, bridge.ts, and its specs
2026-09-22-reply-context-design.md and 2026-09-23-audio-comment-debounce-race-design.md) solves all of these. This
spec ports that behaviour. Header wording is kept identical so both bots read the same to the model.
Decisions
| Topic | Decision |
|---|---|
| Header format | One English line per header in square brackets, before the body: reply header first, then forward header, then the message text. Same wording as the reference. The batch's local timestamp line (next row) comes before all of them. |
| Local timestamp | Every prompt the host sends to the agent starts with one timestamp line in the chat's zone (except a /command the bot doesn't handle itself, which goes to the session as typed; see What bypasses the inbox): [Sat 2026-09-26 14:03 Europe/Berlin] (invariant culture, 24 h, IANA id). One per flushed batch, not per part, before the first part's reply/forward headers. The time is the first part's Message.Date (when the user sent it, not when the batch flushed or a transcript finished), converted to the zone. It's cache-safe: it's in the appended user message, not in the system prompt prefix. Steering messages get it too, since they're prompts. |
| Where the timestamp is built | Host-side (Telegram), not in the core. The core doesn't know when a user message was sent, and a headless caller may not want a header; the zone it uses is the same one the core session carries (ChatSettingsStore.TimeZoneFor(chatId), which also feeds AgentSessionOptions.TimeZone, scheduler spec Β§7βΒ§8), so prompt date, header and bash's TZ always agree. Scheduled prompts and watcher matches get the same header from TelegramJobDelivery (scheduler spec Β§6). |
| Reply content | quote.text if present (reply with quote), else the replied message's text β caption β media descriptor. Capped at 500 Unicode scalars (first 499 + β¦). Quote entities are dropped. |
| Who was replied to | ReplyToMessage.From.Id == bot.BotId β "your message". Any other From β "your own earlier message" (private chats only, so it's the user). No From β "the earlier message". The bot id comes from ITelegramBotClient.BotId (parsed from the token), so no getMe is needed. |
| External reply | Message.ExternalReply (a quote of a message from another chat, which Telegram allows in private chats) β [Reply to a message from <origin>: "β¦"] / [Reply quoting a message from <origin>: "β¦"], with the origin labelled as for forwards. |
| Replied-to media | Described, never re-downloaded. |
| Forward label | [Forwarded from <origin>] for all four MessageOrigin kinds (below). |
| Grouping | A per-chat ChatInbox debouncer. Default window 600 ms after the last event (Telegram:InputDebounce). Everything that lands inside the window becomes one prompt: comment + forward, album items, text + transcript. |
| Plain text is debounced too | The 600 ms window applies to every message that reaches the inbox, plain text included, not only to attachments and forwards. Telegram usually delivers a forward's comment first, as an ordinary text message, so at the moment the comment arrives the host can't know a forward follows. Skipping the window for plain text would send the comment alone and reintroduce the split-turn problem. The cost is ~0.6 s of added latency on every message, which is small next to a model call. |
| Race handling | The reference's pending counter, as leases. A lease is taken synchronously in update order while routing. The window timer is only armed when no lease is open. Parts merge in lease order, which is Telegram order, not completion order. This improves on the reference, which merges in completion order. |
| Hung preparation | A lease open longer than 2 min (ChatInbox constructor default, not user config) stops holding the batch. The batch flushes without it, and the late part becomes a prompt of its own. |
| Slow work off the poll loop | Downloads (and the sibling's transcription) run in a background task per message, holding the lease. RouteAsync returns right after taking the lease, so /stop still gets through. Today the photo download is awaited inline. |
| What bypasses the inbox | Bot commands, callback queries, and plain text that answers a pending TelegramUi input. Unchanged rules, checked before the inbox, and only for messages with no attachment that weren't forwarded: a message with an attachment is never a command or a pending answer, and its caption is prompt text (attachments spec); a forwarded message is content, so a forwarded /new β¦ goes to the agent with its [Forwarded from β¦] label. /new, /stop and a /resume that names exactly one conversation discard the buffer first, before any await, so a buffered message can't flush into the old conversation (open leases still finish, but their parts are dropped). A /resume whose switch an extension cancels has already dropped the buffer. A /command the bot doesn't handle itself goes to the session directly, without headers or the window, so extension commands (/claude-code-doctor, /claude-code-login) still parse. AgentSession.PromptAsync only recognises a command at the start of the text. Such a command doesn't wait for the batch in the inbox, so it can overtake messages sent just before it; that is accepted, since extension commands don't depend on them. |
| Delivery | A flush calls ChatContext.PromptInBackground(text, images), which is unchanged. While a run is active the merged prompt steers it (StreamingBehavior.Steer), as today. |
| π reaction | At flush time, if session.IsStreaming, every message in the batch gets a π reaction (setMessageReaction), best-effort. An album has at most 10 items. It is not sent when the batch starts a new run. |
| π’ Online | Once per process, after BotSetupService's first successful getMe + setMyCommands, it sends π’ Online. to the owner (AllowedUserIds[0]). Best-effort: a failure (for example 403 because the owner never started the bot) is logged and not retried. |
Header wording
| Situation | Header |
|---|---|
| Reply to the bot | [Reply to your message: "<text>"] |
| Reply to the user's own message | [Reply to your own earlier message: "<text>"] |
Reply, From missing |
[Reply to the earlier message: "<text>"] |
| Reply with quote | [Reply quoting your message: "<quote>"] (same "who" variants) |
| External reply | [Reply to a message from <origin>: "<quote or descriptor>"], [Reply quoting a message from <origin>: "<quote>"] |
| Forward | [Forwarded from <origin>] |
| Local time (once per batch, first line) | [<ddd> <yyyy-MM-dd> <HH:mm> <IANA zone>], e.g. [Sat 2026-09-26 14:03 Europe/Berlin] |
Media descriptors, first match wins: (photo), (video), (voice message), (video message) (video note),
(audio: <file name>) / (audio), (document: <file name>) / (document), (sticker <emoji>), otherwise
(message). A caption always wins over the descriptor.
Origin labels (Telegram.Bot.Types.MessageOrigin*):
| Kind | Label |
|---|---|
MessageOriginUser |
@username, else First Last, else unknown user |
MessageOriginHiddenUser |
SenderUserName, else hidden user |
MessageOriginChat |
SenderChat.Title, else @username, else chat; plus (<AuthorSignature>) when set |
MessageOriginChannel |
Chat.Title, else @username, else channel; plus (<AuthorSignature>) when set |
The author signature is an addition to the reference, which drops it. It costs nothing and tells the model which author in a channel wrote the post.
Example: a forwarded channel post, sent as a reply to the bot, with the user's comment sent alongside:
[Sat 2026-09-26 14:03 Europe/Berlin]
look at this, is it the plant you mentioned?
[Reply to your message: "Which plant do you mean?"]
[Forwarded from Daily News (Anna K.)]
Geothermal plant opens in β¦
Components
All new code is in hosts/Volcanic.AgenticCore.Telegram/Inbound/. The core and the Abstractions are unchanged.
MessageContext (static, pure)
string? ReplyHeader(Message message, long botId): coversReplyToMessage,QuoteandExternalReply. Returns null when there's nothing to reply to. It can't throw: an unknown shape falls back to(message).string ForwardLabel(MessageOrigin origin).string Describe(Message replied)andstring Truncate(string text, int max = 500). The truncation countsRunes, so a surrogate pair is never split.string TimestampHeader(DateTimeOffset at, TimeZoneInfo zone):[Sat 2026-09-26 14:03 Europe/Berlin], formatted withCultureInfo.InvariantCulture. Shared withTelegramJobDelivery(scheduler spec), so both read the same.
InboundPart (record)
public sealed record InboundPart(
int MessageId,
DateTimeOffset SentAt, // Message.Date, for the batch's timestamp header
string? MediaGroupId,
string? ReplyHeader, // from MessageContext
string? ForwardLabel, // from MessageContext, without the brackets
IReadOnlyList<string> Body, // text/caption, then lines added by content steps ("[File received: β¦]", "[Voice message] β¦")
IReadOnlyList<ImageContent> Images);
Parts stay structured until the flush, so the merge can collapse album headers. Consecutive parts with the same
MediaGroupId render the reply header and forward label only once (from the first part that has them). An album of
five forwarded photos gets one [Forwarded from β¦], not five. The rendered batch starts with one
TimestampHeader(parts[0].SentAt, zone) line; parts are joined with a blank line. Inside a part,
the order is header lines, then the body lines joined by a blank line. A part with images but no text lines stays
image-only, and UserMessage accepts empty text with images.
InboundComposer
Task<InboundPart?> ComposeAsync(ChatContext chat, Message message, CancellationToken): builds the headers, then
runs the content steps in a fixed order. Each step can add body lines and images, send a user-facing notice, or
refuse the message.
- Step 1 (this spec): text/caption, and the largest photo, using today's
DownloadPhotoAsynclogic. A download failure still goes throughchat.Report. - Step 2 (sibling spec):
Document,VideoandAudiosaved to the workspace inbox ([File received: β¦]), image documents also attached as images. - Step 3 (sibling spec):
Voice/Audiotranscription ([Voice message] β¦), including itsπ€echo.
Notices and the echo are sent through the renderer (ChatRenderer.RunInOrderAsync, added by the sibling spec), so
they stay in order with rendered output. ComposeAsync returns null when the message has nothing usable or a step
refused it (with its own notice); the lease then completes empty and nothing else is sent. Headers are attached only
if the part has a body or images, as in the reference.
Unsupported kinds never reach the composer: the router checks the message kind before taking a lease. Anything other
than text, photo, document, video, audio or voice gets I can read text, photos, files and voice messages. (the
sibling spec's text, InboundComposer.UnsupportedReply), and takes no lease.
The sibling spec relies on this contract: any slow work for a message happens inside ComposeAsync, while the
router holds that message's lease. A preparer never talks to ChatInbox itself.
ChatInbox (per chat, the debouncer)
public sealed class ChatInbox(Func<IReadOnlyList<InboundPart>, Task> onFlush, TimeSpan window,
TimeProvider time, TimeSpan? maxHold = null, ILogger? logger = null) : IAsyncDisposable
{
public InboundLease Begin(int messageId); // reserves the next slot; cancels an armed timer
public void Discard(); // /new, /stop, /resume: drop buffered and in-flight parts (generation bump)
internal Task DrainAsync(); // test helper: completes when nothing is buffered, no lease is open and the last flush was handed over
}
public sealed class InboundLease : IDisposable
{
public void Complete(InboundPart? part); // fills the slot (null = the message produced nothing)
public void Dispose(); // Complete(null) if not completed; always safe to call twice
}
Rules (one lock, timer from TimeProvider.CreateTimer):
Beginappends an open slot and cancels the timer.Complete/Disposefills the slot. If no slot is open and at least one holds a part, the timer is armed forwindow. If every slot is empty, the slots are cleared.- When the timer fires, the filled slots are taken in slot order and passed to
onFlush. A newBeginduringonFlushstarts the next batch. - When a slot has been open for
maxHold(default 2 min, tracked by a second timer), it is detached. The batch is handled as if that slot were empty. Its laterCompleteis treated as a newBegin+Completeat the end. Discardbumps a generation counter and clears the slots. Leases from an older generation complete into nothing.DisposeAsync(fromChatContext.DisposeAsync) discards and logs how many parts were dropped. The optional logger also records a hung lease being detached and a failingonFlush, which is caught so it can't stop later batches.
Race walk-through (comment, then a forwarded voice that takes 3 s to transcribe):
t=0.000 comment routed β Begin#1 (sync) β compose (fast) β Complete#1 open=0 β timer armed (t+0.6)
t=0.050 voice routed β Begin#2 (sync), timer cancelled β compose awaits transcriptionβ¦
t=3.050 transcript done β Complete#2 open=0 β timer armed (t+0.6)
t=3.650 flush [#1 comment, #2 forward+transcript] β one prompt
If the voice had arrived first, it would still come first in the prompt, because leases are taken in routing order.
Polling calls RouteAsync sequentially, so routing order is Telegram order.
UpdateRouter changes
OnMessageAsync keeps the allow-list, private-chat check, pending-input answer and command handling as they are
(the last two only for messages with no attachment). Then it rejects unsupported kinds (above), and instead of
downloading and prompting inline:
var lease = chat.Inbox.Begin(message.Id);
_ = Task.Run(async () =>
{
using (lease)
{
try { lease.Complete(await composer.ComposeAsync(chat, message, ct)); }
catch (Exception ex) when (ex is not OperationCanceledException) { log; chat.Report("I could not read that message."); }
}
});
The pending-input rule adds one condition: a message with ForwardOrigin never answers a pending question. A
forwarded message is content, not an answer, and never a command either. ChatCommands calls chat.Inbox.Discard()
on /new, /stop and a /resume that names exactly one conversation, before its first await.
ChatContext / ChatRegistry changes
ChatContextgets anInboxproperty.ChatRegistry.CreateAsyncbuilds the inbox withoptions.InputDebounce, aTimeProvider(an optional lastChatRegistryconstructor parameter, defaultTimeProvider.System;Program.csregistersTimeProvider.SystemwithTryAddSingleton), and anonFlushthat:- renders the parts to
(text, images), with the timestamp header in the chat's current zone (ChatRegistry.TimeZoneFor(chatId), which returnsChatSettingsStore.TimeZoneFor(chatId); read at flush time, so a/timezonechange applies to the next batch). An image-only batch still gets the header line as its text; - if
session.IsStreaming, callssender.ReactAsync(chatId, id, "π")for each part's message id, fire-and-forget; - calls
chat.PromptInBackground(text, images).
- renders the parts to
- The check in step 2 and the steer in step 3 can race with the end of a run. In that case the message starts a new run and still shows π. That's harmless and accepted.
TelegramSender.ReactAsync
Task ReactAsync(long chatId, int messageId, string emoji, CancellationToken) wraps
bot.SetMessageReaction(chatId, messageId, [new ReactionTypeEmoji { Emoji = emoji }]). Every failure is logged at
Debug and swallowed. A reaction is never worth an error in the chat.
BotSetupService changes
After the first successful setup, before return, it calls
sender.SendTextAsync(options.AllowedUserIds[0], "π’ Online.", ct). SendTextAsync already logs failures and
returns null, and this call is outside the retry loop, so a 403 doesn't turn into a retry loop. It does not create
the owner's ChatContext or session. BotSetupService gains a TelegramSender dependency.
TelegramOptions
InputDebounce (TimeSpan, default 00:00:00.6). It must be >= 0 (validated); zero flushes on the next timer
tick. Add it to the configuration table in docs/telegram-bot.md.
Docs
docs/telegram-bot.md, "Using it":
- replies and quotes are passed to the agent;
- forwards are labelled;
- messages sent within ~0.6 s of each other (a forward with a comment, an album) are one request, and every message waits that long before it's sent to the agent;
- each request carries the local time of the user's message, in the chat's zone;
- π means the message was added to the running request;
- the bot says
π’ Online.to the owner after a start.
The /help text in ChatCommands gets one line about replies.
Testing
xunit, in tests/Volcanic.AgenticCore.Telegram.Tests. Uses FakeTimeProvider from
Microsoft.Extensions.TimeProvider.Testing, added to Volcanic.AgenticCore.Telegram.Tests and
Volcanic.AgenticCore.Extensions.Tests by whichever of the sibling specs lands first (the scheduler and calendar
specs use it too).
Support changes
Updates:Reply(text, replyTo: Message),Quote(text, replyTo, quote),ExternalReply(text, origin, quote),Forward(text, MessageOrigin),AlbumPhoto(groupId, caption?). All take an optional explicit message id.ChatHost: setsInputDebounce = 20 ms(real time, likeEditInterval).SettleAsyncfirst awaitschat.Inbox.DrainAsync(), then does what it does today, so existing tests keep their shape. An optionalTimeProviderparameter is added for the timing tests.FakeBotClientrecordsSetMessageReactionRequestalready (default branch). Nothing to add apart from aReactionsconvenience view.
MessageContextTests (pure, no host). Follows the reference's 13 cases:
- reply to the bot / to self / with no
From; - quote to the bot and to self;
- photo-only, voice-only, named document, sticker;
- caption beats the descriptor;
- 600-char text truncated to 499 +
β¦, with no split surrogate pair at the cut; - reply + forward ordering;
TimestampHeaderfor a fixed instant inEurope/BerlinandAsia/Tokyo(the day name and date follow the zone, e.g. 2026-09-26T23:30Z isSun 2026-09-27 08:30 Asia/Tokyo), invariant culture under a non-EnglishCurrentCulture;- empty reply β
(message).
Also: external reply with and without a quote, each MessageOrigin kind including the fallbacks and the author
signature, and a bot id taken from BotId when From.IsBot is false.
ChatInboxTests (FakeTimeProvider, recording onFlush). Mirrors debouncer.test.ts:
- one part flushes after the window, not before;
- two parts inside the window make one flush, and the timer resets on each;
Begincancels an armed timer;Completewhile another lease is open does not arm;- two leases need two completions;
- begin/complete with a null part never flushes;
- the comment-then-slow-voice race gives one flush;
- the voice-then-comment race keeps slot order (new);
Discardwith an open lease: the late completion is dropped;- double
Disposeis a no-op; maxHolddetaches a hung lease, and its late part flushes as its own batch;- a throwing
onFlushdoesn't stop the next batch, andDrainAsyncstill completes; - album header collapsing in the renderer.
MessageContextChatTests (through ChatHost + FauxProvider, asserting on Faux.Requests[..].Messages.Last()):
- a reply to a bot message reaches the model with the header;
- the first line of every user message the model sees is the timestamp header in the chat's zone; forward + comment
get one header, not two; after
/timezone Asia/Tokyothe next message's header says Tokyo; - plain text alone waits for the window: a text followed within it by a forward is one request;
- forward + comment sent back to back produce one model request containing both, in order;
- a three-photo album with one caption is one request with three images and one forward label;
- a message sent during a scripted long run (a
FauxTurntool that waits on a gate) gets a πSetMessageReactionRequestand appears as a steering message. A message sent while idle gets no reaction; /new,/stopand/resume <id>during the window drop the buffered text;- a forwarded
/new β¦reaches the model with its forward label and starts no new conversation; - a forwarded text while
TelegramUiwaits for input goes to the agent, not to the question; /stoprouted while a photo download is blocked (anOnSendgate onGetFileRequest) is handled right away, which proves the poll loop isn't blocked.
BotSetupServiceTests:
- after
getMefails once and then succeeds, exactly oneπ’ Online.goes toAllowedUserIds[0]; - a 403 on that send doesn't stop the service and isn't retried.
Out of scope
- Voice, audio, video and documents content. The sibling attachments spec adds them as content steps 2 and 3 under the lease contract above.
- Re-downloading media from the replied-to message. Rewinding the session to the replied-to turn.
- Quote entities (formatting), and
reply_parameterson outbound messages. - Group chats,
IsAutomaticForward, and stories/polls/locations beyond the(message)descriptor. - A per-media-group longer window. Telegram delivers album items back to back, well inside 600 ms.
- An offline/shutdown message, and a status block in the online message (the reference adds
formatStatus; here that would force the owner's session to be created at startup).