ayoub5550/ClaudeCode
0
1#!/usr/bin/env python32"""3Telegram bot wrapping the REAL Claude Code CLI.4- Streams output to Telegram in real-time by editing messages5- Merges stdout+stderr to capture everything6- Robust timeout handling7"""8 9import os, asyncio, json, subprocess, logging, threading, time10from flask import Flask, jsonify11from telegram import Update, BotCommand, Bot12from telegram.ext import Application, CommandHandler, MessageHandler, filters13from telegram.constants import ChatAction14from telegram.request import HTTPXRequest15import httpx16 17# ──── Config ────18TELEGRAM_BOT_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]19ALLOWED_USERS = [u.strip() for u in os.environ.get("TELEGRAM_ALLOWED_USERS", "").split(",") if u.strip()]20PROXY_URL = os.environ.get("PROXY_URL", "")21PROXY_SECRET = os.environ.get("PROXY_SECRET", "")22TELEGRAM_BASE_URL = f"{PROXY_URL}/bot" if PROXY_URL else "https://api.telegram.org/bot"23MODEL = os.environ.get("MODEL", "z-ai/glm-5.1")24START_TIME = time.time()25 26# Claude Code environment27CLAUDE_ENV = {28 **os.environ,29 "ANTHROPIC_BASE_URL": "http://localhost:4000",30 "ANTHROPIC_API_KEY": "sk-ant-proxy-key-for-local-translation",31 "ANTHROPIC_MODEL": "claude-sonnet-4-20250514",32 "DISABLE_PROMPT_CACHING": "true",33 "CLAUDE_CODE_SKIP_OOBE": "1",34 "HOME": "/home/user",35 "PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),36 "TERM": "xterm-256color",37 "LANG": "en_US.UTF-8",38}39 40logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")41log = logging.getLogger("claude-bot")42 43# ──── Flask health ────44flask_app = Flask(__name__)45 46@flask_app.route("/")47@flask_app.route("/health")48def health():49 uptime = int(time.time() - START_TIME)50 m, s = divmod(uptime, 60)51 h, m = divmod(m, 60)52 try:53 r = httpx.get("http://localhost:4000/health", timeout=5)54 proxy_ok = r.status_code == 20055 except:56 proxy_ok = False57 try:58 v = subprocess.run(["claude", "--version"], capture_output=True, text=True, timeout=10, env=CLAUDE_ENV)59 claude_version = v.stdout.strip() or v.stderr.strip() or "?"60 except:61 claude_version = "?"62 return jsonify({63 "status": "ok",64 "engine": "Claude Code CLI (real)",65 "claude_version": claude_version,66 "model_backend": MODEL,67 "proxy": "ok" if proxy_ok else "down",68 "uptime": f"{h}h{m}m{s}s",69 })70 71 72# ──── Claude Code runner with streaming ────73async def run_claude_streaming(prompt: str, status_msg, chat) -> str:74 """Run claude -p and stream output to Telegram by editing the status message."""75 cmd = [76 "claude", "-p", prompt,77 "--dangerously-skip-permissions",78 "--output-format", "text",79 ]80 81 log.info(f"Running: claude -p '{prompt[:80]}...'")82 83 proc = await asyncio.create_subprocess_exec(84 *cmd,85 stdout=asyncio.subprocess.PIPE,86 stderr=asyncio.subprocess.STDOUT, # Merge stderr → stdout (avoids deadlocks)87 cwd="/home/user/workspace",88 env=CLAUDE_ENV,89 )90 91 accumulated = ""92 last_edit = time.time()93 last_typing = time.time()94 95 try:96 while True:97 try:98 chunk = await asyncio.wait_for(proc.stdout.read(1024), timeout=4)99 except asyncio.TimeoutError:100 # Keep typing indicator alive101 now = time.time()102 if now - last_typing > 3:103 try:104 await chat.send_action(ChatAction.TYPING)105 except:106 pass107 last_typing = now108 109 # Check if process died110 if proc.returncode is not None:111 break112 continue113 114 if not chunk:115 break116 117 text = chunk.decode("utf-8", errors="replace")118 accumulated += text119 120 # Stream to Telegram: edit message every 3 seconds121 now = time.time()122 if now - last_edit > 3 and accumulated.strip():123 display = accumulated.strip()124 # Take last 3900 chars to fit Telegram limit125 if len(display) > 3900:126 display = "...\n" + display[-3900:]127 try:128 await status_msg.edit_text(display[:4096])129 except Exception as e:130 log.debug(f"Edit failed: {e}")131 last_edit = now132 133 except asyncio.CancelledError:134 proc.kill()135 raise136 except Exception as e:137 log.error(f"Stream read error: {e}")138 139 # Wait for process to finish140 try:141 await asyncio.wait_for(proc.wait(), timeout=10)142 except asyncio.TimeoutError:143 proc.kill()144 145 exit_code = proc.returncode146 log.info(f"Claude Code exited with code {exit_code}, output length: {len(accumulated)}")147 148 result = accumulated.strip()149 if result:150 return result151 else:152 return f"❌ No output (exit code: {exit_code})"153 154 155# ──── Telegram Handlers ────156async def cmd_start(update: Update, context):157 uid = str(update.effective_user.id)158 if ALLOWED_USERS and uid not in ALLOWED_USERS:159 await update.message.reply_text("⛔")160 return161 try:162 v = subprocess.run(["claude", "--version"], capture_output=True, text=True, timeout=10, env=CLAUDE_ENV)163 version = v.stdout.strip() or "?"164 except:165 version = "?"166 await update.message.reply_text(167 "🤖 *Claude Code* — Real CLI Agent\n\n"168 f"📦 Version: `{version}`\n"169 f"🧠 Backend: `{MODEL}`\n"170 "💻 Workspace: `/home/user/workspace`\n"171 "🔧 Permissions: Full (bash, read, write, edit)\n\n"172 "*Commands:*\n"173 "/start — New session\n"174 "/clear — Clear workspace & session\n"175 "/files — List workspace files\n"176 "/bash `cmd` — Quick bash command\n\n"177 "Send any coding task! 🚀",178 parse_mode="Markdown",179 )180 181 182async def cmd_clear(update: Update, context):183 uid = str(update.effective_user.id)184 if ALLOWED_USERS and uid not in ALLOWED_USERS:185 return186 subprocess.run(["bash", "-c", "rm -rf /home/user/workspace/* /home/user/.claude/projects/*"],187 capture_output=True, cwd="/home/user")188 await update.message.reply_text("🧹 Workspace and session cleared.")189 190 191async def cmd_files(update: Update, context):192 uid = str(update.effective_user.id)193 if ALLOWED_USERS and uid not in ALLOWED_USERS:194 return195 r = subprocess.run(196 ["bash", "-c", "find /home/user/workspace -type f 2>/dev/null | head -50 || echo '(empty)'"],197 capture_output=True, text=True, timeout=10198 )199 files = r.stdout.strip() or "(empty)"200 await update.message.reply_text(f"📁 Workspace:\n```\n{files}\n```", parse_mode="Markdown")201 202 203async def cmd_bash(update: Update, context):204 uid = str(update.effective_user.id)205 if ALLOWED_USERS and uid not in ALLOWED_USERS:206 return207 cmd = update.message.text.replace("/bash", "", 1).strip()208 if not cmd:209 await update.message.reply_text("Usage: `/bash ls -la`", parse_mode="Markdown")210 return211 await update.message.chat.send_action(ChatAction.TYPING)212 try:213 r = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True,214 timeout=60, cwd="/home/user/workspace")215 output = (r.stdout + r.stderr).strip() or "(no output)"216 await send_long(update, f"```\n{output[:4000]}\n```")217 except subprocess.TimeoutExpired:218 await update.message.reply_text("⏰ Timed out (60s).")219 220 221async def handle_message(update: Update, context):222 uid = str(update.effective_user.id)223 if ALLOWED_USERS and uid not in ALLOWED_USERS:224 await update.message.reply_text("⛔")225 return226 227 text = update.message.text228 if not text:229 return230 231 # Send initial streaming message232 status_msg = await update.message.reply_text("⏳ Claude Code is working...")233 234 try:235 # Run with streaming (edits the status_msg in real-time)236 result = await asyncio.wait_for(237 run_claude_streaming(text, status_msg, update.message.chat),238 timeout=330, # 5.5 min total timeout239 )240 241 # Send final result242 if len(result) <= 4096:243 try:244 await status_msg.edit_text(result, parse_mode="Markdown")245 except:246 try:247 await status_msg.edit_text(result)248 except:249 await send_long(update, result)250 else:251 # Delete status msg and send in chunks252 try:253 await status_msg.delete()254 except:255 pass256 await send_long(update, result)257 258 except asyncio.TimeoutError:259 await status_msg.edit_text("⏰ Timed out after 5 minutes.")260 except Exception as e:261 log.exception("Error in handle_message")262 try:263 await status_msg.edit_text(f"❌ Error: {e}")264 except:265 await update.message.reply_text(f"❌ Error: {e}")266 267 268async def send_long(update: Update, text: str):269 """Send long text in chunks."""270 if not text:271 text = "✅ Done."272 chunks = [text[i:i + 4096] for i in range(0, len(text), 4096)]273 for chunk in chunks[:15]:274 try:275 await update.message.reply_text(chunk, parse_mode="Markdown")276 except:277 try:278 await update.message.reply_text(chunk)279 except Exception as e:280 log.error(f"Send failed: {e}")281 282 283# ──── Main ────284def run_flask():285 port = int(os.environ.get("FLASK_PORT", "7860"))286 flask_app.run(host="0.0.0.0", port=port, use_reloader=False)287 288 289async def main():290 log.info("Starting Claude Code Telegram Bot")291 292 flask_thread = threading.Thread(target=run_flask, daemon=True)293 flask_thread.start()294 295 request = HTTPXRequest(296 connection_pool_size=8,297 connect_timeout=30.0,298 read_timeout=60.0,299 write_timeout=30.0,300 )301 bot = Bot(302 token=TELEGRAM_BOT_TOKEN,303 base_url=TELEGRAM_BASE_URL,304 request=request,305 )306 application = Application.builder().bot(bot).build()307 308 application.add_handler(CommandHandler("start", cmd_start))309 application.add_handler(CommandHandler("clear", cmd_clear))310 application.add_handler(CommandHandler("files", cmd_files))311 application.add_handler(CommandHandler("bash", cmd_bash))312 application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))313 314 await application.initialize()315 await application.start()316 await application.updater.start_polling(drop_pending_updates=True)317 318 log.info("✅ Bot started — polling Telegram")319 320 try:321 await bot.set_my_commands([322 BotCommand("start", "New session"),323 BotCommand("clear", "Clear workspace"),324 BotCommand("files", "List files"),325 BotCommand("bash", "Run bash command"),326 ])327 except:328 pass329 330 stop = asyncio.Event()331 await stop.wait()332 333 334if __name__ == "__main__":335 asyncio.run(main())336 