feat(telegram): load config from telegram.toml

This commit is contained in:
banteg
2025-12-28 20:37:54 +04:00
parent 0a984e228a
commit 6338c9f635
8 changed files with 187 additions and 34 deletions
@@ -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
+30 -8
View File
@@ -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):
+31 -7
View File
@@ -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)
+12 -1
View File
@@ -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
+15 -4
View File
@@ -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)
+13 -3
View File
@@ -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)