diff --git a/codex/codex_telegram_bridge/bridge_common.py b/codex/codex_telegram_bridge/bridge_common.py index 2e0256f..edbd13c 100644 --- a/codex/codex_telegram_bridge/bridge_common.py +++ b/codex/codex_telegram_bridge/bridge_common.py @@ -7,16 +7,48 @@ import time import urllib.error import urllib.request from dataclasses import dataclass +from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Tuple TELEGRAM_HARD_LIMIT = 4096 DEFAULT_CHUNK_LEN = 3500 # leave room for formatting / safety +TELEGRAM_CONFIG_PATH = Path.home() / ".codex" / "telegram.toml" def _now_unix() -> int: return int(time.time()) +def _load_toml(path: Path) -> Dict[str, Any]: + if not path.exists(): + return {} + try: + import tomllib # type: ignore[attr-defined] + except ModuleNotFoundError: + try: + import tomli as tomllib # type: ignore[import-not-found] + except ModuleNotFoundError as e: + raise RuntimeError( + f"TOML config found at {path} but tomllib/tomli is unavailable. " + "Use Python 3.11+ or install tomli." + ) from e + return tomllib.loads(path.read_text(encoding="utf-8")) + + +def load_telegram_config(path: Optional[str] = None) -> Dict[str, Any]: + cfg_path = Path(path) if path else TELEGRAM_CONFIG_PATH + return _load_toml(cfg_path) + + +def config_get(config: Dict[str, Any], key: str) -> Any: + if key in config: + return config[key] + nested = config.get("telegram") + if isinstance(nested, dict) and key in nested: + return nested[key] + return None + + def chunk_text(text: str, limit: int = DEFAULT_CHUNK_LEN) -> List[str]: """ Telegram hard limit is 4096 chars. Chunk at newlines when possible. @@ -237,3 +269,25 @@ def parse_allowed_chat_ids(env_value: str) -> Optional[set[int]]: continue out.add(int(part)) return out + + +def parse_chat_id_list(value: Any) -> Optional[set[int]]: + if value is None: + return None + if isinstance(value, str): + return parse_allowed_chat_ids(value) + if isinstance(value, int): + return {value} + if isinstance(value, (list, tuple, set)): + out: set[int] = set() + for item in value: + if item is None: + continue + if isinstance(item, str): + if not item.strip(): + continue + out.add(int(item)) + else: + out.add(int(item)) + return out or None + return None diff --git a/codex/codex_telegram_bridge/exec_bridge.py b/codex/codex_telegram_bridge/exec_bridge.py index 818305d..0138305 100644 --- a/codex/codex_telegram_bridge/exec_bridge.py +++ b/codex/codex_telegram_bridge/exec_bridge.py @@ -9,7 +9,14 @@ import time from concurrent.futures import ThreadPoolExecutor from typing import Any, Dict, Optional, Tuple -from bridge_common import TelegramClient, RouteStore, parse_allowed_chat_ids +from bridge_common import ( + TelegramClient, + RouteStore, + config_get, + load_telegram_config, + parse_allowed_chat_ids, + parse_chat_id_list, +) # -------------------- Codex runner -------------------- @@ -138,17 +145,32 @@ class CodexExecRunner: def main() -> None: - token = os.environ.get("TELEGRAM_BOT_TOKEN", "") - db_path = os.environ.get("BRIDGE_DB", "./bridge_routes.sqlite3") + config = load_telegram_config() + token = os.environ.get("TELEGRAM_BOT_TOKEN") or config_get(config, "bot_token") or "" + db_path = os.environ.get("BRIDGE_DB") or config_get(config, "bridge_db") or "./bridge_routes.sqlite3" allowed = parse_allowed_chat_ids(os.environ.get("ALLOWED_CHAT_IDS", "")) - startup_ids = parse_allowed_chat_ids(os.environ.get("STARTUP_CHAT_IDS", "")) or allowed - startup_msg = os.environ.get("STARTUP_MESSAGE", "✅ exec_bridge started (codex exec).") + if allowed is None: + allowed = parse_chat_id_list(config_get(config, "allowed_chat_ids")) + startup_ids = parse_allowed_chat_ids(os.environ.get("STARTUP_CHAT_IDS", "")) + if startup_ids is None: + startup_ids = parse_chat_id_list(config_get(config, "startup_chat_ids")) + if startup_ids is None: + startup_ids = allowed + startup_msg = os.environ.get("STARTUP_MESSAGE") or config_get( + config, "startup_message" + ) or "✅ exec_bridge started (codex exec)." startup_pwd = os.getcwd() startup_msg = f"{startup_msg}\nPWD: {startup_pwd}" - codex_cmd = os.environ.get("CODEX_CMD", "codex") - workspace = os.environ.get("CODEX_WORKSPACE") # optional - extra_args = shlex.split(os.environ.get("CODEX_EXEC_ARGS", "")) # e.g. "--full-auto --search" + codex_cmd = os.environ.get("CODEX_CMD") or config_get(config, "codex_cmd") or "codex" + workspace = os.environ.get("CODEX_WORKSPACE") or config_get(config, "codex_workspace") + raw_exec_args = os.environ.get("CODEX_EXEC_ARGS") + if raw_exec_args is None: + raw_exec_args = config_get(config, "codex_exec_args") or "" + if isinstance(raw_exec_args, list): + extra_args = [str(v) for v in raw_exec_args] + else: + extra_args = shlex.split(str(raw_exec_args)) # e.g. "--full-auto --search" def _has_notify_override(args: list[str]) -> bool: for i, arg in enumerate(args): diff --git a/codex/codex_telegram_bridge/mcp_bridge.py b/codex/codex_telegram_bridge/mcp_bridge.py index 2d5f5a4..5e6a3be 100644 --- a/codex/codex_telegram_bridge/mcp_bridge.py +++ b/codex/codex_telegram_bridge/mcp_bridge.py @@ -9,7 +9,14 @@ import time from queue import Queue, Empty from typing import Any, Dict, List, Optional, Tuple -from bridge_common import TelegramClient, RouteStore, parse_allowed_chat_ids +from bridge_common import ( + TelegramClient, + RouteStore, + config_get, + load_telegram_config, + parse_allowed_chat_ids, + parse_chat_id_list, +) MCP_PROTOCOL_VERSION = "2025-06-18" @@ -244,18 +251,35 @@ class MCPStdioClient: def main() -> None: - token = os.environ.get("TELEGRAM_BOT_TOKEN", "") - db_path = os.environ.get("BRIDGE_DB", "./bridge_routes.sqlite3") + config = load_telegram_config() + token = os.environ.get("TELEGRAM_BOT_TOKEN") or config_get(config, "bot_token") or "" + db_path = os.environ.get("BRIDGE_DB") or config_get(config, "bridge_db") or "./bridge_routes.sqlite3" allowed = parse_allowed_chat_ids(os.environ.get("ALLOWED_CHAT_IDS", "")) + if allowed is None: + allowed = parse_chat_id_list(config_get(config, "allowed_chat_ids")) # How to start Codex MCP server: # default: "codex mcp-server" (can also be "npx -y codex mcp-server") - mcp_cmd = shlex.split(os.environ.get("CODEX_MCP_CMD", "codex mcp-server")) + raw_mcp_cmd = os.environ.get("CODEX_MCP_CMD") + if raw_mcp_cmd is None: + raw_mcp_cmd = config_get(config, "codex_mcp_cmd") or "codex mcp-server" + if isinstance(raw_mcp_cmd, list): + mcp_cmd = [str(v) for v in raw_mcp_cmd] + else: + mcp_cmd = shlex.split(str(raw_mcp_cmd)) # Optional defaults for tool args (you can override as you like) - default_cwd = os.environ.get("CODEX_WORKSPACE") # used as tool 'cwd' - default_sandbox = os.environ.get("CODEX_SANDBOX", "workspace-write") - default_approval = os.environ.get("CODEX_APPROVAL_POLICY", "never") + default_cwd = os.environ.get("CODEX_WORKSPACE") or config_get(config, "codex_workspace") + default_sandbox = ( + os.environ.get("CODEX_SANDBOX") + or config_get(config, "codex_sandbox") + or "workspace-write" + ) + default_approval = ( + os.environ.get("CODEX_APPROVAL_POLICY") + or config_get(config, "codex_approval_policy") + or "never" + ) bot = TelegramClient(token) store = RouteStore(db_path) diff --git a/codex/codex_telegram_bridge/readme.md b/codex/codex_telegram_bridge/readme.md index 11e79b2..d958456 100644 --- a/codex/codex_telegram_bridge/readme.md +++ b/codex/codex_telegram_bridge/readme.md @@ -12,7 +12,18 @@ All options store a mapping from `(chat_id, bot_message_id)` to a route so repli 1. Ensure `uv` is installed. 2. Use the scripts in this folder as-is (no extra dependencies). -3. Set `TELEGRAM_BOT_TOKEN` and (optionally) `ALLOWED_CHAT_IDS`. +3. Set `TELEGRAM_BOT_TOKEN` and (optionally) `ALLOWED_CHAT_IDS`, or put them in `~/.codex/telegram.toml`. + +Example `~/.codex/telegram.toml`: + +```toml +bot_token = "123:abc" +allowed_chat_ids = [123456789] +startup_chat_ids = [123456789] +startup_message = "✅ exec_bridge started (codex exec)." +``` + +Environment variables always override the TOML file. ## Option 1: exec/resume diff --git a/codex/codex_telegram_bridge/tmux_notify.py b/codex/codex_telegram_bridge/tmux_notify.py index 941a83e..38bb6be 100644 --- a/codex/codex_telegram_bridge/tmux_notify.py +++ b/codex/codex_telegram_bridge/tmux_notify.py @@ -5,19 +5,30 @@ import os import sys from typing import Optional -from bridge_common import TelegramClient, RouteStore +from bridge_common import TelegramClient, RouteStore, config_get, load_telegram_config def main() -> None: + config = load_telegram_config() + default_chat_id = config_get(config, "chat_id") + if isinstance(default_chat_id, str): + default_chat_id = int(default_chat_id) if default_chat_id.strip() else None + elif not isinstance(default_chat_id, int): + default_chat_id = None + ap = argparse.ArgumentParser() - ap.add_argument("--chat-id", type=int, required=True) + ap.add_argument("--chat-id", type=int, default=default_chat_id, required=default_chat_id is None) ap.add_argument("--tmux-target", type=str, required=True, help='tmux target, e.g. "codex1:0.0" or "codex1"') - ap.add_argument("--db", type=str, default=os.environ.get("BRIDGE_DB", "./bridge_routes.sqlite3")) + ap.add_argument( + "--db", + type=str, + default=os.environ.get("BRIDGE_DB") or config_get(config, "bridge_db") or "./bridge_routes.sqlite3", + ) ap.add_argument("--reply-to", type=int, default=None, help="Optional Telegram message_id to reply to") ap.add_argument("--text", type=str, default=None, help="Message text. If omitted, read stdin.") args = ap.parse_args() - token = os.environ.get("TELEGRAM_BOT_TOKEN", "") + token = os.environ.get("TELEGRAM_BOT_TOKEN") or config_get(config, "bot_token") or "" bot = TelegramClient(token) store = RouteStore(args.db) diff --git a/codex/codex_telegram_bridge/tmux_reply_bot.py b/codex/codex_telegram_bridge/tmux_reply_bot.py index ec12254..3469447 100644 --- a/codex/codex_telegram_bridge/tmux_reply_bot.py +++ b/codex/codex_telegram_bridge/tmux_reply_bot.py @@ -5,7 +5,14 @@ import subprocess import time from typing import Optional -from bridge_common import TelegramClient, RouteStore, parse_allowed_chat_ids +from bridge_common import ( + TelegramClient, + RouteStore, + config_get, + load_telegram_config, + parse_allowed_chat_ids, + parse_chat_id_list, +) def tmux_send_text(target: str, text: str, press_enter: bool = True) -> None: @@ -21,9 +28,12 @@ def tmux_send_text(target: str, text: str, press_enter: bool = True) -> None: def main() -> None: - token = os.environ.get("TELEGRAM_BOT_TOKEN", "") - db_path = os.environ.get("BRIDGE_DB", "./bridge_routes.sqlite3") + config = load_telegram_config() + token = os.environ.get("TELEGRAM_BOT_TOKEN") or config_get(config, "bot_token") or "" + db_path = os.environ.get("BRIDGE_DB") or config_get(config, "bridge_db") or "./bridge_routes.sqlite3" allowed = parse_allowed_chat_ids(os.environ.get("ALLOWED_CHAT_IDS", "")) + if allowed is None: + allowed = parse_chat_id_list(config_get(config, "allowed_chat_ids")) bot = TelegramClient(token) store = RouteStore(db_path) diff --git a/codex/notify_telegram/notify_telegram.py b/codex/notify_telegram/notify_telegram.py index 9df759c..5bf25ac 100644 --- a/codex/notify_telegram/notify_telegram.py +++ b/codex/notify_telegram/notify_telegram.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # /// script # requires-python = ">=3.10" -# dependencies = ["requests", "markdown-it-py", "sulguk"] +# dependencies = ["requests", "markdown-it-py", "sulguk", "tomli; python_version < '3.11'"] # /// import json import re @@ -12,14 +12,37 @@ import requests from markdown_it import MarkdownIt from sulguk import transform_html -CREDS_PATH = Path.home() / ".codex" / "telegram.json" +CONFIG_PATH = Path.home() / ".codex" / "telegram.toml" ERR_PATH = Path.home() / ".codex" / "telegram_last_error.txt" +def _load_toml(path: Path) -> dict: + if not path.exists(): + return {} + try: + import tomllib # type: ignore[attr-defined] + except ModuleNotFoundError: + import tomli as tomllib # type: ignore[import-not-found] + return tomllib.loads(path.read_text(encoding="utf-8")) + + +def _config_get(config: dict, key: str): + if key in config: + return config[key] + nested = config.get("telegram") + if isinstance(nested, dict) and key in nested: + return nested[key] + return None + + def main() -> None: - creds = json.loads(CREDS_PATH.read_text(encoding="utf-8")) - bot_token = creds["bot_token"] - chat_id = str(creds["chat_id"]) + config = _load_toml(CONFIG_PATH) + bot_token = _config_get(config, "bot_token") + chat_id = _config_get(config, "chat_id") + if not bot_token or chat_id is None: + raise KeyError("telegram.toml must include bot_token and chat_id") + bot_token = str(bot_token) + chat_id = str(chat_id) event = json.loads(sys.argv[1]) diff --git a/codex/notify_telegram/readme.md b/codex/notify_telegram/readme.md index 3bb5a48..58cd66b 100644 --- a/codex/notify_telegram/readme.md +++ b/codex/notify_telegram/readme.md @@ -6,15 +6,13 @@ Send Codex completion summaries to Telegram with safe Markdown rendering and sta 1. Ensure `uv` is installed. 2. Copy the script to `~/.codex/notify_telegram.py`. -3. Create your [Telegram creds](https://t.me/botfather) file at `~/.codex/telegram.json`. +3. Create your [Telegram creds](https://t.me/botfather) file at `~/.codex/telegram.toml`. Example: -```json -{ - "bot_token": "123456:ABCDEF...", - "chat_id": "462722" -} +```toml +bot_token = "123456:ABCDEF..." +chat_id = 462722 ``` ## Configure