feat: transport registry and onboarding updates (#69)
This commit is contained in:
+15
-1
@@ -77,12 +77,25 @@ Defines `Transport`, `MessageRef`, `RenderedMessage`, and `SendOptions`.
|
||||
|
||||
Defines a renderer that converts `ProgressState` into `RenderedMessage` outputs.
|
||||
|
||||
### `transports.py` - Transport registry
|
||||
|
||||
Defines the transport backend protocol, registry helpers, and built-in transport registration.
|
||||
|
||||
### `config_migrations.py` - Config migrations
|
||||
|
||||
Applies one-time edits to on-disk config (e.g., legacy Telegram key migration) before
|
||||
`TakopiSettings` validation runs.
|
||||
|
||||
### `telegram/backend.py` - Telegram transport backend
|
||||
|
||||
Adapter that validates Telegram config, runs onboarding, and builds/runs the Telegram bridge.
|
||||
|
||||
### `cli.py` - CLI entry point
|
||||
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| `run()` / `main()` | Typer CLI entry points |
|
||||
| `_parse_bridge_config()` | Reads config + builds `TelegramBridgeConfig` + `ExecBridgeConfig` |
|
||||
| `_run_auto_router()` | Loads settings, resolves transport + engine, builds router, delegates to transport backend |
|
||||
|
||||
### `progress.py` - Progress tracking
|
||||
|
||||
@@ -261,6 +274,7 @@ Environment flags:
|
||||
- `PI_CODING_AGENT_DIR` (override Pi agent session directory base path)
|
||||
|
||||
CLI flag: `--debug` enables debug logging (overrides `TAKOPI_LOG_LEVEL`).
|
||||
CLI flag: `--transport <id>` overrides the configured transport backend.
|
||||
|
||||
### `telegram/onboarding.py` - Setup validation
|
||||
|
||||
|
||||
+5
-1
@@ -20,7 +20,7 @@ All config lives in `~/.takopi/takopi.toml`.
|
||||
```toml
|
||||
default_engine = "codex" # optional
|
||||
default_project = "z80" # optional
|
||||
transport = "telegram" # required
|
||||
transport = "telegram" # optional, defaults to "telegram"
|
||||
|
||||
[transports.telegram]
|
||||
bot_token = "..." # required
|
||||
@@ -33,6 +33,9 @@ default_engine = "codex" # optional, per-project override
|
||||
worktree_base = "master" # optional, base for new branches
|
||||
```
|
||||
|
||||
Legacy config note: top-level `bot_token` / `chat_id` are auto-migrated into
|
||||
`[transports.telegram]` on startup.
|
||||
|
||||
Note on `worktrees_dir`:
|
||||
|
||||
- The default `.worktrees` lives inside the repo root. You'll see it as an
|
||||
@@ -49,6 +52,7 @@ Validation rules:
|
||||
- `default_project` must match a configured project alias.
|
||||
- Project aliases cannot collide with engine ids or reserved commands (`/cancel`).
|
||||
- `default_engine` and per-project `default_engine` must be valid engine ids.
|
||||
- `transport` defaults to `"telegram"` when omitted; override per-run with `--transport`.
|
||||
|
||||
## `takopi init`
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ global config `~/.takopi/takopi.toml`
|
||||
```toml
|
||||
default_engine = "codex"
|
||||
|
||||
# optional, defaults to "telegram"
|
||||
transport = "telegram"
|
||||
|
||||
[transports.telegram]
|
||||
@@ -81,7 +82,7 @@ provider = "openai"
|
||||
extra_args = ["--no-color"]
|
||||
```
|
||||
|
||||
note: configs with top-level `bot_token` / `chat_id` must be migrated to `[transports.telegram]`.
|
||||
note: configs with top-level `bot_token` / `chat_id` are migrated to `[transports.telegram]` on startup.
|
||||
|
||||
## projects
|
||||
|
||||
@@ -122,6 +123,13 @@ takopi opencode
|
||||
takopi pi
|
||||
```
|
||||
|
||||
list available transports (and override in a run):
|
||||
|
||||
```sh
|
||||
takopi transports
|
||||
takopi --transport telegram
|
||||
```
|
||||
|
||||
resume lines always route to the matching engine; subcommands only override the default for new threads.
|
||||
|
||||
send a message to the bot.
|
||||
|
||||
+105
-91
@@ -6,7 +6,6 @@ import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
import typer
|
||||
|
||||
from . import __version__
|
||||
@@ -16,7 +15,6 @@ from .engines import get_backend, list_backends
|
||||
from .lockfile import LockError, LockHandle, acquire_lock, token_fingerprint
|
||||
from .logging import get_logger, setup_logging
|
||||
from .router import AutoRouter, RunnerEntry
|
||||
from .runner_bridge import ExecBridgeConfig
|
||||
from .settings import (
|
||||
TakopiSettings,
|
||||
load_settings,
|
||||
@@ -24,14 +22,7 @@ from .settings import (
|
||||
require_telegram,
|
||||
validate_settings_data,
|
||||
)
|
||||
from .telegram.bridge import (
|
||||
TelegramBridgeConfig,
|
||||
TelegramPresenter,
|
||||
TelegramTransport,
|
||||
run_main_loop,
|
||||
)
|
||||
from .telegram.client import TelegramClient
|
||||
from .telegram.onboarding import SetupResult, check_setup, interactive_setup
|
||||
from .transports import SetupResult, get_transport, list_transports
|
||||
from .utils.git import resolve_default_base, resolve_main_worktree_root
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -47,6 +38,22 @@ def _version_callback(value: bool) -> None:
|
||||
_print_version_and_exit()
|
||||
|
||||
|
||||
def _resolve_transport_id(override: str | None) -> str:
|
||||
if override is not None:
|
||||
value = override.strip()
|
||||
if not value:
|
||||
raise ConfigError("Invalid `--transport`; expected a non-empty string.")
|
||||
return value
|
||||
try:
|
||||
config, _ = load_or_init_config()
|
||||
except ConfigError:
|
||||
return "telegram"
|
||||
raw = config.get("transport")
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
return "telegram"
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def load_and_validate_config(
|
||||
path: str | Path | None = None,
|
||||
) -> tuple[TakopiSettings, Path, str, int]:
|
||||
@@ -55,11 +62,12 @@ def load_and_validate_config(
|
||||
return settings, config_path, token, chat_id
|
||||
|
||||
|
||||
def acquire_config_lock(config_path: Path, token: str) -> LockHandle:
|
||||
def acquire_config_lock(config_path: Path, token: str | None) -> LockHandle:
|
||||
fingerprint = token_fingerprint(token) if token else None
|
||||
try:
|
||||
return acquire_lock(
|
||||
config_path=config_path,
|
||||
token_fingerprint=token_fingerprint(token),
|
||||
token_fingerprint=fingerprint,
|
||||
)
|
||||
except LockError as exc:
|
||||
lines = str(exc).splitlines()
|
||||
@@ -75,7 +83,10 @@ def acquire_config_lock(config_path: Path, token: str) -> LockHandle:
|
||||
def _default_engine_for_setup(override: str | None) -> str:
|
||||
if override:
|
||||
return override
|
||||
try:
|
||||
loaded = load_settings_if_exists()
|
||||
except ConfigError:
|
||||
return "codex"
|
||||
if loaded is None:
|
||||
return "codex"
|
||||
settings, config_path = loaded
|
||||
@@ -173,72 +184,6 @@ def _build_router(
|
||||
return AutoRouter(entries=entries, default_engine=default_engine)
|
||||
|
||||
|
||||
def _parse_bridge_config(
|
||||
*,
|
||||
final_notify: bool,
|
||||
default_engine_override: str | None,
|
||||
settings: TakopiSettings,
|
||||
config_path: Path,
|
||||
token: str,
|
||||
chat_id: int,
|
||||
) -> TelegramBridgeConfig:
|
||||
startup_pwd = os.getcwd()
|
||||
|
||||
backends = list_backends()
|
||||
projects = settings.to_projects_config(
|
||||
config_path=config_path,
|
||||
engine_ids=[backend.id for backend in backends],
|
||||
reserved=("cancel",),
|
||||
)
|
||||
default_engine = _resolve_default_engine(
|
||||
override=default_engine_override,
|
||||
settings=settings,
|
||||
config_path=config_path,
|
||||
backends=backends,
|
||||
)
|
||||
router = _build_router(
|
||||
settings=settings,
|
||||
config_path=config_path,
|
||||
backends=backends,
|
||||
default_engine=default_engine,
|
||||
)
|
||||
available_engines = [entry.engine for entry in router.available_entries]
|
||||
missing_engines = [entry.engine for entry in router.entries if not entry.available]
|
||||
engine_list = ", ".join(available_engines) if available_engines else "none"
|
||||
if missing_engines:
|
||||
engine_list = f"{engine_list} (not installed: {', '.join(missing_engines)})"
|
||||
project_aliases = sorted(
|
||||
{project.alias for project in projects.projects.values()},
|
||||
key=str.lower,
|
||||
)
|
||||
project_list = ", ".join(project_aliases) if project_aliases else "none"
|
||||
startup_msg = (
|
||||
f"\N{OCTOPUS} **takopi is ready**\n\n"
|
||||
f"default: `{router.default_engine}` \n"
|
||||
f"agents: `{engine_list}` \n"
|
||||
f"projects: `{project_list}` \n"
|
||||
f"working in: `{startup_pwd}`"
|
||||
)
|
||||
|
||||
bot = TelegramClient(token)
|
||||
transport = TelegramTransport(bot)
|
||||
presenter = TelegramPresenter()
|
||||
exec_cfg = ExecBridgeConfig(
|
||||
transport=transport,
|
||||
presenter=presenter,
|
||||
final_notify=final_notify,
|
||||
)
|
||||
|
||||
return TelegramBridgeConfig(
|
||||
bot=bot,
|
||||
router=router,
|
||||
chat_id=chat_id,
|
||||
startup_msg=startup_msg,
|
||||
exec_cfg=exec_cfg,
|
||||
projects=projects,
|
||||
)
|
||||
|
||||
|
||||
def _config_path_display(path: Path) -> str:
|
||||
home = Path.home()
|
||||
try:
|
||||
@@ -259,12 +204,16 @@ def _setup_needs_config(setup: SetupResult) -> bool:
|
||||
|
||||
def _fail_missing_config(path: Path) -> None:
|
||||
display = _config_path_display(path)
|
||||
if path.exists():
|
||||
typer.echo(f"error: invalid takopi config at {display}", err=True)
|
||||
else:
|
||||
typer.echo(f"error: missing takopi config at {display}", err=True)
|
||||
|
||||
|
||||
def _run_auto_router(
|
||||
*,
|
||||
default_engine_override: str | None,
|
||||
transport_override: str | None,
|
||||
final_notify: bool,
|
||||
debug: bool,
|
||||
onboard: bool,
|
||||
@@ -273,7 +222,9 @@ def _run_auto_router(
|
||||
lock_handle: LockHandle | None = None
|
||||
try:
|
||||
default_engine = _default_engine_for_setup(default_engine_override)
|
||||
backend = get_backend(default_engine)
|
||||
engine_backend = get_backend(default_engine)
|
||||
transport_id = _resolve_transport_id(transport_override)
|
||||
transport_backend = get_transport(transport_id)
|
||||
except ConfigError as e:
|
||||
typer.echo(f"error: {e}", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
@@ -281,17 +232,37 @@ def _run_auto_router(
|
||||
if not _should_run_interactive():
|
||||
typer.echo("error: --onboard requires a TTY", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
if not interactive_setup(force=True):
|
||||
if not transport_backend.interactive_setup(force=True):
|
||||
raise typer.Exit(code=1)
|
||||
default_engine = _default_engine_for_setup(default_engine_override)
|
||||
backend = get_backend(default_engine)
|
||||
setup = check_setup(backend)
|
||||
engine_backend = get_backend(default_engine)
|
||||
setup = transport_backend.check_setup(
|
||||
engine_backend,
|
||||
transport_override=transport_override,
|
||||
)
|
||||
if not setup.ok:
|
||||
if _setup_needs_config(setup) and _should_run_interactive():
|
||||
if interactive_setup(force=False):
|
||||
if setup.config_path.exists():
|
||||
display = _config_path_display(setup.config_path)
|
||||
run_onboard = typer.confirm(
|
||||
f"config at {display} is missing/invalid for "
|
||||
f"{transport_backend.id}, run onboarding now?",
|
||||
default=False,
|
||||
)
|
||||
if run_onboard and transport_backend.interactive_setup(force=True):
|
||||
default_engine = _default_engine_for_setup(default_engine_override)
|
||||
backend = get_backend(default_engine)
|
||||
setup = check_setup(backend)
|
||||
engine_backend = get_backend(default_engine)
|
||||
setup = transport_backend.check_setup(
|
||||
engine_backend,
|
||||
transport_override=transport_override,
|
||||
)
|
||||
elif transport_backend.interactive_setup(force=False):
|
||||
default_engine = _default_engine_for_setup(default_engine_override)
|
||||
engine_backend = get_backend(default_engine)
|
||||
setup = transport_backend.check_setup(
|
||||
engine_backend,
|
||||
transport_override=transport_override,
|
||||
)
|
||||
if not setup.ok:
|
||||
if _setup_needs_config(setup):
|
||||
_fail_missing_config(setup.config_path)
|
||||
@@ -300,17 +271,40 @@ def _run_auto_router(
|
||||
typer.echo(f"error: {first.title}", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
try:
|
||||
settings, config_path, token, chat_id = load_and_validate_config()
|
||||
lock_handle = acquire_config_lock(config_path, token)
|
||||
cfg = _parse_bridge_config(
|
||||
settings, config_path = load_settings()
|
||||
if transport_override and transport_override != settings.transport:
|
||||
settings = settings.model_copy(update={"transport": transport_override})
|
||||
backends = list_backends()
|
||||
projects = settings.to_projects_config(
|
||||
config_path=config_path,
|
||||
engine_ids=[backend.id for backend in backends],
|
||||
reserved=("cancel",),
|
||||
)
|
||||
default_engine = _resolve_default_engine(
|
||||
override=default_engine_override,
|
||||
settings=settings,
|
||||
config_path=config_path,
|
||||
backends=backends,
|
||||
)
|
||||
router = _build_router(
|
||||
settings=settings,
|
||||
config_path=config_path,
|
||||
backends=backends,
|
||||
default_engine=default_engine,
|
||||
)
|
||||
lock_token = transport_backend.lock_token(
|
||||
settings=settings,
|
||||
config_path=config_path,
|
||||
)
|
||||
lock_handle = acquire_config_lock(config_path, lock_token)
|
||||
transport_backend.build_and_run(
|
||||
final_notify=final_notify,
|
||||
default_engine_override=default_engine_override,
|
||||
settings=settings,
|
||||
config_path=config_path,
|
||||
token=token,
|
||||
chat_id=chat_id,
|
||||
router=router,
|
||||
projects=projects,
|
||||
)
|
||||
anyio.run(run_main_loop, cfg)
|
||||
except ConfigError as e:
|
||||
typer.echo(f"error: {e}", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
@@ -423,6 +417,13 @@ def init(
|
||||
typer.echo(f"saved project {alias!r} to {_config_path_display(config_path)}")
|
||||
|
||||
|
||||
def transports_cmd() -> None:
|
||||
"""List available transport backends."""
|
||||
ids = list_transports()
|
||||
for transport_id in ids:
|
||||
typer.echo(transport_id)
|
||||
|
||||
|
||||
app = typer.Typer(
|
||||
add_completion=False,
|
||||
invoke_without_command=True,
|
||||
@@ -431,6 +432,7 @@ app = typer.Typer(
|
||||
|
||||
|
||||
app.command(name="init")(init)
|
||||
app.command(name="transports")(transports_cmd)
|
||||
|
||||
|
||||
@app.callback()
|
||||
@@ -453,6 +455,11 @@ def app_main(
|
||||
"--onboard/--no-onboard",
|
||||
help="Run the interactive setup wizard before starting.",
|
||||
),
|
||||
transport: str | None = typer.Option(
|
||||
None,
|
||||
"--transport",
|
||||
help="Override the transport backend id.",
|
||||
),
|
||||
debug: bool = typer.Option(
|
||||
False,
|
||||
"--debug/--no-debug",
|
||||
@@ -463,6 +470,7 @@ def app_main(
|
||||
if ctx.invoked_subcommand is None:
|
||||
_run_auto_router(
|
||||
default_engine_override=None,
|
||||
transport_override=transport,
|
||||
final_notify=final_notify,
|
||||
debug=debug,
|
||||
onboard=onboard,
|
||||
@@ -482,6 +490,11 @@ def make_engine_cmd(engine_id: str) -> Callable[..., None]:
|
||||
"--onboard/--no-onboard",
|
||||
help="Run the interactive setup wizard before starting.",
|
||||
),
|
||||
transport: str | None = typer.Option(
|
||||
None,
|
||||
"--transport",
|
||||
help="Override the transport backend id.",
|
||||
),
|
||||
debug: bool = typer.Option(
|
||||
False,
|
||||
"--debug/--no-debug",
|
||||
@@ -490,6 +503,7 @@ def make_engine_cmd(engine_id: str) -> Callable[..., None]:
|
||||
) -> None:
|
||||
_run_auto_router(
|
||||
default_engine_override=engine_id,
|
||||
transport_override=transport,
|
||||
final_notify=final_notify,
|
||||
debug=debug,
|
||||
onboard=onboard,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .config import ConfigError
|
||||
from .config_store import read_raw_toml, write_raw_toml
|
||||
from .logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _ensure_table(
|
||||
config: dict[str, Any],
|
||||
key: str,
|
||||
*,
|
||||
config_path: Path,
|
||||
label: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
value = config.get(key)
|
||||
if value is None:
|
||||
table: dict[str, Any] = {}
|
||||
config[key] = table
|
||||
return table
|
||||
if not isinstance(value, dict):
|
||||
name = label or key
|
||||
raise ConfigError(f"Invalid `{name}` in {config_path}; expected a table.")
|
||||
return value
|
||||
|
||||
|
||||
def _migrate_legacy_telegram(config: dict[str, Any], *, config_path: Path) -> bool:
|
||||
has_legacy = "bot_token" in config or "chat_id" in config
|
||||
if not has_legacy:
|
||||
return False
|
||||
|
||||
transports = _ensure_table(config, "transports", config_path=config_path)
|
||||
telegram = transports.get("telegram")
|
||||
if telegram is None:
|
||||
telegram = {}
|
||||
transports["telegram"] = telegram
|
||||
if not isinstance(telegram, dict):
|
||||
raise ConfigError(
|
||||
f"Invalid `transports.telegram` in {config_path}; expected a table."
|
||||
)
|
||||
|
||||
if "bot_token" in config and "bot_token" not in telegram:
|
||||
telegram["bot_token"] = config["bot_token"]
|
||||
if "chat_id" in config and "chat_id" not in telegram:
|
||||
telegram["chat_id"] = config["chat_id"]
|
||||
|
||||
config.pop("bot_token", None)
|
||||
config.pop("chat_id", None)
|
||||
config.setdefault("transport", "telegram")
|
||||
return True
|
||||
|
||||
|
||||
def migrate_config(config: dict[str, Any], *, config_path: Path) -> list[str]:
|
||||
applied: list[str] = []
|
||||
if _migrate_legacy_telegram(config, config_path=config_path):
|
||||
applied.append("legacy-telegram")
|
||||
return applied
|
||||
|
||||
|
||||
def migrate_config_file(path: Path) -> list[str]:
|
||||
config = read_raw_toml(path)
|
||||
applied = migrate_config(config, config_path=path)
|
||||
if applied:
|
||||
write_raw_toml(config, path)
|
||||
for migration in applied:
|
||||
logger.info(
|
||||
"config.migrated",
|
||||
migration=migration,
|
||||
path=str(path),
|
||||
)
|
||||
return applied
|
||||
@@ -17,6 +17,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic_settings.sources import TomlConfigSettingsSource
|
||||
|
||||
from .config import ConfigError, ProjectConfig, ProjectsConfig, HOME_CONFIG_PATH
|
||||
from .config_migrations import migrate_config_file
|
||||
|
||||
|
||||
class TelegramTransportSettings(BaseModel):
|
||||
@@ -263,6 +264,7 @@ class TakopiSettings(BaseSettings):
|
||||
def load_settings(path: str | Path | None = None) -> tuple[TakopiSettings, Path]:
|
||||
cfg_path = _resolve_config_path(path)
|
||||
_ensure_config_file(cfg_path)
|
||||
migrate_config_file(cfg_path)
|
||||
return _load_settings_from_path(cfg_path), cfg_path
|
||||
|
||||
|
||||
@@ -275,6 +277,7 @@ def load_settings_if_exists(
|
||||
raise ConfigError(
|
||||
f"Config path {cfg_path} exists but is not a file."
|
||||
) from None
|
||||
migrate_config_file(cfg_path)
|
||||
return _load_settings_from_path(cfg_path), cfg_path
|
||||
return None
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
|
||||
from ..backends import EngineBackend
|
||||
from ..config import ProjectsConfig
|
||||
from ..router import AutoRouter
|
||||
from ..runner_bridge import ExecBridgeConfig
|
||||
from ..settings import TakopiSettings, require_telegram
|
||||
from ..transports import SetupResult, TransportBackend
|
||||
from .bridge import (
|
||||
TelegramBridgeConfig,
|
||||
TelegramPresenter,
|
||||
TelegramTransport,
|
||||
run_main_loop,
|
||||
)
|
||||
from .client import TelegramClient
|
||||
from .onboarding import check_setup, interactive_setup
|
||||
|
||||
|
||||
def _build_startup_message(
|
||||
router: AutoRouter,
|
||||
projects: ProjectsConfig,
|
||||
*,
|
||||
startup_pwd: str,
|
||||
) -> str:
|
||||
available_engines = [entry.engine for entry in router.available_entries]
|
||||
missing_engines = [entry.engine for entry in router.entries if not entry.available]
|
||||
engine_list = ", ".join(available_engines) if available_engines else "none"
|
||||
if missing_engines:
|
||||
engine_list = f"{engine_list} (not installed: {', '.join(missing_engines)})"
|
||||
project_aliases = sorted(
|
||||
{project.alias for project in projects.projects.values()},
|
||||
key=str.lower,
|
||||
)
|
||||
project_list = ", ".join(project_aliases) if project_aliases else "none"
|
||||
return (
|
||||
f"\N{OCTOPUS} **takopi is ready**\n\n"
|
||||
f"default: `{router.default_engine}` \n"
|
||||
f"agents: `{engine_list}` \n"
|
||||
f"projects: `{project_list}` \n"
|
||||
f"working in: `{startup_pwd}`"
|
||||
)
|
||||
|
||||
|
||||
class TelegramBackend(TransportBackend):
|
||||
id = "telegram"
|
||||
description = "Telegram bot"
|
||||
|
||||
def check_setup(
|
||||
self,
|
||||
engine_backend: EngineBackend,
|
||||
*,
|
||||
transport_override: str | None = None,
|
||||
) -> SetupResult:
|
||||
return check_setup(engine_backend, transport_override=transport_override)
|
||||
|
||||
def interactive_setup(self, *, force: bool) -> bool:
|
||||
return interactive_setup(force=force)
|
||||
|
||||
def lock_token(self, *, settings: TakopiSettings, config_path: Path) -> str | None:
|
||||
token, _ = require_telegram(settings, config_path)
|
||||
return token
|
||||
|
||||
def build_and_run(
|
||||
self,
|
||||
*,
|
||||
settings: TakopiSettings,
|
||||
config_path: Path,
|
||||
router: AutoRouter,
|
||||
projects: ProjectsConfig,
|
||||
final_notify: bool,
|
||||
default_engine_override: str | None,
|
||||
) -> None:
|
||||
token, chat_id = require_telegram(settings, config_path)
|
||||
startup_msg = _build_startup_message(
|
||||
router,
|
||||
projects,
|
||||
startup_pwd=os.getcwd(),
|
||||
)
|
||||
bot = TelegramClient(token)
|
||||
transport = TelegramTransport(bot)
|
||||
presenter = TelegramPresenter()
|
||||
exec_cfg = ExecBridgeConfig(
|
||||
transport=transport,
|
||||
presenter=presenter,
|
||||
final_notify=final_notify,
|
||||
)
|
||||
cfg = TelegramBridgeConfig(
|
||||
bot=bot,
|
||||
router=router,
|
||||
chat_id=chat_id,
|
||||
startup_msg=startup_msg,
|
||||
exec_cfg=exec_cfg,
|
||||
projects=projects,
|
||||
)
|
||||
anyio.run(run_main_loop, cfg)
|
||||
|
||||
|
||||
telegram_backend = TelegramBackend()
|
||||
@@ -27,19 +27,10 @@ from ..config_store import read_raw_toml, write_raw_toml
|
||||
from ..engines import list_backends
|
||||
from ..logging import suppress_logs
|
||||
from ..settings import HOME_CONFIG_PATH, load_settings, require_telegram
|
||||
from ..transports import SetupResult
|
||||
from .client import TelegramClient, TelegramRetryAfter
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SetupResult:
|
||||
issues: list[SetupIssue]
|
||||
config_path: Path = HOME_CONFIG_PATH
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not self.issues
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ChatInfo:
|
||||
chat_id: int
|
||||
@@ -81,7 +72,11 @@ def config_issue(path: Path) -> SetupIssue:
|
||||
return SetupIssue("create a config", (f" {_display_path(path)}",))
|
||||
|
||||
|
||||
def check_setup(backend: EngineBackend) -> SetupResult:
|
||||
def check_setup(
|
||||
backend: EngineBackend,
|
||||
*,
|
||||
transport_override: str | None = None,
|
||||
) -> SetupResult:
|
||||
issues: list[SetupIssue] = []
|
||||
config_path = HOME_CONFIG_PATH
|
||||
cmd = backend.cli_cmd or backend.id
|
||||
@@ -91,6 +86,8 @@ def check_setup(backend: EngineBackend) -> SetupResult:
|
||||
|
||||
try:
|
||||
settings, config_path = load_settings()
|
||||
if transport_override:
|
||||
settings = settings.model_copy(update={"transport": transport_override})
|
||||
try:
|
||||
require_telegram(settings, config_path)
|
||||
except ConfigError:
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from .backends import EngineBackend, SetupIssue
|
||||
from .config import ConfigError, ProjectsConfig
|
||||
from .router import AutoRouter
|
||||
from .settings import TakopiSettings
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SetupResult:
|
||||
issues: list[SetupIssue]
|
||||
config_path: Path
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not self.issues
|
||||
|
||||
|
||||
class TransportBackend(Protocol):
|
||||
id: str
|
||||
description: str
|
||||
|
||||
def check_setup(
|
||||
self,
|
||||
engine_backend: EngineBackend,
|
||||
*,
|
||||
transport_override: str | None = None,
|
||||
) -> SetupResult: ...
|
||||
|
||||
def interactive_setup(self, *, force: bool) -> bool: ...
|
||||
|
||||
def lock_token(
|
||||
self, *, settings: TakopiSettings, config_path: Path
|
||||
) -> str | None: ...
|
||||
|
||||
def build_and_run(
|
||||
self,
|
||||
*,
|
||||
settings: TakopiSettings,
|
||||
config_path: Path,
|
||||
router: AutoRouter,
|
||||
projects: ProjectsConfig,
|
||||
final_notify: bool,
|
||||
default_engine_override: str | None,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
_registry: dict[str, TransportBackend] = {}
|
||||
_builtins_loaded = False
|
||||
|
||||
|
||||
def register_transport(backend: TransportBackend) -> None:
|
||||
existing = _registry.get(backend.id)
|
||||
if existing is not None and existing is not backend:
|
||||
raise ConfigError(f"Transport {backend.id!r} is already registered.")
|
||||
_registry[backend.id] = backend
|
||||
|
||||
|
||||
def register_builtin_transports() -> None:
|
||||
global _builtins_loaded
|
||||
if _builtins_loaded:
|
||||
return
|
||||
from .telegram.backend import telegram_backend
|
||||
|
||||
register_transport(telegram_backend)
|
||||
_builtins_loaded = True
|
||||
|
||||
|
||||
def get_transport(transport_id: str) -> TransportBackend:
|
||||
register_builtin_transports()
|
||||
try:
|
||||
return _registry[transport_id]
|
||||
except KeyError:
|
||||
available = ", ".join(sorted(_registry))
|
||||
raise ConfigError(
|
||||
f"Unknown transport {transport_id!r}. Available: {available}."
|
||||
) from None
|
||||
|
||||
|
||||
def list_transports() -> list[str]:
|
||||
register_builtin_transports()
|
||||
return sorted(_registry)
|
||||
+12
-3
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from takopi.config import ConfigError
|
||||
from takopi.config_store import read_raw_toml
|
||||
from takopi.settings import (
|
||||
TakopiSettings,
|
||||
load_settings,
|
||||
@@ -58,12 +59,20 @@ def test_env_overrides_toml(tmp_path: Path, monkeypatch) -> None:
|
||||
assert settings.default_engine == "claude"
|
||||
|
||||
|
||||
def test_legacy_keys_rejected(tmp_path: Path) -> None:
|
||||
def test_legacy_keys_migrated(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "takopi.toml"
|
||||
config_path.write_text('bot_token = "token"\nchat_id = 123\n', encoding="utf-8")
|
||||
|
||||
with pytest.raises(ConfigError, match="transports\\.telegram"):
|
||||
load_settings(config_path)
|
||||
settings, loaded_path = load_settings(config_path)
|
||||
|
||||
assert loaded_path == config_path
|
||||
assert settings.transports.telegram.chat_id == 123
|
||||
raw = read_raw_toml(config_path)
|
||||
assert "bot_token" not in raw
|
||||
assert "chat_id" not in raw
|
||||
assert raw["transports"]["telegram"]["bot_token"] == "token"
|
||||
assert raw["transports"]["telegram"]["chat_id"] == 123
|
||||
assert raw["transport"] == "telegram"
|
||||
|
||||
|
||||
def test_validate_settings_data_rejects_invalid_bot_token_type(tmp_path: Path) -> None:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import pytest
|
||||
|
||||
from takopi import transports
|
||||
from takopi.config import ConfigError
|
||||
|
||||
|
||||
def test_transport_registry_lists_telegram() -> None:
|
||||
ids = transports.list_transports()
|
||||
assert "telegram" in ids
|
||||
|
||||
|
||||
def test_transport_registry_gets_telegram() -> None:
|
||||
backend = transports.get_transport("telegram")
|
||||
assert backend.id == "telegram"
|
||||
|
||||
|
||||
def test_transport_registry_unknown() -> None:
|
||||
with pytest.raises(ConfigError, match="Unknown transport"):
|
||||
transports.get_transport("nope")
|
||||
Reference in New Issue
Block a user