Files
Playa/docs/superpowers/specs/2026-09-26-telegram-message-context-design.md

24 KiB
Raw Blame History

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.Quote and Message.ExternalReply are 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): covers ReplyToMessage, Quote and ExternalReply. 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) and string Truncate(string text, int max = 500). The truncation counts Runes, so a surrogate pair is never split.
  • string TimestampHeader(DateTimeOffset at, TimeZoneInfo zone): [Sat 2026-09-26 14:03 Europe/Berlin], formatted with CultureInfo.InvariantCulture. Shared with TelegramJobDelivery (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 DownloadPhotoAsync logic. A download failure still goes through chat.Report.
  • Step 2 (sibling spec): Document, Video and Audio saved to the workspace inbox ([File received: …]), image documents also attached as images.
  • Step 3 (sibling spec): Voice/Audio transcription ([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):

  1. Begin appends an open slot and cancels the timer.
  2. Complete/Dispose fills the slot. If no slot is open and at least one holds a part, the timer is armed for window. If every slot is empty, the slots are cleared.
  3. When the timer fires, the filled slots are taken in slot order and passed to onFlush. A new Begin during onFlush starts the next batch.
  4. 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 later Complete is treated as a new Begin + Complete at the end.
  5. Discard bumps a generation counter and clears the slots. Leases from an older generation complete into nothing.
  6. DisposeAsync (from ChatContext.DisposeAsync) discards and logs how many parts were dropped. The optional logger also records a hung lease being detached and a failing onFlush, 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

  • ChatContext gets an Inbox property. ChatRegistry.CreateAsync builds the inbox with options.InputDebounce, a TimeProvider (an optional last ChatRegistry constructor parameter, default TimeProvider.System; Program.cs registers TimeProvider.System with TryAddSingleton), and an onFlush that:
    1. renders the parts to (text, images), with the timestamp header in the chat's current zone (ChatRegistry.TimeZoneFor(chatId), which returns ChatSettingsStore.TimeZoneFor(chatId); read at flush time, so a /timezone change applies to the next batch). An image-only batch still gets the header line as its text;
    2. if session.IsStreaming, calls sender.ReactAsync(chatId, id, "πŸ‘€") for each part's message id, fire-and-forget;
    3. calls chat.PromptInBackground(text, images).
  • 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: sets InputDebounce = 20 ms (real time, like EditInterval). SettleAsync first awaits chat.Inbox.DrainAsync(), then does what it does today, so existing tests keep their shape. An optional TimeProvider parameter is added for the timing tests.
  • FakeBotClient records SetMessageReactionRequest already (default branch). Nothing to add apart from a Reactions convenience 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;
  • TimestampHeader for a fixed instant in Europe/Berlin and Asia/Tokyo (the day name and date follow the zone, e.g. 2026-09-26T23:30Z is Sun 2026-09-27 08:30 Asia/Tokyo), invariant culture under a non-English CurrentCulture;
  • 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;
  • Begin cancels an armed timer;
  • Complete while 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);
  • Discard with an open lease: the late completion is dropped;
  • double Dispose is a no-op;
  • maxHold detaches a hung lease, and its late part flushes as its own batch;
  • a throwing onFlush doesn't stop the next batch, and DrainAsync still 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/Tokyo the 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 FauxTurn tool that waits on a gate) gets a πŸ‘€ SetMessageReactionRequest and appears as a steering message. A message sent while idle gets no reaction;
  • /new, /stop and /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 TelegramUi waits for input goes to the agent, not to the question;
  • /stop routed while a photo download is blocked (an OnSend gate on GetFileRequest) is handled right away, which proves the poll loop isn't blocked.

BotSetupServiceTests:

  • after getMe fails once and then succeeds, exactly one 🟒 Online. goes to AllowedUserIds[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_parameters on 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).