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
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 banteg
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2 -2
View File
@@ -71,14 +71,14 @@ def render_markdown(md: str) -> tuple[str, list[dict[str, Any]]]:
```python ```python
def load_telegram_config(path=None) -> dict: def load_telegram_config(path=None) -> dict:
# Loads ~/.codex/telegram.toml (or custom path) # Loads ./codex/takopi.toml or ~/.codex/takopi.toml (or custom path)
``` ```
### `constants.py` — Shared Constants ### `constants.py` — Shared Constants
```python ```python
TELEGRAM_HARD_LIMIT = 4096 # Max message length TELEGRAM_HARD_LIMIT = 4096 # Max message length
TELEGRAM_CONFIG_PATH = ~/.codex/telegram.toml DEFAULT_CONFIG_PATHS = (./codex/takopi.toml, ~/.codex/takopi.toml)
``` ```
### `logging.py` — Secure Logging Setup ### `logging.py` — Secure Logging Setup
+15 -2
View File
@@ -3,6 +3,7 @@ name = "takopi"
version = "0.1.0" version = "0.1.0"
description = "Telegram bridge tools for Codex." description = "Telegram bridge tools for Codex."
readme = "readme.md" readme = "readme.md"
license = { file = "LICENSE" }
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"httpx>=0.28.1", "httpx>=0.28.1",
@@ -10,10 +11,22 @@ dependencies = [
"sulguk>=0.11.0", "sulguk>=0.11.0",
"typer", "typer",
] ]
classifiers = [
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3 :: Only",
"Operating System :: OS Independent",
]
[project.urls]
Homepage = "https://github.com/banteg/takopi"
Repository = "https://github.com/banteg/takopi"
Issues = "https://github.com/banteg/takopi/issues"
[project.scripts] [project.scripts]
takopi = "takopi.exec_bridge:main" takopi = "takopi.exec_bridge:main"
exec-bridge = "takopi.exec_bridge:main"
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
@@ -23,7 +36,7 @@ build-backend = "hatchling.build"
packages = ["src/takopi"] packages = ["src/takopi"]
[tool.hatch.build.targets.sdist] [tool.hatch.build.targets.sdist]
include = ["src/takopi", "readme.md"] include = ["src/takopi", "readme.md", "LICENSE"]
[dependency-groups] [dependency-groups]
dev = [ dev = [
+1 -1
View File
@@ -32,7 +32,7 @@ uv run takopi --help
### Configuration ### Configuration
Create `~/.codex/telegram.toml`: Create `~/.codex/takopi.toml` (or `./codex/takopi.toml` for a repo-local config):
```toml ```toml
bot_token = "123456789:ABCdefGHIjklMNOpqrsTUVwxyz" bot_token = "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
+62 -4
View File
@@ -1,9 +1,67 @@
from __future__ import annotations
import tomllib import tomllib
from pathlib import Path from pathlib import Path
from .constants import TELEGRAM_CONFIG_PATH from .constants import DEFAULT_CONFIG_PATHS
def load_telegram_config(path=None): class ConfigError(RuntimeError):
cfg_path = Path(path) if path else TELEGRAM_CONFIG_PATH pass
return tomllib.loads(cfg_path.read_text(encoding="utf-8"))
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 from pathlib import Path
TELEGRAM_HARD_LIMIT = 4096 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",
)
+49 -14
View File
@@ -18,7 +18,7 @@ from weakref import WeakValueDictionary
import typer import typer
from .config import load_telegram_config from .config import ConfigError, load_telegram_config
from .exec_render import ExecProgressRenderer, render_event_cli from .exec_render import ExecProgressRenderer, render_event_cli
from .logging import setup_logging from .logging import setup_logging
from .rendering import render_markdown from .rendering import render_markdown
@@ -358,20 +358,35 @@ def _parse_bridge_config(
cd: str | None, cd: str | None,
model: str | None, model: str | None,
) -> BridgeConfig: ) -> BridgeConfig:
config = load_telegram_config() config, config_path = load_telegram_config()
try:
token = config["bot_token"] 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"]) 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") codex_cmd = shutil.which("codex")
if not codex_cmd: 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() startup_pwd = os.getcwd()
workspace = None workspace = None
if cd is not None: if cd is not None:
expanded_cd = os.path.expanduser(cd) expanded_cd = os.path.expanduser(cd)
if not os.path.isdir(expanded_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 workspace = expanded_cd
startup_pwd = expanded_cd startup_pwd = expanded_cd
@@ -425,6 +440,8 @@ async def _send_startup(cfg: BridgeConfig) -> None:
async def _drain_backlog(cfg: BridgeConfig, offset: int | None) -> int | None: async def _drain_backlog(cfg: BridgeConfig, offset: int | None) -> int | None:
drained = 0
while True:
try: try:
updates = await cfg.bot.get_updates( updates = await cfg.bot.get_updates(
offset=offset, timeout_s=0, allowed_updates=["message"] offset=offset, timeout_s=0, allowed_updates=["message"]
@@ -433,10 +450,12 @@ async def _drain_backlog(cfg: BridgeConfig, offset: int | None) -> int | None:
logger.info("[startup] backlog drain failed: %s", e) logger.info("[startup] backlog drain failed: %s", e)
return offset return offset
logger.debug("[startup] backlog updates: %s", updates) logger.debug("[startup] backlog updates: %s", updates)
if updates: if not updates:
offset = updates[-1]["update_id"] + 1 if drained:
logger.info("[startup] drained %s pending update(s)", len(updates)) logger.info("[startup] drained %s pending update(s)", drained)
return offset return offset
offset = updates[-1]["update_id"] + 1
drained += len(updates)
async def _handle_message( async def _handle_message(
@@ -463,11 +482,15 @@ async def _handle_message(
last_edit_at = 0.0 last_edit_at = 0.0
edit_task: asyncio.Task[None] | None = None 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: if progress_id is None:
return return
rendered, entities = prepare_telegram(md, limit=TELEGRAM_MARKDOWN_LIMIT)
logger.debug( logger.debug(
"[progress] edit message_id=%s md=%s rendered=%s entities=%s", "[progress] edit message_id=%s md=%s rendered=%s entities=%s",
progress_id, progress_id,
@@ -482,6 +505,7 @@ async def _handle_message(
text=rendered, text=rendered,
entities=entities, entities=entities,
) )
last_rendered = rendered
except Exception as e: except Exception as e:
logger.info( logger.info(
"[progress] edit failed chat_id=%s message_id=%s: %s", "[progress] edit failed chat_id=%s message_id=%s: %s",
@@ -489,6 +513,9 @@ async def _handle_message(
progress_id, progress_id,
e, e,
) )
finally:
if pending_rendered == rendered:
pending_rendered = None
try: try:
initial_md = progress_renderer.render_progress(0.0) initial_md = progress_renderer.render_progress(0.0)
@@ -511,6 +538,7 @@ async def _handle_message(
) )
progress_id = int(progress_msg["message_id"]) progress_id = int(progress_msg["message_id"])
last_edit_at = clock() last_edit_at = clock()
last_rendered = initial_rendered
logger.debug("[progress] sent chat_id=%s message_id=%s", chat_id, progress_id) logger.debug("[progress] sent chat_id=%s message_id=%s", chat_id, progress_id)
except Exception as e: except Exception as e:
logger.info( logger.info(
@@ -518,7 +546,7 @@ async def _handle_message(
) )
async def on_event(evt: dict[str, Any]) -> None: 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: if progress_id is None:
return return
if not progress_renderer.note_event(evt): if not progress_renderer.note_event(evt):
@@ -528,11 +556,14 @@ async def _handle_message(
return return
if edit_task is not None and not edit_task.done(): if edit_task is not None and not edit_task.done():
return return
last_edit_at = now
elapsed = now - started_at elapsed = now - started_at
edit_task = asyncio.create_task( md = progress_renderer.render_progress(elapsed)
_edit_progress(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: try:
session_id, answer, saw_agent_message = await cfg.runner.run_serialized( session_id, answer, saw_agent_message = await cfg.runner.run_serialized(
@@ -692,11 +723,15 @@ def run(
), ),
) -> None: ) -> None:
setup_logging(debug=debug) setup_logging(debug=debug)
try:
cfg = _parse_bridge_config( cfg = _parse_bridge_config(
final_notify=final_notify, final_notify=final_notify,
cd=cd, cd=cd,
model=model, model=model,
) )
except ConfigError as e:
typer.echo(str(e), err=True)
raise typer.Exit(code=1)
asyncio.run(_run_main_loop(cfg)) 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: def setup_logging(*, debug: bool = False) -> None:
root_logger = logging.getLogger() 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[:]: for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler) root_logger.removeHandler(handler)
handler.close() handler.close()