feat: improve config UX and packaging metadata
This commit is contained in:
@@ -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
@@ -71,14 +71,14 @@ def render_markdown(md: str) -> tuple[str, list[dict[str, Any]]]:
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
+15
-2
@@ -3,6 +3,7 @@ name = "takopi"
|
||||
version = "0.1.0"
|
||||
description = "Telegram bridge tools for Codex."
|
||||
readme = "readme.md"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"httpx>=0.28.1",
|
||||
@@ -10,10 +11,22 @@ dependencies = [
|
||||
"sulguk>=0.11.0",
|
||||
"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]
|
||||
takopi = "takopi.exec_bridge:main"
|
||||
exec-bridge = "takopi.exec_bridge:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
@@ -23,7 +36,7 @@ build-backend = "hatchling.build"
|
||||
packages = ["src/takopi"]
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = ["src/takopi", "readme.md"]
|
||||
include = ["src/takopi", "readme.md", "LICENSE"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
||||
@@ -32,7 +32,7 @@ uv run takopi --help
|
||||
|
||||
### Configuration
|
||||
|
||||
Create `~/.codex/telegram.toml`:
|
||||
Create `~/.codex/takopi.toml` (or `./codex/takopi.toml` for a repo-local config):
|
||||
|
||||
```toml
|
||||
bot_token = "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||
|
||||
+62
-4
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
+49
-14
@@ -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()
|
||||
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,6 +440,8 @@ async def _send_startup(cfg: BridgeConfig) -> None:
|
||||
|
||||
|
||||
async def _drain_backlog(cfg: BridgeConfig, offset: int | None) -> int | None:
|
||||
drained = 0
|
||||
while True:
|
||||
try:
|
||||
updates = await cfg.bot.get_updates(
|
||||
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)
|
||||
return offset
|
||||
logger.debug("[startup] backlog updates: %s", updates)
|
||||
if updates:
|
||||
offset = updates[-1]["update_id"] + 1
|
||||
logger.info("[startup] drained %s pending update(s)", len(updates))
|
||||
if not updates:
|
||||
if drained:
|
||||
logger.info("[startup] drained %s pending update(s)", drained)
|
||||
return offset
|
||||
offset = updates[-1]["update_id"] + 1
|
||||
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)
|
||||
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))
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user