Skip to content

Isolate Claude sessions per Telegram forum topic - #20

Open
zamber wants to merge 1 commit into
linuz90:mainfrom
zamber:feature/per-thread-sessions
Open

Isolate Claude sessions per Telegram forum topic#20
zamber wants to merge 1 commit into
linuz90:mainfrom
zamber:feature/per-thread-sessions

Conversation

@zamber

@zamber zamber commented Jul 26, 2026

Copy link
Copy Markdown

Summary

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. 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 each chatId:message_thread_id to its own ClaudeSession instance. 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: 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 turned out to matter: 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 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.
  • The 9 handler files each get a 2-line mechanical change: swap the import { session } from "../session" singleton for const session = getSession(ctx) at the top of the 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.

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 across session-manager.test.ts / thread-routing.test.ts, including a concurrency test that proves the AsyncLocalStorage isolation actually holds under parallel requests (two "in-flight" updates on different threads don't leak into each other).
  • bun run typecheck clean.
  • One round of manual end-to-end testing in a real forum-mode Telegram group, including finding and fixing the message_thread_id bug 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.

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.
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.

1 participant