diff --git a/CLAUDE.md b/CLAUDE.md index 3948b09..9d88904 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,7 @@ telecode/ - `TELEGRAM_BOT_TOKEN` - Required for Telegram API - `TELEGRAM_TUNNEL_URL` - Public webhook URL - `TELECODE_ENGINE` - Default engine: `claude` or `codex` +- `TELECODE_CODEX_PERSIST_SESSION` - Persist Codex sessions across messages (`0`=disabled, `1`=enabled, default: `1`) - `TELECODE_HOST` - Server bind host (default: `0.0.0.0`) - `TELECODE_PORT` - Server port (default: `8000`) - `TELECODE_ALLOWED_USERS` - Access control (comma-separated IDs/@usernames) diff --git a/README.md b/README.md index 977136d..242b35c 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ TELECODE_VERBOSE=1 | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | *Required* | | `TELEGRAM_TUNNEL_URL` | Public webhook URL | Auto via ngrok | | `TELECODE_ENGINE` | Default engine: `claude` or `codex` | `claude` | +| `TELECODE_CODEX_PERSIST_SESSION` | Persist Codex sessions across messages | `1` | | `TELECODE_ENABLE_MCP` | Enable MCP server | `0` | | `TELECODE_ALLOWED_USERS` | User whitelist (IDs/@usernames) | *(empty = all)* | | `TELECODE_VERBOSE` | Enable verbose logging | `0` | diff --git a/telecode/claude.py b/telecode/claude.py index 2c10340..eda6453 100644 --- a/telecode/claude.py +++ b/telecode/claude.py @@ -58,7 +58,8 @@ def _retry_resume(cmd: list[str], timeout_s: Optional[int]) -> str: raise RuntimeError("Claude failed: Session ID is already in use.") def _build_cmd(args: list[str], prompt: str, image_paths: Optional[list[str]]) -> list[str]: - cmd = ["claude"] + args + ["--print"] + binary = "claude" + cmd = [binary] + args + ["--print"] if image_paths: dirs = sorted({os.path.dirname(path) or "." for path in image_paths}) for directory in dirs: @@ -75,9 +76,12 @@ def _run_claude(cmd: list[str], timeout_s: Optional[int]) -> str: capture_output=True, timeout=timeout_s, check=True, + encoding="utf-8", ) except subprocess.TimeoutExpired as exc: raise RuntimeError(f"Claude timed out after {timeout_s}s") from exc + except FileNotFoundError as exc: + raise RuntimeError("The 'claude' command was not found. Please ensure 'claude-code' is installed (npm install -g @anthropic-ai/claude-code).") from exc except subprocess.CalledProcessError as exc: stderr = (exc.stderr or "").strip() stdout = (exc.stdout or "").strip() diff --git a/telecode/cli.py b/telecode/cli.py index deb2142..78b61ee 100644 --- a/telecode/cli.py +++ b/telecode/cli.py @@ -298,6 +298,7 @@ def _ensure_bot_commands(bot_token: str) -> None: {"command": "claude", "description": "Use Claude for this chat"}, {"command": "codex", "description": "Use Codex for this chat"}, {"command": "cli", "description": "Run a shell command: /cli "}, + {"command": "new", "description": "/clear or /new - Start fresh conversation"}, {"command": "tts_on", "description": "Enable TTS audio responses"}, {"command": "tts_off", "description": "Disable TTS audio responses"}, ] diff --git a/telecode/codex.py b/telecode/codex.py index 3ae294a..3cb930b 100644 --- a/telecode/codex.py +++ b/telecode/codex.py @@ -1,4 +1,5 @@ import json +import os import re import subprocess from typing import Optional @@ -33,7 +34,7 @@ def _build_cmd( session_id: Optional[str], image_paths: list[str], ) -> list[str]: - base = ["codex", "exec"] + base = ["codex.cmd" if os.name == "nt" else "codex", "exec"] for path in image_paths: base.extend(["--image", path]) if session_id: @@ -57,9 +58,12 @@ def _run_codex( input=prompt_input, timeout=timeout_s, check=True, + encoding="utf-8", ) except subprocess.TimeoutExpired as exc: raise RuntimeError(f"Codex timed out after {timeout_s}s") from exc + except FileNotFoundError as exc: + raise RuntimeError("The 'codex' command was not found. Please ensure it is installed and in your PATH.") from exc except subprocess.CalledProcessError as exc: stderr = (exc.stderr or "").strip() stdout = (exc.stdout or "").strip() diff --git a/telecode/server.py b/telecode/server.py index 2643ae0..ef71d6e 100644 --- a/telecode/server.py +++ b/telecode/server.py @@ -102,6 +102,12 @@ async def dispatch(self, request: Request, call_next): print(f"Debug: TELECODE_VERBOSE = {verbose_env}") print(f"Debug: Verbose logging = {'ENABLED' if verbose_env in {'1', 'true', 'yes', 'on', 'verbose', 'debug'} else 'DISABLED'}") +# Debug: Print Codex session persistence setting +codex_persist = os.getenv("TELECODE_CODEX_PERSIST_SESSION", "1") +codex_persist_enabled = codex_persist not in {"0", "false", "no", "off", "disable", "disabled"} +print(f"Debug: TELECODE_CODEX_PERSIST_SESSION = {codex_persist}") +print(f"Debug: Codex session persistence = {'ENABLED' if codex_persist_enabled else 'DISABLED'}") + # Add CORS middleware for MCP clients app.add_middleware( CORSMiddleware, @@ -212,6 +218,7 @@ def _ensure_bot_commands(telegram: TelegramConfig) -> None: {"command": "claude", "description": "Use Claude for this chat"}, {"command": "codex", "description": "Use Codex for this chat"}, {"command": "cli", "description": "Run a shell command: /cli "}, + {"command": "new", "description": "/clear or /new - Start fresh conversation"}, {"command": "tts_on", "description": "Enable TTS audio responses"}, {"command": "tts_off", "description": "Disable TTS audio responses"}, ] @@ -310,6 +317,18 @@ def _handle_engine_command( ) return True + if command in {"/new", "/clear"}: + _log(f"IN command chat_id={chat_id} command={command}") + engine = _get_engine_for_chat(chat_id, default_engine, sessions_file) + _clear_session_for_engine(engine, sessions_file) + _send_message( + telegram, + chat_id, + f"New conversation started with {engine}. Previous session cleared.", + reply_to_message_id=message_id, + ) + return True + return False @@ -723,7 +742,7 @@ def _handle_prompt( chat_id, sessions_file, ) - _send_message(telegram, chat_id, answer.strip(), reply_to_message_id=message_id) + _send_message(telegram, chat_id, answer.strip(), reply_to_message_id=message_id, parse_mode="Markdown") _maybe_send_tts(answer, chat_id, message_id, telegram) @@ -749,14 +768,17 @@ def transcribe_with_whisper(audio_bytes: bytes) -> str: def _get_or_create_session(chat_id: int, sessions_file: str, engine: str) -> Optional[str]: + # Check if Codex session persistence is disabled (default: enabled) + if engine == "codex": + persist_codex = os.getenv("TELECODE_CODEX_PERSIST_SESSION", "1").strip() + if persist_codex in {"0", "false", "no", "off", "disable", "disabled"}: + return None + sessions = _load_sessions(sessions_file) session_id = sessions.get(engine) if session_id: return session_id - if engine == "codex": - return None - session_id = str(uuid.uuid4()) sessions[engine] = session_id _save_sessions(sessions_file, sessions) @@ -818,6 +840,15 @@ def _save_sessions(sessions_file: str, sessions: dict[str, Optional[str]]) -> No _save_sessions_to_kv(sessions_file, sessions) +def _clear_session_for_engine(engine: str, sessions_file: str) -> None: + """Clear the session ID for the specified engine.""" + sessions = _load_sessions(sessions_file) + sessions[engine] = None + _save_sessions(sessions_file, sessions) + _log(f"Cleared {engine} session") + + + def _get_session_lock(session_id: str) -> threading.Lock: with _SESSION_LOCKS_GUARD: lock = _SESSION_LOCKS.get(session_id) @@ -1145,12 +1176,7 @@ def _ensure_project_temp_dir() -> str: def _format_codex_prompt(prompt: str) -> str: - return ( - "You are responding to a Telegram user.\n" - "Reply with one concise paragraph.\n\n" - f"User said:\n{prompt}\n\n" - "Reply concisely." - ) + return prompt def _format_prompt_with_images(prompt: str, image_paths: list[str]) -> str: @@ -1242,6 +1268,7 @@ def _send_message( text: str, reply_to_message_id: int | None = None, reply_markup: dict | None = None, + parse_mode: str | None = None, ) -> int: _log(f"OUT message chat_id={chat_id} text={text}") if reply_markup is not None: @@ -1252,6 +1279,7 @@ def _send_message( text, reply_to_message_id=reply_to_message_id, reply_markup=reply_markup, + parse_mode=parse_mode, ) @@ -1264,6 +1292,7 @@ def _run_cli_command(cmd: str, timeout_s: int = 30) -> str: capture_output=True, timeout=timeout_s, cwd=os.getcwd(), + encoding="utf-8", ) except subprocess.TimeoutExpired: return f"Command timed out after {timeout_s}s." diff --git a/telecode/telegram.py b/telecode/telegram.py index 891caf1..47d00e9 100644 --- a/telecode/telegram.py +++ b/telecode/telegram.py @@ -25,14 +25,28 @@ def telegram_send_message( text: str, reply_to_message_id: int | None = None, reply_markup: dict[str, Any] | None = None, + parse_mode: str | None = None, ) -> int: payload: dict[str, Any] = {"chat_id": chat_id, "text": text} if reply_to_message_id is not None: payload["reply_to_message_id"] = reply_to_message_id if reply_markup is not None: payload["reply_markup"] = reply_markup + if parse_mode is not None: + payload["parse_mode"] = parse_mode + + + try: + data = _post_json(f"{config.api_base}/sendMessage", payload) + except RuntimeError as exc: + if parse_mode and "can't parse entities" in str(exc): + # Fallback to plain text if parsing fails + if "parse_mode" in payload: + del payload["parse_mode"] + data = _post_json(f"{config.api_base}/sendMessage", payload) + else: + raise - data = _post_json(f"{config.api_base}/sendMessage", payload) return data["result"]["message_id"]