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.error
import urllib.request import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple from typing import Any, Dict, Iterable, List, Optional, Tuple
TELEGRAM_HARD_LIMIT = 4096 TELEGRAM_HARD_LIMIT = 4096
DEFAULT_CHUNK_LEN = 3500 # leave room for formatting / safety DEFAULT_CHUNK_LEN = 3500 # leave room for formatting / safety
TELEGRAM_CONFIG_PATH = Path.home() / ".codex" / "telegram.toml"
def _now_unix() -> int: def _now_unix() -> int:
return int(time.time()) 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]: def chunk_text(text: str, limit: int = DEFAULT_CHUNK_LEN) -> List[str]:
""" """
Telegram hard limit is 4096 chars. Chunk at newlines when possible. 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 continue
out.add(int(part)) out.add(int(part))
return out 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 concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, Optional, Tuple 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 -------------------- # -------------------- Codex runner --------------------
@@ -138,17 +145,32 @@ class CodexExecRunner:
def main() -> None: def main() -> None:
token = os.environ.get("TELEGRAM_BOT_TOKEN", "") config = load_telegram_config()
db_path = os.environ.get("BRIDGE_DB", "./bridge_routes.sqlite3") 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", "")) 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 if allowed is None:
startup_msg = os.environ.get("STARTUP_MESSAGE", "✅ exec_bridge started (codex exec).") 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_pwd = os.getcwd()
startup_msg = f"{startup_msg}\nPWD: {startup_pwd}" startup_msg = f"{startup_msg}\nPWD: {startup_pwd}"
codex_cmd = os.environ.get("CODEX_CMD", "codex") codex_cmd = os.environ.get("CODEX_CMD") or config_get(config, "codex_cmd") or "codex"
workspace = os.environ.get("CODEX_WORKSPACE") # optional workspace = os.environ.get("CODEX_WORKSPACE") or config_get(config, "codex_workspace")
extra_args = shlex.split(os.environ.get("CODEX_EXEC_ARGS", "")) # e.g. "--full-auto --search" 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: def _has_notify_override(args: list[str]) -> bool:
for i, arg in enumerate(args): for i, arg in enumerate(args):
+31 -7
View File
@@ -9,7 +9,14 @@ import time
from queue import Queue, Empty from queue import Queue, Empty
from typing import Any, Dict, List, Optional, Tuple 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" MCP_PROTOCOL_VERSION = "2025-06-18"
@@ -244,18 +251,35 @@ class MCPStdioClient:
def main() -> None: def main() -> None:
token = os.environ.get("TELEGRAM_BOT_TOKEN", "") config = load_telegram_config()
db_path = os.environ.get("BRIDGE_DB", "./bridge_routes.sqlite3") 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", "")) 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: # How to start Codex MCP server:
# default: "codex mcp-server" (can also be "npx -y 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) # Optional defaults for tool args (you can override as you like)
default_cwd = os.environ.get("CODEX_WORKSPACE") # used as tool 'cwd' default_cwd = os.environ.get("CODEX_WORKSPACE") or config_get(config, "codex_workspace")
default_sandbox = os.environ.get("CODEX_SANDBOX", "workspace-write") default_sandbox = (
default_approval = os.environ.get("CODEX_APPROVAL_POLICY", "never") 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) bot = TelegramClient(token)
store = RouteStore(db_path) 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. 1. Ensure `uv` is installed.
2. Use the scripts in this folder as-is (no extra dependencies). 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 ## Option 1: exec/resume
+15 -4
View File
@@ -5,19 +5,30 @@ import os
import sys import sys
from typing import Optional from typing import Optional
from bridge_common import TelegramClient, RouteStore from bridge_common import TelegramClient, RouteStore, config_get, load_telegram_config
def main() -> None: 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 = 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("--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("--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.") ap.add_argument("--text", type=str, default=None, help="Message text. If omitted, read stdin.")
args = ap.parse_args() 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) bot = TelegramClient(token)
store = RouteStore(args.db) store = RouteStore(args.db)
+13 -3
View File
@@ -5,7 +5,14 @@ import subprocess
import time import time
from typing import Optional 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: 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: def main() -> None:
token = os.environ.get("TELEGRAM_BOT_TOKEN", "") config = load_telegram_config()
db_path = os.environ.get("BRIDGE_DB", "./bridge_routes.sqlite3") 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", "")) 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) bot = TelegramClient(token)
store = RouteStore(db_path) store = RouteStore(db_path)
+28 -5
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# /// script # /// script
# requires-python = ">=3.10" # requires-python = ">=3.10"
# dependencies = ["requests", "markdown-it-py", "sulguk"] # dependencies = ["requests", "markdown-it-py", "sulguk", "tomli; python_version < '3.11'"]
# /// # ///
import json import json
import re import re
@@ -12,14 +12,37 @@ import requests
from markdown_it import MarkdownIt from markdown_it import MarkdownIt
from sulguk import transform_html 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" 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: def main() -> None:
creds = json.loads(CREDS_PATH.read_text(encoding="utf-8")) config = _load_toml(CONFIG_PATH)
bot_token = creds["bot_token"] bot_token = _config_get(config, "bot_token")
chat_id = str(creds["chat_id"]) 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]) event = json.loads(sys.argv[1])
+4 -6
View File
@@ -6,15 +6,13 @@ Send Codex completion summaries to Telegram with safe Markdown rendering and sta
1. Ensure `uv` is installed. 1. Ensure `uv` is installed.
2. Copy the script to `~/.codex/notify_telegram.py`. 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: Example:
```json ```toml
{ bot_token = "123456:ABCDEF..."
"bot_token": "123456:ABCDEF...", chat_id = 462722
"chat_id": "462722"
}
``` ```
## Configure ## Configure