Skip to content

Telegram: route a reply to the session that sent the replied-to message - #415

Open
serxa wants to merge 6 commits into
mainfrom
serxa/telegram-reply-routing
Open

Telegram: route a reply to the session that sent the replied-to message#415
serxa wants to merge 6 commits into
mainfrom
serxa/telegram-reply-routing

Conversation

@serxa

@serxa serxa commented Aug 27, 2026

Copy link
Copy Markdown
Member

What

Adds opt-in reply routing for Telegram, behind a new setting telegram.reply_routes_to_origin_session (default false).

  • Off (default): unchanged behavior — a reply is context-only: it quotes the replied-to message (and pulls in its attachments) and lands in the chat's active session, exactly like a plain message. This is the existing/intended behavior, so nothing changes for anyone who doesn't opt in.
  • On: a reply routes to the session that produced the replied-to message (and makes it active), so you can answer cron/background sessions, or reply to your own earlier message to jump back into whatever session it went to — without an explicit /session switch.

How

When the flag is off, none of the machinery below runs and inbound replies are delivered to the active session (session_id stays unset). When on:

  • Recording (both directions): a bounded LRU keyed by (chat_id, message_id) records the session for every message the bot sends (TelegramChannel.send + NotificationService._deliver_telegram, the cron case) and for every inbound message's landing session (_delivery_session). The key is (chat_id, message_id) because Telegram message ids are unique only within a chat.
  • Inbound: _resolve_reply() classifies a message three ways — not a reply → active session; reply to a mapped, live session → route there + make active; reply that can't be resolved → reject.
  • The flag is read live per message, so toggling it takes effect on the next message (no restart).

Safety (when on): an unresolvable reply is rejected, never mis-routed

A reply we can't tie to a live session — the target isn't in the map (sent before the daemon started, since the map is in-memory; or evicted from the LRU), its session is archived/gone, or the chat id doesn't match — is not delivered to the active session (that would be a silent mis-route). Instead the bot sends a static, no-LLM nudge ("couldn't find the session… use /sessions and resend") and drops the message without starting a turn.

Behavior choice (when on)

Routing is sticky — a reply also makes the target session active (like /session <id>), so a following plain message continues there.

Config

Setting Type Default Meaning
telegram.reply_routes_to_origin_session bool false Off = reply is context-only, lands in active session. On = reply routes to the replied-to message's session (sticky); unresolvable reply is refused.

Testing

  • tests/test_telegram_reply_routing.py (20 tests): record/lookup, (chat_id, message_id) cross-chat isolation, LRU eviction/recency, three-way _resolve_reply, _delivery_session, reply-to-own-message routing, send() recording, and the flag gate (enabled reflects config; recording skipped when off).
  • Full suite: 3378 passed; the only 2 failures (test_config_env, test_lockdown) reproduce on clean main in this environment and are unrelated to this change.

serxa and others added 2 commits August 27, 2026 16:57
Replying (Telegram reply) to a message the bot sent now routes the reply
to the session that produced that message — and makes it the active
session — instead of the chat's active session. This makes it easy to
answer messages from cron/background sessions and to talk to several
sessions without an explicit /session switch.

The channel records message_id -> (chat_id, session_id) for every
outbound message (interactive send() and notification delivery) in a
bounded LRU, and resolves it on inbound replies. It falls back to the
active session when the mapping is unknown or the target session is
gone/archived, and requires the chat id to match so a reply can never
cross into a session bound to a different chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…uting

A reply we can't tie to a live session — the replied-to message was never
recorded / was evicted from the LRU, or its session is gone/archived — is
now rejected with a static, no-LLM message asking the user to pick a
session with /sessions and resend, rather than silently delivered to the
chat's active session (which would be a mis-route to the wrong session).

_resolve_reply() is now three-way (active / route / reject); the inbound
handlers send the nudge and drop a rejected reply without starting a turn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@serxa
serxa marked this pull request as ready for review August 28, 2026 05:50
Record each inbound message against the session it is delivered to (the
resolved reply target, else the active session), not just outbound bot
messages. A user can then reply to their OWN earlier message to route the
reply to that message's session — the same first-class targeting as
replying to a bot message, and a clean way to re-target without /session.

Adds _delivery_session(); both inbound handlers record the incoming
message id -> target session before dispatch, and dispatch with that
session explicitly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@alex-clickhouse alex-clickhouse self-assigned this Aug 28, 2026
Comment thread nerve/channels/telegram.py Outdated
"""
if not session_id:
return
self._reply_routes[message_id] = (chat_id, session_id)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the key here needs to be (chat_id, message_id), as message_ids are not unique by themselves.

@alex-clickhouse

Copy link
Copy Markdown
Collaborator

Routing is sticky — a reply also makes the target session active (like /session ), so a following plain message continues there.

Is this actually desirable? I think it may be quite surprising/unexpected behavior for users. Open to being convinced otherwise though.

@serxa

serxa commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

I was initially thinking that sticky is bad as well, but TBH, if it's not sticky, then you have to keep two things in your head - where you are answering and where the current active session is. This is tricky if you have many sessions. Imagine you started something and then replied to 5 different sessions. At this point, you probably do not remember where you started. Also, when you later look at chat history, it is completely unclear where the message goes.

On the other hand, sticky resolves everything. This is the natural way chat works. Wherever you are, just continue the conversation in a way consistent with your previous message. It's 100% better IMO and keeps chat history clear and understandable.

@pufit

pufit commented Sep 1, 2026

Copy link
Copy Markdown
Member

I personally would like to have this feature. But I talked with Nerve contributor @constkolesnyak before who originally introduced most of the Telegram integration and he said that the current behavior is intended.

Let's put it under a feature flag in settings?

@serxa

serxa commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

current behavior is intended.

Could you please elaborate on it a bit? If I understood correctly, currently replies allow to enrich somehow what is routed to the current session. I didn't use it, but anyway, this is unchanged, I believe. You can reply to a message generated on the same session - this is exactly the previous behavior.

Slop mode:

What you lose by enabling unconditionally:

Quoting an arbitrary / cross-session message into your current session. A reply now either switches you to that message's session, or — if it's unmapped — rejects. So you can't reply to something from another session (or an old one) just to pull it in here. Workaround = copy-paste or a plain message.

The "a reply always delivers" guarantee. An unmapped reply now rejects instead of landing in active. Sharpest edge: the map is in-memory, so right after any restart, replying to any pre-restart message (including the bot's last one) rejects until you exchange a few fresh messages — same for your own old messages or bot messages past the 2000 LRU cap.

@serxa

serxa commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

Let's put it under a feature flag in settings?

I'm okay with this if that's what we want.

serxa and others added 2 commits September 1, 2026 13:44
Telegram message ids are unique only within a chat, so keying the
reply-route map on message_id alone let one chat's message clobber
another chat's identically-numbered one — the clobbered chat's later
reply would then miss and be wrongly rejected. Key on
(chat_id, message_id) instead. Addresses review feedback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Put the new reply→origin-session routing behind an opt-in setting
telegram.reply_routes_to_origin_session (default false), so the existing
context-only reply behavior is unchanged unless enabled — addresses review
feedback that the current behavior is intended. When off, no record /
resolve / reject runs and a reply lands in the active session like a plain
message. The flag is read live per message (no restart to toggle).

Adds the setting to TelegramConfig + docs (settings + hot-reload tables);
gates the recorder and both inbound handlers; adds on/off gate tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants