Isolate Claude sessions per Telegram forum topic - #20
Open
zamber wants to merge 1 commit into
Open
Conversation
The bot currently keeps exactly one global ClaudeSession singleton, shared
by every chat and every forum topic - a message in one topic bleeds into
the same Claude conversation as every other topic in the group.
- src/ext/session-manager.ts: getSession(ctx) routes each
chatId:message_thread_id to its own ClaudeSession instance (lazily
created, cached for the process lifetime). A message with no thread id
(i.e. not inside a forum topic) always maps to the literal key "default",
so the pre-existing single-session behavior is preserved exactly for
every non-topic chat and for the session history already saved in
/tmp/claude-telegram-session.json.
- session.ts: ClaudeSession takes an optional sessionKey (default
"default"), threaded into the saved-session JSON (SavedSession gets an
optional session_key field) and into getSessionList()'s filtering, so
/resume's picker only shows the current thread's own history.
- src/ext/thread-routing.ts: forces every outgoing Bot API call for the
update being processed to carry the correct message_thread_id, via
AsyncLocalStorage + a raw API transformer (bot.api.config.use). This is
needed because grammY's own ctx.reply()/etc. shortcuts only attach
message_thread_id when Telegram's is_topic_message flag happens to be
set, which isn't reliable enough on its own - when it's missing, a reply
silently lands in the forum's "General" topic instead of the one the
user is in, which looks exactly like "threads don't work" even though
session isolation itself is unaffected.
- The 9 handler files get a 2-line mechanical change each: swap the
`import { session } from "../session"` singleton for
`const session = getSession(ctx)` at the top of each handler function -
the rest of each function body is untouched.
- index.ts's sequentialize() key also becomes thread-aware, so one forum
topic no longer blocks message processing in another topic of the same
chat.
Full disclosure: this was built with Claude Code (vibe-coded), and testing
so far is a mix of unit tests (bun test, new in this PR - 20 tests across
session-manager.test.ts/thread-routing.test.ts, including a concurrency
test proving the AsyncLocalStorage isolation actually holds under
parallel requests) and one round of manual end-to-end testing in a real
forum-mode group after finding and fixing the message_thread_id bug above.
It hasn't seen extended real-world use yet - if you spot an issue, happy
to iterate on this PR.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The bot currently keeps exactly one global
ClaudeSessionsingleton, shared by every chat and every forum topic - a message in one topic bleeds into the same Claude conversation as every other topic in the group. This gives each topic its own session/conversation history, while leaving regular (non-topic) chats completely unaffected.src/ext/session-manager.ts:getSession(ctx)routes eachchatId:message_thread_idto its ownClaudeSessioninstance. A message with no thread id (not inside a forum topic) always maps to the literal key"default", so the existing single-session behavior - and the session history already saved in/tmp/claude-telegram-session.json- is preserved exactly for every non-topic chat.session.ts:ClaudeSessiontakes an optionalsessionKey(default"default"), threaded into the saved-session JSON (SavedSessiongets an optionalsession_keyfield) and intogetSessionList()'s filtering, so/resume's picker only shows the current thread's own history.src/ext/thread-routing.ts: forces every outgoing Bot API call for the update being processed to carry the correctmessage_thread_id, viaAsyncLocalStorage+ a raw API transformer (bot.api.config.use). This turned out to matter: grammY's ownctx.reply()/etc. shortcuts only attachmessage_thread_idwhen Telegram'sis_topic_messageflag happens to be set, which isn't reliable enough on its own - when it's missing, a reply silently lands in the forum's "General" topic instead of the one the user is actually in, which looks exactly like "threads don't work" even though session isolation itself is unaffected. Doing this as an API transformer means it applies to every current (and future) call site with zero handler changes.import { session } from "../session"singleton forconst session = getSession(ctx)at the top of the function - the rest of each function body is untouched.index.ts'ssequentialize()key also becomes thread-aware, so one forum topic no longer blocks message processing in another topic of the same chat.Everything new lives under
src/ext/; the touches to existing files are small and additive (session.ts/types.ts/index.ts/the 9 handlers) - tried to keep this easy to review and low-conflict for future changes to this repo.Test plan
bun test- new in this PR (wasn't wired up before), 20 tests acrosssession-manager.test.ts/thread-routing.test.ts, including a concurrency test that proves theAsyncLocalStorageisolation actually holds under parallel requests (two "in-flight" updates on different threads don't leak into each other).bun run typecheckclean.message_thread_idbug described above.Disclosure
Built with Claude Code (this repo already ships its own
CLAUDE.md, so figured that's fair game). Testing so far is unit tests plus one round of manual testing - it hasn't seen extended real-world use yet. Happy to iterate on this PR if you spot an issue or want something done differently.