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
|
```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
@@ -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 = [
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user