From ca0a3cd334834453abceec866f00f6d8dd66b48f Mon Sep 17 00:00:00 2001 From: mathieu hamel Date: Thu, 8 Jan 2026 13:31:03 -0500 Subject: [PATCH 1/3] Fix Windows compatibility, Telegram encoding, and add session control This commit addresses three critical issues: 1. **Windows Compatibility**: Added explicit UTF-8 encoding to subprocess calls in claude.py and codex.py to ensure proper execution on Windows systems where the default encoding may differ. 2. **Telegram Special Characters**: Fixed encoding issues with special characters (accents, emojis, etc.) in Telegram messages by: - Adding UTF-8 encoding to subprocess calls - Implementing fallback mechanism in telegram.py to retry without parse_mode when markdown parsing fails with special characters 3. **Codex Session Control**: Added TELECODE_CODEX_PERSIST_SESSION configuration option to control whether Codex sessions are persisted across messages: - Default: enabled (1) - maintains backward compatibility - Set to 0 to disable session persistence (fresh context each message) - Added startup debug output to show current configuration - Updated README.md and CLAUDE.md documentation Breaking Changes: None - all changes are backward compatible. Fixes issues with non-ASCII characters and provides better control over AI session management. --- CLAUDE.md | 1 + README.md | 1 + telecode/claude.py | 6 +++++- telecode/codex.py | 6 +++++- telecode/server.py | 27 +++++++++++++++++---------- telecode/telegram.py | 16 +++++++++++++++- 6 files changed, 44 insertions(+), 13 deletions(-) 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..931b9ca 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" if os.name == "nt" else "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/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..0a2fad7 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, @@ -723,7 +729,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 +755,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) @@ -1145,12 +1154,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 +1246,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 +1257,7 @@ def _send_message( text, reply_to_message_id=reply_to_message_id, reply_markup=reply_markup, + parse_mode=parse_mode, ) @@ -1264,6 +1270,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"] From 4f4cf6e5fb67eff0fcdc84840790cdd9e25c3d2a Mon Sep 17 00:00:00 2001 From: mathieu hamel Date: Thu, 8 Jan 2026 16:01:35 -0500 Subject: [PATCH 2/3] fix: use 'claude' binary name for cross-platform compatibility The previous implementation used 'claude.cmd' on Windows, but the actual installed binary is 'claude.exe'. Simplifying to just 'claude' works across all platforms since Windows automatically resolves to .exe files in PATH. --- telecode/claude.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telecode/claude.py b/telecode/claude.py index 931b9ca..eda6453 100644 --- a/telecode/claude.py +++ b/telecode/claude.py @@ -58,7 +58,7 @@ 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]: - binary = "claude.cmd" if os.name == "nt" else "claude" + binary = "claude" cmd = [binary] + args + ["--print"] if image_paths: dirs = sorted({os.path.dirname(path) or "." for path in image_paths}) From 6f470368bacbbc02a28106968e03f9149d576e10 Mon Sep 17 00:00:00 2001 From: mathieu hamel Date: Thu, 8 Jan 2026 16:07:01 -0500 Subject: [PATCH 3/3] feat: add /clear and /new commands to reset conversation sessions - Added support for both /new (Codex) and /clear (Claude) commands - Both commands clear the current engine's session ID - Single menu entry shows '/clear or /new' to indicate both work - Provides user feedback confirming session reset --- telecode/cli.py | 1 + telecode/server.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) 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/server.py b/telecode/server.py index 0a2fad7..ef71d6e 100644 --- a/telecode/server.py +++ b/telecode/server.py @@ -218,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"}, ] @@ -316,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 @@ -827,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)