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.
|
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
|
### `cli.py` - CLI entry point
|
||||||
|
|
||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| `run()` / `main()` | Typer CLI entry points |
|
| `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
|
### `progress.py` - Progress tracking
|
||||||
|
|
||||||
@@ -261,6 +274,7 @@ Environment flags:
|
|||||||
- `PI_CODING_AGENT_DIR` (override Pi agent session directory base path)
|
- `PI_CODING_AGENT_DIR` (override Pi agent session directory base path)
|
||||||
|
|
||||||
CLI flag: `--debug` enables debug logging (overrides `TAKOPI_LOG_LEVEL`).
|
CLI flag: `--debug` enables debug logging (overrides `TAKOPI_LOG_LEVEL`).
|
||||||
|
CLI flag: `--transport <id>` overrides the configured transport backend.
|
||||||
|
|
||||||
### `telegram/onboarding.py` - Setup validation
|
### `telegram/onboarding.py` - Setup validation
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -20,7 +20,7 @@ All config lives in `~/.takopi/takopi.toml`.
|
|||||||
```toml
|
```toml
|
||||||
default_engine = "codex" # optional
|
default_engine = "codex" # optional
|
||||||
default_project = "z80" # optional
|
default_project = "z80" # optional
|
||||||
transport = "telegram" # required
|
transport = "telegram" # optional, defaults to "telegram"
|
||||||
|
|
||||||
[transports.telegram]
|
[transports.telegram]
|
||||||
bot_token = "..." # required
|
bot_token = "..." # required
|
||||||
@@ -33,6 +33,9 @@ default_engine = "codex" # optional, per-project override
|
|||||||
worktree_base = "master" # optional, base for new branches
|
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`:
|
Note on `worktrees_dir`:
|
||||||
|
|
||||||
- The default `.worktrees` lives inside the repo root. You'll see it as an
|
- 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.
|
- `default_project` must match a configured project alias.
|
||||||
- Project aliases cannot collide with engine ids or reserved commands (`/cancel`).
|
- Project aliases cannot collide with engine ids or reserved commands (`/cancel`).
|
||||||
- `default_engine` and per-project `default_engine` must be valid engine ids.
|
- `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`
|
## `takopi init`
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ global config `~/.takopi/takopi.toml`
|
|||||||
```toml
|
```toml
|
||||||
default_engine = "codex"
|
default_engine = "codex"
|
||||||
|
|
||||||
|
# optional, defaults to "telegram"
|
||||||
transport = "telegram"
|
transport = "telegram"
|
||||||
|
|
||||||
[transports.telegram]
|
[transports.telegram]
|
||||||
@@ -81,7 +82,7 @@ provider = "openai"
|
|||||||
extra_args = ["--no-color"]
|
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
|
## projects
|
||||||
|
|
||||||
@@ -122,6 +123,13 @@ takopi opencode
|
|||||||
takopi pi
|
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.
|
resume lines always route to the matching engine; subcommands only override the default for new threads.
|
||||||
|
|
||||||
send a message to the bot.
|
send a message to the bot.
|
||||||
|
|||||||
+105
-91
@@ -6,7 +6,6 @@ import sys
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import anyio
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
@@ -16,7 +15,6 @@ from .engines import get_backend, list_backends
|
|||||||
from .lockfile import LockError, LockHandle, acquire_lock, token_fingerprint
|
from .lockfile import LockError, LockHandle, acquire_lock, token_fingerprint
|
||||||
from .logging import get_logger, setup_logging
|
from .logging import get_logger, setup_logging
|
||||||
from .router import AutoRouter, RunnerEntry
|
from .router import AutoRouter, RunnerEntry
|
||||||
from .runner_bridge import ExecBridgeConfig
|
|
||||||
from .settings import (
|
from .settings import (
|
||||||
TakopiSettings,
|
TakopiSettings,
|
||||||
load_settings,
|
load_settings,
|
||||||
@@ -24,14 +22,7 @@ from .settings import (
|
|||||||
require_telegram,
|
require_telegram,
|
||||||
validate_settings_data,
|
validate_settings_data,
|
||||||
)
|
)
|
||||||
from .telegram.bridge import (
|
from .transports import SetupResult, get_transport, list_transports
|
||||||
TelegramBridgeConfig,
|
|
||||||
TelegramPresenter,
|
|
||||||
TelegramTransport,
|
|
||||||
run_main_loop,
|
|
||||||
)
|
|
||||||
from .telegram.client import TelegramClient
|
|
||||||
from .telegram.onboarding import SetupResult, check_setup, interactive_setup
|
|
||||||
from .utils.git import resolve_default_base, resolve_main_worktree_root
|
from .utils.git import resolve_default_base, resolve_main_worktree_root
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -47,6 +38,22 @@ def _version_callback(value: bool) -> None:
|
|||||||
_print_version_and_exit()
|
_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(
|
def load_and_validate_config(
|
||||||
path: str | Path | None = None,
|
path: str | Path | None = None,
|
||||||
) -> tuple[TakopiSettings, Path, str, int]:
|
) -> tuple[TakopiSettings, Path, str, int]:
|
||||||
@@ -55,11 +62,12 @@ def load_and_validate_config(
|
|||||||
return settings, config_path, token, chat_id
|
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:
|
try:
|
||||||
return acquire_lock(
|
return acquire_lock(
|
||||||
config_path=config_path,
|
config_path=config_path,
|
||||||
token_fingerprint=token_fingerprint(token),
|
token_fingerprint=fingerprint,
|
||||||
)
|
)
|
||||||
except LockError as exc:
|
except LockError as exc:
|
||||||
lines = str(exc).splitlines()
|
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:
|
def _default_engine_for_setup(override: str | None) -> str:
|
||||||
if override:
|
if override:
|
||||||
return override
|
return override
|
||||||
|
try:
|
||||||
loaded = load_settings_if_exists()
|
loaded = load_settings_if_exists()
|
||||||
|
except ConfigError:
|
||||||
|
return "codex"
|
||||||
if loaded is None:
|
if loaded is None:
|
||||||
return "codex"
|
return "codex"
|
||||||
settings, config_path = loaded
|
settings, config_path = loaded
|
||||||
@@ -173,72 +184,6 @@ def _build_router(
|
|||||||
return AutoRouter(entries=entries, default_engine=default_engine)
|
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:
|
def _config_path_display(path: Path) -> str:
|
||||||
home = Path.home()
|
home = Path.home()
|
||||||
try:
|
try:
|
||||||
@@ -259,12 +204,16 @@ def _setup_needs_config(setup: SetupResult) -> bool:
|
|||||||
|
|
||||||
def _fail_missing_config(path: Path) -> None:
|
def _fail_missing_config(path: Path) -> None:
|
||||||
display = _config_path_display(path)
|
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)
|
typer.echo(f"error: missing takopi config at {display}", err=True)
|
||||||
|
|
||||||
|
|
||||||
def _run_auto_router(
|
def _run_auto_router(
|
||||||
*,
|
*,
|
||||||
default_engine_override: str | None,
|
default_engine_override: str | None,
|
||||||
|
transport_override: str | None,
|
||||||
final_notify: bool,
|
final_notify: bool,
|
||||||
debug: bool,
|
debug: bool,
|
||||||
onboard: bool,
|
onboard: bool,
|
||||||
@@ -273,7 +222,9 @@ def _run_auto_router(
|
|||||||
lock_handle: LockHandle | None = None
|
lock_handle: LockHandle | None = None
|
||||||
try:
|
try:
|
||||||
default_engine = _default_engine_for_setup(default_engine_override)
|
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:
|
except ConfigError as e:
|
||||||
typer.echo(f"error: {e}", err=True)
|
typer.echo(f"error: {e}", err=True)
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
@@ -281,17 +232,37 @@ def _run_auto_router(
|
|||||||
if not _should_run_interactive():
|
if not _should_run_interactive():
|
||||||
typer.echo("error: --onboard requires a TTY", err=True)
|
typer.echo("error: --onboard requires a TTY", err=True)
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
if not interactive_setup(force=True):
|
if not transport_backend.interactive_setup(force=True):
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
default_engine = _default_engine_for_setup(default_engine_override)
|
default_engine = _default_engine_for_setup(default_engine_override)
|
||||||
backend = get_backend(default_engine)
|
engine_backend = get_backend(default_engine)
|
||||||
setup = check_setup(backend)
|
setup = transport_backend.check_setup(
|
||||||
|
engine_backend,
|
||||||
|
transport_override=transport_override,
|
||||||
|
)
|
||||||
if not setup.ok:
|
if not setup.ok:
|
||||||
if _setup_needs_config(setup) and _should_run_interactive():
|
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)
|
default_engine = _default_engine_for_setup(default_engine_override)
|
||||||
backend = get_backend(default_engine)
|
engine_backend = get_backend(default_engine)
|
||||||
setup = check_setup(backend)
|
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 not setup.ok:
|
||||||
if _setup_needs_config(setup):
|
if _setup_needs_config(setup):
|
||||||
_fail_missing_config(setup.config_path)
|
_fail_missing_config(setup.config_path)
|
||||||
@@ -300,17 +271,40 @@ def _run_auto_router(
|
|||||||
typer.echo(f"error: {first.title}", err=True)
|
typer.echo(f"error: {first.title}", err=True)
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
try:
|
try:
|
||||||
settings, config_path, token, chat_id = load_and_validate_config()
|
settings, config_path = load_settings()
|
||||||
lock_handle = acquire_config_lock(config_path, token)
|
if transport_override and transport_override != settings.transport:
|
||||||
cfg = _parse_bridge_config(
|
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,
|
final_notify=final_notify,
|
||||||
default_engine_override=default_engine_override,
|
default_engine_override=default_engine_override,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
config_path=config_path,
|
config_path=config_path,
|
||||||
token=token,
|
router=router,
|
||||||
chat_id=chat_id,
|
projects=projects,
|
||||||
)
|
)
|
||||||
anyio.run(run_main_loop, cfg)
|
|
||||||
except ConfigError as e:
|
except ConfigError as e:
|
||||||
typer.echo(f"error: {e}", err=True)
|
typer.echo(f"error: {e}", err=True)
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
@@ -423,6 +417,13 @@ def init(
|
|||||||
typer.echo(f"saved project {alias!r} to {_config_path_display(config_path)}")
|
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(
|
app = typer.Typer(
|
||||||
add_completion=False,
|
add_completion=False,
|
||||||
invoke_without_command=True,
|
invoke_without_command=True,
|
||||||
@@ -431,6 +432,7 @@ app = typer.Typer(
|
|||||||
|
|
||||||
|
|
||||||
app.command(name="init")(init)
|
app.command(name="init")(init)
|
||||||
|
app.command(name="transports")(transports_cmd)
|
||||||
|
|
||||||
|
|
||||||
@app.callback()
|
@app.callback()
|
||||||
@@ -453,6 +455,11 @@ def app_main(
|
|||||||
"--onboard/--no-onboard",
|
"--onboard/--no-onboard",
|
||||||
help="Run the interactive setup wizard before starting.",
|
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(
|
debug: bool = typer.Option(
|
||||||
False,
|
False,
|
||||||
"--debug/--no-debug",
|
"--debug/--no-debug",
|
||||||
@@ -463,6 +470,7 @@ def app_main(
|
|||||||
if ctx.invoked_subcommand is None:
|
if ctx.invoked_subcommand is None:
|
||||||
_run_auto_router(
|
_run_auto_router(
|
||||||
default_engine_override=None,
|
default_engine_override=None,
|
||||||
|
transport_override=transport,
|
||||||
final_notify=final_notify,
|
final_notify=final_notify,
|
||||||
debug=debug,
|
debug=debug,
|
||||||
onboard=onboard,
|
onboard=onboard,
|
||||||
@@ -482,6 +490,11 @@ def make_engine_cmd(engine_id: str) -> Callable[..., None]:
|
|||||||
"--onboard/--no-onboard",
|
"--onboard/--no-onboard",
|
||||||
help="Run the interactive setup wizard before starting.",
|
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(
|
debug: bool = typer.Option(
|
||||||
False,
|
False,
|
||||||
"--debug/--no-debug",
|
"--debug/--no-debug",
|
||||||
@@ -490,6 +503,7 @@ def make_engine_cmd(engine_id: str) -> Callable[..., None]:
|
|||||||
) -> None:
|
) -> None:
|
||||||
_run_auto_router(
|
_run_auto_router(
|
||||||
default_engine_override=engine_id,
|
default_engine_override=engine_id,
|
||||||
|
transport_override=transport,
|
||||||
final_notify=final_notify,
|
final_notify=final_notify,
|
||||||
debug=debug,
|
debug=debug,
|
||||||
onboard=onboard,
|
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 pydantic_settings.sources import TomlConfigSettingsSource
|
||||||
|
|
||||||
from .config import ConfigError, ProjectConfig, ProjectsConfig, HOME_CONFIG_PATH
|
from .config import ConfigError, ProjectConfig, ProjectsConfig, HOME_CONFIG_PATH
|
||||||
|
from .config_migrations import migrate_config_file
|
||||||
|
|
||||||
|
|
||||||
class TelegramTransportSettings(BaseModel):
|
class TelegramTransportSettings(BaseModel):
|
||||||
@@ -263,6 +264,7 @@ class TakopiSettings(BaseSettings):
|
|||||||
def load_settings(path: str | Path | None = None) -> tuple[TakopiSettings, Path]:
|
def load_settings(path: str | Path | None = None) -> tuple[TakopiSettings, Path]:
|
||||||
cfg_path = _resolve_config_path(path)
|
cfg_path = _resolve_config_path(path)
|
||||||
_ensure_config_file(cfg_path)
|
_ensure_config_file(cfg_path)
|
||||||
|
migrate_config_file(cfg_path)
|
||||||
return _load_settings_from_path(cfg_path), cfg_path
|
return _load_settings_from_path(cfg_path), cfg_path
|
||||||
|
|
||||||
|
|
||||||
@@ -275,6 +277,7 @@ def load_settings_if_exists(
|
|||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
f"Config path {cfg_path} exists but is not a file."
|
f"Config path {cfg_path} exists but is not a file."
|
||||||
) from None
|
) from None
|
||||||
|
migrate_config_file(cfg_path)
|
||||||
return _load_settings_from_path(cfg_path), cfg_path
|
return _load_settings_from_path(cfg_path), cfg_path
|
||||||
return None
|
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 ..engines import list_backends
|
||||||
from ..logging import suppress_logs
|
from ..logging import suppress_logs
|
||||||
from ..settings import HOME_CONFIG_PATH, load_settings, require_telegram
|
from ..settings import HOME_CONFIG_PATH, load_settings, require_telegram
|
||||||
|
from ..transports import SetupResult
|
||||||
from .client import TelegramClient, TelegramRetryAfter
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ChatInfo:
|
class ChatInfo:
|
||||||
chat_id: int
|
chat_id: int
|
||||||
@@ -81,7 +72,11 @@ def config_issue(path: Path) -> SetupIssue:
|
|||||||
return SetupIssue("create a config", (f" {_display_path(path)}",))
|
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] = []
|
issues: list[SetupIssue] = []
|
||||||
config_path = HOME_CONFIG_PATH
|
config_path = HOME_CONFIG_PATH
|
||||||
cmd = backend.cli_cmd or backend.id
|
cmd = backend.cli_cmd or backend.id
|
||||||
@@ -91,6 +86,8 @@ def check_setup(backend: EngineBackend) -> SetupResult:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
settings, config_path = load_settings()
|
settings, config_path = load_settings()
|
||||||
|
if transport_override:
|
||||||
|
settings = settings.model_copy(update={"transport": transport_override})
|
||||||
try:
|
try:
|
||||||
require_telegram(settings, config_path)
|
require_telegram(settings, config_path)
|
||||||
except ConfigError:
|
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
|
import pytest
|
||||||
|
|
||||||
from takopi.config import ConfigError
|
from takopi.config import ConfigError
|
||||||
|
from takopi.config_store import read_raw_toml
|
||||||
from takopi.settings import (
|
from takopi.settings import (
|
||||||
TakopiSettings,
|
TakopiSettings,
|
||||||
load_settings,
|
load_settings,
|
||||||
@@ -58,12 +59,20 @@ def test_env_overrides_toml(tmp_path: Path, monkeypatch) -> None:
|
|||||||
assert settings.default_engine == "claude"
|
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 = tmp_path / "takopi.toml"
|
||||||
config_path.write_text('bot_token = "token"\nchat_id = 123\n', encoding="utf-8")
|
config_path.write_text('bot_token = "token"\nchat_id = 123\n', encoding="utf-8")
|
||||||
|
|
||||||
with pytest.raises(ConfigError, match="transports\\.telegram"):
|
settings, loaded_path = load_settings(config_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:
|
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