feat: improve config UX and packaging metadata

This commit is contained in:
banteg
2025-12-29 15:48:05 +04:00
parent e1394aee4c
commit caa9f4f99d
8 changed files with 170 additions and 40 deletions
+62 -4
View File
@@ -1,9 +1,67 @@
from __future__ import annotations
import tomllib
from pathlib import Path
from .constants import TELEGRAM_CONFIG_PATH
from .constants import DEFAULT_CONFIG_PATHS
def load_telegram_config(path=None):
cfg_path = Path(path) if path else TELEGRAM_CONFIG_PATH
return tomllib.loads(cfg_path.read_text(encoding="utf-8"))
class ConfigError(RuntimeError):
pass
def _display_path(path: Path) -> str:
try:
cwd = Path.cwd()
if path.is_relative_to(cwd):
return f"./{path.relative_to(cwd).as_posix()}"
home = Path.home()
if path.is_relative_to(home):
return f"~/{path.relative_to(home).as_posix()}"
except Exception:
return str(path)
return str(path)
def _missing_config_message(primary: Path, alternate: Path | None = None) -> str:
if alternate is None:
header = f"Missing config file `{_display_path(primary)}`."
else:
header = (
f"Missing config file `{_display_path(primary)}` "
f"(or `{_display_path(alternate)}`)."
)
return "\n".join(
[
header,
"Create it with:",
' bot_token = "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"',
" chat_id = 123456789",
]
)
def _read_config(cfg_path: Path) -> dict:
try:
raw = cfg_path.read_text(encoding="utf-8")
except FileNotFoundError:
raise ConfigError(_missing_config_message(cfg_path)) from None
except OSError as e:
raise ConfigError(f"Failed to read config file {cfg_path}: {e}") from e
try:
return tomllib.loads(raw)
except tomllib.TOMLDecodeError as e:
raise ConfigError(f"Malformed TOML in {cfg_path}: {e}") from None
def load_telegram_config(path: str | Path | None = None) -> tuple[dict, Path]:
if path:
cfg_path = Path(path).expanduser()
return _read_config(cfg_path), cfg_path
local_path, home_path = DEFAULT_CONFIG_PATHS
for candidate in (local_path, home_path):
if candidate.is_file():
return _read_config(candidate), candidate
raise ConfigError(_missing_config_message(home_path, local_path))
+4 -1
View File
@@ -3,4 +3,7 @@ from __future__ import annotations
from pathlib import Path
TELEGRAM_HARD_LIMIT = 4096
TELEGRAM_CONFIG_PATH = Path.home() / ".codex" / "telegram.toml"
DEFAULT_CONFIG_PATHS = (
Path.cwd() / "codex" / "takopi.toml",
Path.home() / ".codex" / "takopi.toml",
)
+64 -29
View File
@@ -18,7 +18,7 @@ from weakref import WeakValueDictionary
import typer
from .config import load_telegram_config
from .config import ConfigError, load_telegram_config
from .exec_render import ExecProgressRenderer, render_event_cli
from .logging import setup_logging
from .rendering import render_markdown
@@ -358,20 +358,35 @@ def _parse_bridge_config(
cd: str | None,
model: str | None,
) -> BridgeConfig:
config = load_telegram_config()
token = config["bot_token"]
chat_id = int(config["chat_id"])
config, config_path = load_telegram_config()
try:
token = config["bot_token"]
except KeyError:
raise ConfigError(f"Missing key `bot_token` in {config_path}.") from None
try:
chat_id = int(config["chat_id"])
except KeyError:
raise ConfigError(f"Missing key `chat_id` in {config_path}.") from None
except (TypeError, ValueError):
raise ConfigError(
f"Invalid `chat_id` in {config_path}; expected an integer."
) from None
codex_cmd = shutil.which("codex")
if not codex_cmd:
raise RuntimeError("codex not found on PATH")
raise ConfigError(
"codex not found on PATH. Install the Codex CLI with:\n"
" npm install -g @openai/codex\n"
" # or on macOS\n"
" brew install codex"
)
startup_pwd = os.getcwd()
workspace = None
if cd is not None:
expanded_cd = os.path.expanduser(cd)
if not os.path.isdir(expanded_cd):
raise RuntimeError(f"--cd must be an existing directory: {expanded_cd}")
raise ConfigError(f"--cd must be an existing directory: {expanded_cd}")
workspace = expanded_cd
startup_pwd = expanded_cd
@@ -425,18 +440,22 @@ async def _send_startup(cfg: BridgeConfig) -> None:
async def _drain_backlog(cfg: BridgeConfig, offset: int | None) -> int | None:
try:
updates = await cfg.bot.get_updates(
offset=offset, timeout_s=0, allowed_updates=["message"]
)
except Exception as e:
logger.info("[startup] backlog drain failed: %s", e)
return offset
logger.debug("[startup] backlog updates: %s", updates)
if updates:
drained = 0
while True:
try:
updates = await cfg.bot.get_updates(
offset=offset, timeout_s=0, allowed_updates=["message"]
)
except Exception as e:
logger.info("[startup] backlog drain failed: %s", e)
return offset
logger.debug("[startup] backlog updates: %s", updates)
if not updates:
if drained:
logger.info("[startup] drained %s pending update(s)", drained)
return offset
offset = updates[-1]["update_id"] + 1
logger.info("[startup] drained %s pending update(s)", len(updates))
return offset
drained += len(updates)
async def _handle_message(
@@ -463,11 +482,15 @@ async def _handle_message(
last_edit_at = 0.0
edit_task: asyncio.Task[None] | None = None
last_rendered: str | None = None
pending_rendered: str | None = None
async def _edit_progress(md: str) -> None:
async def _edit_progress(
md: str, rendered: str, entities: list[dict[str, Any]] | None
) -> None:
nonlocal last_rendered, pending_rendered
if progress_id is None:
return
rendered, entities = prepare_telegram(md, limit=TELEGRAM_MARKDOWN_LIMIT)
logger.debug(
"[progress] edit message_id=%s md=%s rendered=%s entities=%s",
progress_id,
@@ -482,6 +505,7 @@ async def _handle_message(
text=rendered,
entities=entities,
)
last_rendered = rendered
except Exception as e:
logger.info(
"[progress] edit failed chat_id=%s message_id=%s: %s",
@@ -489,6 +513,9 @@ async def _handle_message(
progress_id,
e,
)
finally:
if pending_rendered == rendered:
pending_rendered = None
try:
initial_md = progress_renderer.render_progress(0.0)
@@ -511,6 +538,7 @@ async def _handle_message(
)
progress_id = int(progress_msg["message_id"])
last_edit_at = clock()
last_rendered = initial_rendered
logger.debug("[progress] sent chat_id=%s message_id=%s", chat_id, progress_id)
except Exception as e:
logger.info(
@@ -518,7 +546,7 @@ async def _handle_message(
)
async def on_event(evt: dict[str, Any]) -> None:
nonlocal last_edit_at, edit_task
nonlocal last_edit_at, edit_task, pending_rendered
if progress_id is None:
return
if not progress_renderer.note_event(evt):
@@ -528,11 +556,14 @@ async def _handle_message(
return
if edit_task is not None and not edit_task.done():
return
last_edit_at = now
elapsed = now - started_at
edit_task = asyncio.create_task(
_edit_progress(progress_renderer.render_progress(elapsed))
)
md = progress_renderer.render_progress(elapsed)
rendered, entities = prepare_telegram(md, limit=TELEGRAM_MARKDOWN_LIMIT)
if rendered == last_rendered or rendered == pending_rendered:
return
last_edit_at = now
pending_rendered = rendered
edit_task = asyncio.create_task(_edit_progress(md, rendered, entities))
try:
session_id, answer, saw_agent_message = await cfg.runner.run_serialized(
@@ -692,11 +723,15 @@ def run(
),
) -> None:
setup_logging(debug=debug)
cfg = _parse_bridge_config(
final_notify=final_notify,
cd=cd,
model=model,
)
try:
cfg = _parse_bridge_config(
final_notify=final_notify,
cd=cd,
model=model,
)
except ConfigError as e:
typer.echo(str(e), err=True)
raise typer.Exit(code=1)
asyncio.run(_run_main_loop(cfg))
+1 -1
View File
@@ -25,7 +25,7 @@ class RedactTokenFilter(logging.Filter):
def setup_logging(*, debug: bool = False) -> None:
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
root_logger.setLevel(logging.DEBUG if debug else logging.INFO)
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
handler.close()