Fork of claude-plugins-official/telegram v0.0.5 with four bug fixes applied.
All original functionality is preserved: pairing flow, access.json schema, group support, permission relay, attachment handling, static mode, and the /start, /help, /status bot commands.
Root cause: Claude Code loads .mcp.json from two locations simultaneously:
~/.claude/plugins/cache/claude-plugins-official/telegram/0.0.5/.mcp.json~/.claude/plugins/marketplaces/claude-plugins-official/external_plugins/telegram/.mcp.json
This spawns two identical server processes in the same session. Both attempt long-polling (getUpdates) with the same bot token, Telegram allows only one consumer per token, and the second one sees 409 Conflict immediately.
Fix: On startup, the server atomically creates ~/.claude/channels/telegram/poll.lock using the 'wx' (O_WRONLY | O_CREAT | O_EXCL) flag. The first process to succeed becomes the sole poller. If the lock file already exists, the holder's PID is checked:
- Alive → this process starts in tools-only mode (
reply,react,edit_message,download_attachmentall work; no inbound polling). - Dead/stale → lock is removed and this process tries to acquire it once more.
The lock is released (poll.lock deleted) in shutdown() so the next session can acquire it cleanly.
Files changed: src/server.ts — POLL_LOCK_FILE constant, lock acquisition block (~40 lines), shutdown() cleanup.
Root cause: When Claude Code exits, it closes the pipe to the server's stderr. The orphan watchdog detects the orphaned state and calls shutdown(), which calls process.stderr.write('shutting down\n'). That write throws EPIPE (broken pipe). The uncaughtException handler catches it and tries to write the error to stderr — which throws EPIPE again — creating an infinite loop burning 100% CPU.
Fix: A safeStderr(msg) helper wraps every process.stderr.write call in a try/catch. On any error (including EPIPE), it calls process.exit(0) immediately without any further I/O. Additionally, the uncaughtException handler explicitly checks for EPIPE (err.code === 'EPIPE') and calls process.exit(0) before even trying safeStderr.
safeStderr is defined early in the file (before any code that writes to stderr) and is used for every log write throughout the server.
Files changed: src/server.ts — safeStderr() function, uncaughtException handler, all process.stderr.write calls replaced with safeStderr.
Root cause: The original polling loop only retried 409 Conflict. Any other error from bot.start() — including ECONNRESET, ETIMEDOUT, ENOTFOUND (DNS failures), and Telegram 5xx responses — caused the loop to return, killing polling permanently for the session lifetime.
Fix: Two classification functions distinguish error types:
isTransientError(err)— returnstruefor: Telegram5xx,ECONNRESET,ETIMEDOUT,ENOTFOUND,ECONNREFUSED,EAI_AGAIN,EHOSTUNREACH,ECONNABORTED,ENETUNREACH, and network-related error message strings.isPermanentError(err)— returnstruefor Telegram401(bad token) and403(globally blocked).
The polling loop now:
- Exits immediately on permanent errors (unchanged from original intent, now correctly enforced).
- Retries indefinitely on transient errors with exponential backoff: 2 s base, 1.8× multiplier, 30 s cap.
- Keeps the original linear backoff for
409 Conflict(up to 8 attempts, then gives up).
Backoff schedule: 2 s → 3.6 s → 6.5 s → 11.7 s → 21 s → 30 s (then stays at 30 s).
Files changed: src/server.ts — isTransientError(), isPermanentError() functions; polling loop catch block.
Root cause: When claude is started without --channels, Claude Code still loads and spawns all configured MCP servers (including this plugin). That server starts polling, stealing getUpdates from the --channels session and causing 409 Conflict there.
Fix: The server checks process.env.TELEGRAM_POLLING_ENABLED at startup. If it is not "1", the server skips polling entirely and runs in tools-only mode. Set TELEGRAM_POLLING_ENABLED=1 in the MCP server's env block (see activation instructions below) so polling only runs in the session where you explicitly configure it.
Files changed: src/server.ts — POLLING_ENABLED constant; polling loop checks it before calling bot.start().
Claude Code looks for user-scope MCP server definitions in the top-level mcpServers object of ~/.claude.json — not settings.json. Add the fork there with TELEGRAM_POLLING_ENABLED baked into the env block so polling only starts in the session you intend:
{
"mcpServers": {
"telegram": {
"type": "stdio",
"command": "bun",
"args": [
"run",
"--cwd",
"/path/to/telegram-plugin-fork",
"start"
],
"env": {
"TELEGRAM_POLLING_ENABLED": "1"
}
}
}
}Replace /path/to/telegram-plugin-fork with the actual path where you cloned this repo.
Then disable the official plugin to prevent duplicate spawning:
claude plugin disable telegram@claude-plugins-officialAlso rename the marketplace .mcp.json that causes the duplicate-spawn (issue #1171):
mv ~/.claude/plugins/marketplaces/claude-plugins-official/external_plugins/telegram/.mcp.json \
~/.claude/plugins/marketplaces/claude-plugins-official/external_plugins/telegram/.mcp.json.disabledStart a channels session with:
claude --dangerously-load-development-channels server:telegramPlace or symlink the fork's .mcp.json in your project root. Claude Code walks up from the current directory looking for .mcp.json files and will pick it up automatically.
Note: Update the --cwd path in .mcp.json if you move the fork to a different location. Add "env": { "TELEGRAM_POLLING_ENABLED": "1" } to the server entry if you want polling enabled from this .mcp.json.
-
Start a channels session:
claude --dangerously-load-development-channels server:telegram -
Watch stderr for the fork:
telegram channel: polling as @your_bot_nameThe second process spawned in the same session should log:
telegram channel: polling lock held by pid=XXXXX, starting tools-onlyand not
409 Conflict. -
Check
~/.claude/channels/telegram/poll.lock— it should contain the PID of the polling process.
- Start the server, then kill Claude Code (or close the terminal abruptly).
- Watch CPU usage — the server process should exit cleanly within ~5 seconds (the orphan watchdog interval).
- It should not peg a CPU core at 100%.
- Start polling, then temporarily block network access (e.g., disable Wi-Fi).
- The log should show:
telegram channel: transient error (attempt 1): FetchError: fetch failed. Retrying in 2.0s telegram channel: transient error (attempt 2): ... Retrying in 3.6s ... - Re-enable network. Polling should resume automatically without restarting the session.
- Start a regular
claudesession withoutTELEGRAM_POLLING_ENABLED=1in the server's env. - The server should log:
telegram channel: TELEGRAM_POLLING_ENABLED not set — running tools-only - The
reply,react,edit_message,download_attachmenttools should still be available to Claude. - No
getUpdatesrequests should appear in Telegram's bot debug logs.
All state lives in ~/.claude/channels/telegram/ (or $TELEGRAM_STATE_DIR):
| File | Purpose |
|---|---|
.env |
TELEGRAM_BOT_TOKEN=... (written by /telegram:configure) |
access.json |
Pairing state, allowlists, group policies, delivery config |
bot.pid |
PID of the active poller (cross-session zombie cleanup) |
poll.lock |
PID of the active poller (intra-session duplicate prevention — new in this fork) |
inbox/ |
Downloaded photo and attachment files |
approved/ |
Handshake files from /telegram:access pair |
The poll.lock file is new. If you ever see polling stuck and suspect a stale lock, check whether the PID inside is still alive:
kill -0 $(cat ~/.claude/channels/telegram/poll.lock) 2>/dev/null && echo alive || echo staleIf stale, remove it manually:
rm ~/.claude/channels/telegram/poll.lock