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