feat: multi-session router with Telegram forum topic support#2
Conversation
d3f3ef2 to
3145e63
Compare
|
Thanks for this — genuinely impressed with the quality of the work. Clean module boundaries, OS-level file lock for the mutex (instead of PID heartbeat), peek+commit-after-send for at-least-once outbox semantics, reconcile-on-startup for orphans, graceful VS Code refusal, idle auto-shutdown, zero Did a thorough review. One blocker and a few should-fixes before we can merge / release. Must-fix (blocker)1. [target.'cfg(unix)'.dependencies]
libc = \"0.2\"Could you also add Should-fix (before v0.2.0 release)2. Fail-fast on missing 3. 4. File permissions (Unix). The router state directory currently relies on the user's umask. For defense in depth against other local processes on the same machine, chmod the state dir 5. 6. Nice-to-have (follow-up PRs are fine)
On compatibility with v0.1.2Confirmed the opt-in design is clean — users without One thing we'll need to handle on our end (not in this PR): the plugin launcher in Again, really solid work. Happy to merge as soon as the blocker is fixed — should be a one-liner on your end. The should-fixes can land in this PR or as a follow-up, your call. If you'd prefer we handle (3)–(6) in a separate PR after merging, that's fine too. |
|
Hi @Filyus 👋 — wanted to check in. Any blockers on the For what it's worth, we just merged #4 from @AsgardMuninn — so the repo is actively moving. Your router architecture is the right next step for v0.2.0. |
Add claude_session module that discovers Claude's PID via parent-chain
walk and reads the stable sessionId from ~/.claude/sessions/{PID}.json.
A background watcher in the MCP detects sessionId changes at runtime
(interactive --resume picker, /resume, /clear) and rewrites the
registration file so the router rebinds forum topics on next poll.
Key changes:
- Session registry stores claude_session_id and message_count per entry
- register() renamed to bind(); evicts prior topic owner on reattach,
carries message_count so reattached topics aren't treated as parasitic
- reconcile_session() replaces ensure_topic(): handles no-op, first
registration, and mid-session rebind in one path
- Parasitic topics (zero activity) are deleted instead of closed to keep
the supergroup clean; topics with history are only closed
- Router stderr redirected to state_dir/router.log to prevent broken-pipe
kills when the spawning MCP exits before the router
- HDCD_SKIP_SESSION_DISCOVERY env var disables discovery in tests
Moves `load_token` out of main.rs into a new `src/token.rs` so router mode can reuse the same env / `.env` fallback logic. Adds `try_load_token` (returning `Option`) alongside the hard-fail `load_token`, plus unit tests covering priority order, quote stripping, and the "nothing configured" branch. No behavior change for standalone mode.
Introduces `secure_dir` (0700) and `secure_file` (0600) as Unix-only chmod helpers, with Windows no-op stubs. Call-site wiring for the router state directory and its files follows in a subsequent commit.
`RouterConfig.supergroup_id` is now `i64`. A custom deserializer accepts either a JSON number (`-1001234567890`) or a JSON string (`"-1001234567890"`, whitespace tolerated) so users can paste the id in whichever form their tools emit. Invalid strings, overflowing numbers, and zero are rejected up-front with a clear error — a typo in the `-100` prefix no longer silently drops every inbound message. Call-sites that need the string form for Bot API use the new `RouterConfig::chat_id_str()` helper. The supergroup filter in `handle_update` is now a direct `i64 == i64` comparison.
`bot_token` in `config.json` is now optional: if it's missing or empty, the router resolves it via the standalone channel's discovery chain — `HDCD_TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_TOKEN`, then `~/.claude/channels/telegram/.env`. This lets existing v0.1.2 users flip on router mode without duplicating their token into a fresh config file. If nothing resolves, the error now lists all four discovery paths instead of just the missing config field. Adds `token::with_isolated_token_env` as a shared test harness so tests in `token` and `router::config` can safely manipulate the process-global env vars under one mutex.
`ensure_router_running` previously warned and returned `Ok` if `config.json` or the `hdcd-router` binary was missing. The MCP server kept running in router mode with no peer, silently accepting tool calls and polling an inbox that nothing ever wrote to — from Claude's perspective the channel was connected, but nothing arrived. Both missing-prerequisite paths now `bail!` with actionable error messages pointing to the expected location and suggesting the standalone fallback. Adds an `HDCD_SKIP_ROUTER_LAUNCH` env escape hatch so the IPC integration test can exercise file plumbing without a live router peer.
Every file and directory the router creates under its state dir is now chmod'd 0600 (files) or 0700 (dirs) on Unix: - router state dir itself (`config::state_dir`) - `inbox/`, `outbox/`, `register/` subdirs (`mailbox::ensure_dirs`) - per-session registration JSON written by the MCP binary - `sessions.json` persisted by `SessionRegistry::save` - `router.lock` heartbeat and `router.guard` OS lock - inbox/outbox JSONL files and `.pos` companions Same defense-in-depth posture the standalone channel already had for `.env`. Windows paths are no-ops (NTFS ACLs inherit user-private from the profile directory).
`config::load` now calls `fs_perms::warn_if_world_readable` on `config.json` before reading it. Mirrors the existing `.env` check in standalone mode — a misconfigured perm bit gets logged at startup with a `chmod 600` hint instead of silently leaking the bot token to other local users. Consolidates the shared warning logic in `fs_perms` so both token discovery and router config use the same path.
Adds a comment documenting that any future update variant must be gated by is_allowed() before session-affecting work. The early-return now logs which variant was ignored (callback_query vs other) to make silent drops visible in debug traces. Purely documentation/observability — no behavior change.
The check-job already runs tests on Ubuntu, but the Build-job only compiles. Adds a dedicated Test-job with a matrix covering ubuntu-latest and macos-latest to catch unix-specific regressions (file perms, tempdir behavior) before they land on main.
Extracts PID-liveness filtering from close_dead_sessions into SessionRegistry::dead_session_ids so the decision is testable without a TopicManager. Adds three tests: - dead_session_ids_finds_sessions_with_nonexistent_pid: u32::MAX PID is treated as dead; None-pid sessions are not - dead_session_ids_ignores_closed_sessions: only active sessions contribute to the reconcile list - lock_guard_is_exclusive: a second acquire fails fast (non-blocking) and re-acquire succeeds after the first guard drops
Applies cargo fmt across router sources touched during the v0.2.0 work, and converts the is_some/unwrap pattern in reconcile_session to an if-let so clippy passes under -D warnings. No behavior change.
3145e63 to
c0a1538
Compare
|
Thanks for the thorough review, really appreciated the detail. Pushed a series of commits on Must-fix
Should-fix
Nice-to-have tests
Extra (beyond the review)One bigger feature landed on top of the router work while this was in flight: forum topics now stay attached to the same logical conversation across The fix is to track Claude Code's stable There was also a small infra fix that fell out of this: the router's stderr is now redirected to a log file in the state dir rather than inherited from the spawning MCP, so the router doesn't get killed by SIGPIPE if the MCP exits first. Deferred
All green locally: |
|
Outstanding turnaround — every review item landed, plus the session-tracking feature ( Re-reviewed everything. Build is clean ( One blocker before merge:
unsafe { libc::kill(pid as i32, 0) == 0 }
One-liner fix at the top of if pid == 0 || pid > i32::MAX as u32 {
return false;
}That plugs both the test and the underlying bug. Follow-up items (not blocking):
One CI note: the matrix job you added is exactly what we needed (ubuntu + macos running Once M1 is fixed and CI goes green, I'll merge. Really solid contribution. |
Re-apply secure file permissions after the Claude session watcher rewrites its registration file. std::fs::write normally preserves permissions for the existing inode, but this keeps watcher rewrites consistent with the initial registration write and hardens edge cases where the file was recreated or permissions changed unexpectedly.
Emit an early warning when config.json contains a positive supergroup_id. Telegram supergroup chat IDs normally use the -100... form, so accepting a positive value would likely lead to confusing downstream API failures.
|
Thanks again for the careful review and the kind words. Fixed the blocker and the three follow-up items:
CI is currently waiting on maintainer approval ( |
|
Quick follow-up: I spoke too soon on the macOS fix. The initial follow-up used I pushed a new fix: macOS parent walking now uses a target-specific This should remove the macOS CI blocker. |
|
Excellent turnaround on every review item. M1 ( I re-approved CI after your latest push. Linux + Windows + clippy/fmt are clean, but all three macOS jobs fail:
Reproduced locally on
Blocker — one fix, three paths1 (recommended) — #[cfg(target_os = \"macos\")]
fn parent_of(pid: u32) -> Option<u32> {
if pid == 0 || pid > i32::MAX as u32 { return None; }
let out = std::process::Command::new(\"ps\")
.args([\"-o\", \"ppid=\", \"-p\", &pid.to_string()])
.output().ok()?;
if !out.status.success() { return None; }
std::str::from_utf8(&out.stdout).ok()?.trim().parse().ok()
}Lands the PR fast and keeps the macOS code in the same readability tier as Linux. We can revisit if the cost ever bites in practice (it won't — this is on the cold path, called only when the watcher resolves a Claude PID). 2 — 3 — Manual My vote: (1). Smaller items (do whatever's quickest)
DocsREADME expansion looks complete and well-structured. No CHANGELOG to maintain — consistent with the project so far. Once the macOS path lands and the matrix goes green, this is a clean v0.2.0. Thanks again — really impressed by how cleanly each iteration converges. |
05bdb88 to
20566c0
Compare
|
Updated with the simpler macOS approach you recommended. |
|
Merging now — it ships as v0.2.0. 🎉 This is the largest external contribution to hdcd-telegram so far, and the quality bar stayed high the whole way through:
Cross-platform CI (Linux / macOS / Windows) is fully green. Thank you, @Filyus — first-rate work, and exactly the kind of contribution that makes an OSS project better than its maintainers could manage alone. Looking forward to whatever you pick up next. 🙏 |
Features
Why:
TELEGRAM_BOT_TOKENyields409 ConflictongetUpdates./newbotand token per project, and leaves you guessing which bot's DM belongs to which session.What the router does:
hdcd-routerholds the single polling connection and multiplexes sessions across forum topics of a shared supergroup.set_topic_title— Claude renames its own topic once the conversation theme becomes clear (the initial name is the project folder plus a short session ID).Usage
Router mode is opt-in via the
--routerflag:{ "mcpServers": { "telegram": { "command": "hdcd-telegram", "args": ["--router"] } } }Config (
~/.claude/channels/telegram-router/config.json):{ "bot_token": "...", "supergroup_id": "-100xxxxxxxxxx", "allowed_users": ["<your-telegram-user-id>"] }The supergroup must have Topics enabled (Telegram → Group Settings → Topics), and the bot must be an admin with Manage Topics permission. To find the
supergroup_id: send a message in the group, then callhttps://api.telegram.org/bot<TOKEN>/getUpdates— the ID is.result[].message.chat.id(negative number starting with-100). The same response has.from.idforallowed_users.Summary
hdcd-router— a long-running process that manages concurrent sessions and coordinates with the Telegram Bot API.Changes
src/router/(config, mailbox, sessions, topics, main).src/main.rswith a router subcommand.set_topic_title(router mode only).tests/router_ipc_test.rs.