Skip to content

griffinwork40/telegram-plugin-fork

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

telegram-plugin-fork

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.


What was changed and why

Bug 1 (CRITICAL) — Intra-session duplicate pollers (issue #1171)

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_attachment all 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.tsPOLL_LOCK_FILE constant, lock acquisition block (~40 lines), shutdown() cleanup.


Bug 2 (HIGH) — Orphan watchdog EPIPE infinite loop (issue #1049)

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.tssafeStderr() function, uncaughtException handler, all process.stderr.write calls replaced with safeStderr.


Bug 3 (HIGH) — Polling exits permanently on transient errors (issue #963)

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) — returns true for: Telegram 5xx, ECONNRESET, ETIMEDOUT, ENOTFOUND, ECONNREFUSED, EAI_AGAIN, EHOSTUNREACH, ECONNABORTED, ENETUNREACH, and network-related error message strings.
  • isPermanentError(err) — returns true for Telegram 401 (bad token) and 403 (globally blocked).

The polling loop now:

  1. Exits immediately on permanent errors (unchanged from original intent, now correctly enforced).
  2. Retries indefinitely on transient errors with exponential backoff: 2 s base, 1.8× multiplier, 30 s cap.
  3. 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.tsisTransientError(), isPermanentError() functions; polling loop catch block.


Bug 4 (MEDIUM) — Non-channels instances also start polling (issue #881)

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.tsPOLLING_ENABLED constant; polling loop checks it before calling bot.start().


How to activate

Option A: Register in ~/.claude.json (recommended)

Claude Code looks for user-scope MCP server definitions in the top-level mcpServers object of ~/.claude.jsonnot 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-official

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

Start a channels session with:

claude --dangerously-load-development-channels server:telegram

Option B: Use the fork's .mcp.json directly

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


How to verify it's working

Verify Bug 1 (no duplicate pollers)

  1. Start a channels session: claude --dangerously-load-development-channels server:telegram

  2. Watch stderr for the fork:

    telegram channel: polling as @your_bot_name
    

    The second process spawned in the same session should log:

    telegram channel: polling lock held by pid=XXXXX, starting tools-only
    

    and not 409 Conflict.

  3. Check ~/.claude/channels/telegram/poll.lock — it should contain the PID of the polling process.

Verify Bug 2 (no EPIPE loop)

  1. Start the server, then kill Claude Code (or close the terminal abruptly).
  2. Watch CPU usage — the server process should exit cleanly within ~5 seconds (the orphan watchdog interval).
  3. It should not peg a CPU core at 100%.

Verify Bug 3 (transient error recovery)

  1. Start polling, then temporarily block network access (e.g., disable Wi-Fi).
  2. 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
    ...
    
  3. Re-enable network. Polling should resume automatically without restarting the session.

Verify Bug 4 (tools-only without polling enabled)

  1. Start a regular claude session without TELEGRAM_POLLING_ENABLED=1 in the server's env.
  2. The server should log:
    telegram channel: TELEGRAM_POLLING_ENABLED not set — running tools-only
    
  3. The reply, react, edit_message, download_attachment tools should still be available to Claude.
  4. No getUpdates requests should appear in Telegram's bot debug logs.

State files

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 stale

If stale, remove it manually:

rm ~/.claude/channels/telegram/poll.lock

About

Fork of claude-plugins-official Telegram plugin with 4 reliability fixes (poll.lock, EPIPE guard, transient retry, channels-only polling)

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors