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

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 <prompt>` | 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 <prompt>"},
{"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 <prompt>")
return
prompt = parts[1].replace('"', '\\"')
full = f'{prompt} Output <promise>DONE</promise> 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'<b>\1</b>', text)
text = re.sub(r'(?<!\*)\*([^*]+)\*(?!\*)', r'<i>\1</i>', text)
for i, (lang, code) in enumerate(blocks):
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>')
for i, code in enumerate(inlines):
text = text.replace(f"\x00I{i}\x00", f'<code>{esc(code)}</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"
gitextract_7klvte_z/ ├── .gitignore ├── README.md ├── bridge.py ├── hooks/ │ └── send-to-telegram.sh └── pyproject.toml
SYMBOL INDEX (17 symbols across 1 files)
FILE: bridge.py
function telegram_api (line 36) | def telegram_api(method, data):
function setup_bot_commands (line 52) | def setup_bot_commands():
function send_typing_loop (line 58) | def send_typing_loop(chat_id):
function tmux_exists (line 64) | def tmux_exists():
function tmux_send (line 68) | def tmux_send(text, literal=True):
function tmux_send_enter (line 76) | def tmux_send_enter():
function tmux_send_escape (line 80) | def tmux_send_escape():
function get_recent_sessions (line 84) | def get_recent_sessions(limit=5):
function get_session_id (line 101) | def get_session_id(project_path):
class Handler (line 112) | class Handler(BaseHTTPRequestHandler):
method do_POST (line 113) | def do_POST(self):
method do_GET (line 127) | def do_GET(self):
method handle_callback (line 132) | def handle_callback(self, cb):
method handle_message (line 162) | def handle_message(self, update):
method reply (line 265) | def reply(self, chat_id, text):
method log_message (line 268) | def log_message(self, *args):
function main (line 272) | def main():
Condensed preview — 5 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (17K chars).
[
{
"path": ".gitignore",
"chars": 43,
"preview": "__pycache__/\n*.pyc\n.venv/\n*.egg-info/\n.env\n"
},
{
"path": "README.md",
"chars": 2728,
"preview": "# claudecode-telegram\n\n\n\nTelegram bot bridge for Claude Code. Send messages from Telegram, get response"
},
{
"path": "bridge.py",
"chars": 9810,
"preview": "#!/usr/bin/env python3\n\"\"\"Claude Code <-> Telegram Bridge\"\"\"\n\nimport os\nimport json\nimport subprocess\nimport threading\ni"
},
{
"path": "hooks/send-to-telegram.sh",
"chars": 2822,
"preview": "#!/bin/bash\n# Claude Code Stop hook - sends response back to Telegram\n# Install: copy to ~/.claude/hooks/ and add to ~/."
},
{
"path": "pyproject.toml",
"chars": 205,
"preview": "[project]\nname = \"claudecode-telegram\"\nversion = \"0.1.0\"\ndescription = \"Telegram bridge for Claude Code\"\nrequires-python"
}
]
About this extraction
This page contains the full source code of the hanxiao/claudecode-telegram GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 5 files (15.2 KB), approximately 4.1k tokens, and a symbol index with 17 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.