Repository: hanxiao/claudecode-telegram Branch: main Commit: c925f90e0b54 Files: 5 Total size: 15.2 KB Directory structure: gitextract_7klvte_z/ ├── .gitignore ├── README.md ├── bridge.py ├── hooks/ │ └── send-to-telegram.sh └── pyproject.toml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ __pycache__/ *.pyc .venv/ *.egg-info/ .env ================================================ FILE: README.md ================================================ # claudecode-telegram ![demo](demo.gif) Telegram bot bridge for Claude Code. Send messages from Telegram, get responses back. ## How it works ```mermaid flowchart LR A[Telegram] --> B[Cloudflare Tunnel] B --> C[Bridge Server] C -->|tmux send-keys| D[Claude Code] D -->|Stop Hook| E[Read Transcript] E -->|Send Response| A ``` 1. Bridge receives Telegram webhooks, injects messages into Claude Code via tmux 2. Claude Code's Stop hook reads the transcript and sends response back to Telegram 3. Only responds to Telegram-initiated messages (uses pending file as flag) ## Install ```bash # Prerequisites brew install tmux cloudflared # Clone git clone https://github.com/hanxiao/claudecode-telegram cd claudecode-telegram # Setup Python env uv venv && source .venv/bin/activate uv pip install -e . ``` ## Setup ### 1. Create Telegram bot Bot receives your messages and sends Claude's responses back. ```bash # Message @BotFather on Telegram, create bot, get token ``` ### 2. Configure Stop hook Hook triggers when Claude finishes responding, reads transcript, sends to Telegram. ```bash cp hooks/send-to-telegram.sh ~/.claude/hooks/ nano ~/.claude/hooks/send-to-telegram.sh # set your bot token chmod +x ~/.claude/hooks/send-to-telegram.sh ``` Add to `~/.claude/settings.json`: ```json { "hooks": { "Stop": [{"hooks": [{"type": "command", "command": "~/.claude/hooks/send-to-telegram.sh"}]}] } } ``` ### 3. Start tmux + Claude tmux keeps Claude Code running persistently; bridge injects messages via `send-keys`. ```bash tmux new -s claude claude --dangerously-skip-permissions ``` ### 4. Run bridge Bridge receives Telegram webhooks and injects messages into Claude Code. ```bash export TELEGRAM_BOT_TOKEN="your_token" python bridge.py ``` ### 5. Expose via Cloudflare Tunnel Tunnel exposes local bridge to the internet so Telegram can reach it. ```bash cloudflared tunnel --url http://localhost:8080 ``` ### 6. Set webhook Tells Telegram where to send message updates. ```bash curl "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/setWebhook?url=https://YOUR-TUNNEL-URL.trycloudflare.com" ``` ## Bot Commands | Command | Description | |---------|-------------| | `/status` | Check tmux session | | `/clear` | Clear conversation | | `/resume` | Pick session to resume (inline keyboard) | | `/continue_` | Auto-continue most recent | | `/loop ` | Start Ralph Loop (5 iterations) | | `/stop` | Interrupt Claude | ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `TELEGRAM_BOT_TOKEN` | required | Bot token from BotFather | | `TMUX_SESSION` | `claude` | tmux session name | | `PORT` | `8080` | Bridge port | ================================================ FILE: bridge.py ================================================ #!/usr/bin/env python3 """Claude Code <-> Telegram Bridge""" import os import json import subprocess import threading import time import urllib.request from http.server import HTTPServer, BaseHTTPRequestHandler from pathlib import Path TMUX_SESSION = os.environ.get("TMUX_SESSION", "claude") CHAT_ID_FILE = os.path.expanduser("~/.claude/telegram_chat_id") PENDING_FILE = os.path.expanduser("~/.claude/telegram_pending") HISTORY_FILE = os.path.expanduser("~/.claude/history.jsonl") BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "") PORT = int(os.environ.get("PORT", "8080")) BOT_COMMANDS = [ {"command": "clear", "description": "Clear conversation"}, {"command": "resume", "description": "Resume session (shows picker)"}, {"command": "continue_", "description": "Continue most recent session"}, {"command": "loop", "description": "Ralph Loop: /loop "}, {"command": "stop", "description": "Interrupt Claude (Escape)"}, {"command": "status", "description": "Check tmux status"}, ] BLOCKED_COMMANDS = [ "/mcp", "/help", "/settings", "/config", "/model", "/compact", "/cost", "/doctor", "/init", "/login", "/logout", "/memory", "/permissions", "/pr", "/review", "/terminal", "/vim", "/approved-tools", "/listen" ] def telegram_api(method, data): if not BOT_TOKEN: return None req = urllib.request.Request( f"https://api.telegram.org/bot{BOT_TOKEN}/{method}", data=json.dumps(data).encode(), headers={"Content-Type": "application/json"} ) try: with urllib.request.urlopen(req, timeout=10) as r: return json.loads(r.read()) except Exception as e: print(f"Telegram API error: {e}") return None def setup_bot_commands(): result = telegram_api("setMyCommands", {"commands": BOT_COMMANDS}) if result and result.get("ok"): print("Bot commands registered") def send_typing_loop(chat_id): while os.path.exists(PENDING_FILE): telegram_api("sendChatAction", {"chat_id": chat_id, "action": "typing"}) time.sleep(4) def tmux_exists(): return subprocess.run(["tmux", "has-session", "-t", TMUX_SESSION], capture_output=True).returncode == 0 def tmux_send(text, literal=True): cmd = ["tmux", "send-keys", "-t", TMUX_SESSION] if literal: cmd.append("-l") cmd.append(text) subprocess.run(cmd) def tmux_send_enter(): subprocess.run(["tmux", "send-keys", "-t", TMUX_SESSION, "Enter"]) def tmux_send_escape(): subprocess.run(["tmux", "send-keys", "-t", TMUX_SESSION, "Escape"]) def get_recent_sessions(limit=5): if not os.path.exists(HISTORY_FILE): return [] sessions = [] try: with open(HISTORY_FILE) as f: for line in f: try: sessions.append(json.loads(line.strip())) except: continue except: return [] sessions.sort(key=lambda x: x.get("timestamp", 0), reverse=True) return sessions[:limit] def get_session_id(project_path): encoded = project_path.replace("/", "-").lstrip("-") for prefix in [f"-{encoded}", encoded]: project_dir = Path.home() / ".claude" / "projects" / prefix if project_dir.exists(): jsonls = list(project_dir.glob("*.jsonl")) if jsonls: return max(jsonls, key=lambda p: p.stat().st_mtime).stem return None class Handler(BaseHTTPRequestHandler): def do_POST(self): body = self.rfile.read(int(self.headers.get("Content-Length", 0))) try: update = json.loads(body) if "callback_query" in update: self.handle_callback(update["callback_query"]) elif "message" in update: self.handle_message(update) except Exception as e: print(f"Error: {e}") self.send_response(200) self.end_headers() self.wfile.write(b"OK") def do_GET(self): self.send_response(200) self.end_headers() self.wfile.write(b"Claude-Telegram Bridge") def handle_callback(self, cb): chat_id = cb.get("message", {}).get("chat", {}).get("id") data = cb.get("data", "") telegram_api("answerCallbackQuery", {"callback_query_id": cb.get("id")}) if not tmux_exists(): self.reply(chat_id, "tmux session not found") return if data.startswith("resume:"): session_id = data.split(":", 1)[1] tmux_send_escape() time.sleep(0.2) tmux_send("/exit") tmux_send_enter() time.sleep(0.5) tmux_send(f"claude --resume {session_id} --dangerously-skip-permissions") tmux_send_enter() self.reply(chat_id, f"Resuming: {session_id[:8]}...") elif data == "continue_recent": tmux_send_escape() time.sleep(0.2) tmux_send("/exit") tmux_send_enter() time.sleep(0.5) tmux_send("claude --continue --dangerously-skip-permissions") tmux_send_enter() self.reply(chat_id, "Continuing most recent...") def handle_message(self, update): msg = update.get("message", {}) text, chat_id, msg_id = msg.get("text", ""), msg.get("chat", {}).get("id"), msg.get("message_id") if not text or not chat_id: return with open(CHAT_ID_FILE, "w") as f: f.write(str(chat_id)) if text.startswith("/"): cmd = text.split()[0].lower() if cmd == "/status": status = "running" if tmux_exists() else "not found" self.reply(chat_id, f"tmux '{TMUX_SESSION}': {status}") return if cmd == "/stop": if tmux_exists(): tmux_send_escape() if os.path.exists(PENDING_FILE): os.remove(PENDING_FILE) self.reply(chat_id, "Interrupted") return if cmd == "/clear": if not tmux_exists(): self.reply(chat_id, "tmux not found") return tmux_send_escape() time.sleep(0.2) tmux_send("/clear") tmux_send_enter() self.reply(chat_id, "Cleared") return if cmd == "/continue_": if not tmux_exists(): self.reply(chat_id, "tmux not found") return tmux_send_escape() time.sleep(0.2) tmux_send("/exit") tmux_send_enter() time.sleep(0.5) tmux_send("claude --continue --dangerously-skip-permissions") tmux_send_enter() self.reply(chat_id, "Continuing...") return if cmd == "/loop": if not tmux_exists(): self.reply(chat_id, "tmux not found") return parts = text.split(maxsplit=1) if len(parts) < 2: self.reply(chat_id, "Usage: /loop ") return prompt = parts[1].replace('"', '\\"') full = f'{prompt} Output DONE when complete.' with open(PENDING_FILE, "w") as f: f.write(str(int(time.time()))) threading.Thread(target=send_typing_loop, args=(chat_id,), daemon=True).start() tmux_send(f'/ralph-loop:ralph-loop "{full}" --max-iterations 5 --completion-promise "DONE"') time.sleep(0.3) tmux_send_enter() self.reply(chat_id, "Ralph Loop started (max 5 iterations)") return if cmd == "/resume": sessions = get_recent_sessions() if not sessions: self.reply(chat_id, "No sessions") return kb = [[{"text": "Continue most recent", "callback_data": "continue_recent"}]] for s in sessions: sid = get_session_id(s.get("project", "")) if sid: kb.append([{"text": s.get("display", "?")[:40] + "...", "callback_data": f"resume:{sid}"}]) telegram_api("sendMessage", {"chat_id": chat_id, "text": "Select session:", "reply_markup": {"inline_keyboard": kb}}) return if cmd in BLOCKED_COMMANDS: self.reply(chat_id, f"'{cmd}' not supported (interactive)") return # Regular message print(f"[{chat_id}] {text[:50]}...") with open(PENDING_FILE, "w") as f: f.write(str(int(time.time()))) if msg_id: telegram_api("setMessageReaction", {"chat_id": chat_id, "message_id": msg_id, "reaction": [{"type": "emoji", "emoji": "\u2705"}]}) if not tmux_exists(): self.reply(chat_id, "tmux not found") os.remove(PENDING_FILE) return threading.Thread(target=send_typing_loop, args=(chat_id,), daemon=True).start() tmux_send(text) tmux_send_enter() def reply(self, chat_id, text): telegram_api("sendMessage", {"chat_id": chat_id, "text": text}) def log_message(self, *args): pass def main(): if not BOT_TOKEN: print("Error: TELEGRAM_BOT_TOKEN not set") return setup_bot_commands() print(f"Bridge on :{PORT} | tmux: {TMUX_SESSION}") try: HTTPServer(("0.0.0.0", PORT), Handler).serve_forever() except KeyboardInterrupt: print("\nStopped") if __name__ == "__main__": main() ================================================ FILE: hooks/send-to-telegram.sh ================================================ #!/bin/bash # Claude Code Stop hook - sends response back to Telegram # Install: copy to ~/.claude/hooks/ and add to ~/.claude/settings.json TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-YOUR_BOT_TOKEN_HERE}" INPUT=$(cat) TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path') CHAT_ID_FILE=~/.claude/telegram_chat_id PENDING_FILE=~/.claude/telegram_pending # Only respond to Telegram-initiated messages [ ! -f "$PENDING_FILE" ] && exit 0 PENDING_TIME=$(cat "$PENDING_FILE" 2>/dev/null) NOW=$(date +%s) [ -z "$PENDING_TIME" ] || [ $((NOW - PENDING_TIME)) -gt 600 ] && rm -f "$PENDING_FILE" && exit 0 [ ! -f "$CHAT_ID_FILE" ] || [ ! -f "$TRANSCRIPT_PATH" ] && rm -f "$PENDING_FILE" && exit 0 CHAT_ID=$(cat "$CHAT_ID_FILE") LAST_USER_LINE=$(grep -n '"type":"user"' "$TRANSCRIPT_PATH" | tail -1 | cut -d: -f1) [ -z "$LAST_USER_LINE" ] && rm -f "$PENDING_FILE" && exit 0 TMPFILE=$(mktemp) tail -n "+$LAST_USER_LINE" "$TRANSCRIPT_PATH" | \ grep '"type":"assistant"' | \ jq -rs '[.[].message.content[] | select(.type == "text") | .text] | join("\n\n")' > "$TMPFILE" 2>/dev/null [ ! -s "$TMPFILE" ] && rm -f "$TMPFILE" "$PENDING_FILE" && exit 0 python3 - "$TMPFILE" "$CHAT_ID" "$TELEGRAM_BOT_TOKEN" << 'PYEOF' import sys, re, json, urllib.request tmpfile, chat_id, token = sys.argv[1], sys.argv[2], sys.argv[3] with open(tmpfile) as f: text = f.read().strip() if not text or text == "null": sys.exit(0) if len(text) > 4000: text = text[:4000] + "\n..." def esc(s): return s.replace('&', '&').replace('<', '<').replace('>', '>') blocks, inlines = [], [] text = re.sub(r'```(\w*)\n?(.*?)```', lambda m: (blocks.append((m.group(1) or '', m.group(2))), f"\x00B{len(blocks)-1}\x00")[1], text, flags=re.DOTALL) text = re.sub(r'`([^`\n]+)`', lambda m: (inlines.append(m.group(1)), f"\x00I{len(inlines)-1}\x00")[1], text) text = esc(text) text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) text = re.sub(r'(?\1', text) for i, (lang, code) in enumerate(blocks): text = text.replace(f"\x00B{i}\x00", f'
{esc(code.strip())}
' if lang else f'
{esc(code.strip())}
') for i, code in enumerate(inlines): text = text.replace(f"\x00I{i}\x00", f'{esc(code)}') def send(txt, mode=None): data = {"chat_id": chat_id, "text": txt} if mode: data["parse_mode"] = mode try: req = urllib.request.Request(f"https://api.telegram.org/bot{token}/sendMessage", json.dumps(data).encode(), {"Content-Type": "application/json"}) return json.loads(urllib.request.urlopen(req, timeout=10).read()).get("ok") except: return False if not send(text, "HTML"): with open(tmpfile) as f: send(f.read()[:4096]) PYEOF rm -f "$TMPFILE" "$PENDING_FILE" exit 0 ================================================ FILE: pyproject.toml ================================================ [project] name = "claudecode-telegram" version = "0.1.0" description = "Telegram bridge for Claude Code" requires-python = ">=3.10" dependencies = [] [project.scripts] claudecode-telegram = "bridge:main"