diff --git a/docs/architecture.md b/docs/architecture.md index 8d16d34..a845f75 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -138,7 +138,7 @@ classDiagram ActionEvent --> Action CompletedEvent --> ResumeToken - note for Action "ActionKind: command | tool | file_change |\nweb_search | subagent | note | turn | warning" + note for Action "ActionKind: command | tool | file_change |\nweb_search | subagent | note | turn | warning | telemetry" ``` --- @@ -202,9 +202,9 @@ flowchart TD C --> D{Engine?} D -->|Claude| D1["claude --print --output-format stream-json
[--resume id] prompt"] - D -->|Codex| D2["codex exec --output jsonl
[--reconnect id] prompt"] - D -->|Pi| D3["pi --output jsonl
[--session id] prompt"] - D -->|OpenCode| D4["opencode --output jsonl
[--session id] prompt"] + D -->|Codex| D2["codex exec --json
[resume <token>] -"] + D -->|Pi| D3["pi --print --mode json
--session <id> <prompt>"] + D -->|OpenCode| D4["opencode run --format json
[--session id] -- <prompt>"] D1 --> E[Spawn Subprocess
anyio.open_process] D2 --> E @@ -320,14 +320,14 @@ flowchart TD flowchart LR subgraph Config["~/.takopi/"] toml[takopi.toml] - lock[.takopi.lock] + lock[takopi.lock] end subgraph toml_contents["takopi.toml"] direction TB global["transport
default_engine
default_project"] telegram_cfg["[transports.telegram]
bot_token = ...
chat_id = ..."] - plugins_cfg["[plugins]
enabled = [\"...\"]"] + plugins_cfg["[plugins]
enabled = [...]"] plugins_extra["[plugins.mycommand]
setting = ..."] claude_cfg["[claude]
model = ..."] codex_cfg["[codex]
model = ..."] diff --git a/docs/plugins.md b/docs/plugins.md index ad01dfc..c0e9f87 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -17,7 +17,7 @@ See `public-api.md` for the stable API surface you should depend on. ## Entrypoint groups -Takopi uses two Python entrypoint groups: +Takopi uses three Python entrypoint groups: ```toml [project.entry-points."takopi.engine_backends"] @@ -36,6 +36,7 @@ mycommand = "mycommand.backend:BACKEND" - The entrypoint value must resolve to a **backend object**: - Engine backend -> `EngineBackend` - Transport backend -> `TransportBackend` + - Command backend -> `CommandBackend` - The backend object **must** have `id == entrypoint name`. Takopi validates this at load time and will report errors via `takopi plugins --load`. @@ -76,15 +77,12 @@ Takopi supports a simple enabled list to control which plugins are visible. ```toml [plugins] enabled = ["takopi-transport-slack", "takopi-engine-acme"] -auto_install = false ``` - `enabled = []` (default) -> load all installed plugins. - If `enabled` is non-empty, **only distributions with matching names** are visible. - Distribution names are taken from package metadata (case-insensitive). - If a plugin has no resolvable distribution name and an enabled list is set, it is hidden. -- `auto_install` is **reserved** and not implemented yet. - This enabled list affects: - Engine subcommands registered in the CLI @@ -289,7 +287,7 @@ Takopi exposes a **stable plugin API** via `takopi.api`. - Depend on a compatible Takopi version range, for example: ```toml -dependencies = ["takopi>=0.11,<0.12"] +dependencies = ["takopi>=0.14,<0.15"] ``` When the plugin API changes, Takopi will bump the API version and document diff --git a/docs/public-api.md b/docs/public-api.md index 657ff68..b743076 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -17,7 +17,7 @@ subject to change. The API version is tracked by `TAKOPI_PLUGIN_API_VERSION`. - Plugins should pin to a compatible Takopi range, e.g.: ```toml -dependencies = ["takopi>=0.11,<0.12"] +dependencies = ["takopi>=0.14,<0.15"] ``` --- diff --git a/docs/transports/telegram.md b/docs/transports/telegram.md index c44cc6b..bb26b48 100644 --- a/docs/transports/telegram.md +++ b/docs/transports/telegram.md @@ -10,7 +10,7 @@ This document captures current behavior so transport changes stay intentional. ## Flow -1. CLI emits JSON events. +1. Engine CLI emits JSONL events. 2. We render progress on every step and diff against the last output. 3. Only deltas enqueue a Telegram edit. 4. High-value messages enqueue a send. @@ -91,7 +91,6 @@ Scheduling: - Ordered by `(priority, queued_at)`. - Priorities: send=0, delete=1, edit=2. - Within a priority tier, the oldest pending op runs first. -- `updated_at` is kept for debugging only. ## Rate limiting + backoff diff --git a/pyproject.toml b/pyproject.toml index b9e7b1c..d800cb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "httpx>=0.28.1", "markdown-it-py", "msgspec>=0.20.0", + "openai>=2.15.0", "pydantic>=2.12.5", "pydantic-settings>=2.12.0", "questionary>=2.1.1", diff --git a/src/takopi/cli.py b/src/takopi/cli.py index 458f0ff..d1cbe63 100644 --- a/src/takopi/cli.py +++ b/src/takopi/cli.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import shutil import sys from collections.abc import Callable from importlib.metadata import EntryPoint @@ -10,7 +9,6 @@ from pathlib import Path import typer from . import __version__ -from .backends import EngineBackend from .config import ConfigError, load_or_init_config, write_config from .config_migrations import migrate_config from .commands import get_command @@ -18,7 +16,7 @@ from .engines import get_backend, list_backend_ids from .ids import RESERVED_COMMAND_IDS, RESERVED_ENGINE_IDS from .lockfile import LockError, LockHandle, acquire_lock, token_fingerprint from .logging import get_logger, setup_logging -from .router import AutoRouter, RunnerEntry +from .runtime_loader import build_runtime_spec, resolve_plugins_allowlist from .settings import ( TakopiSettings, load_settings, @@ -36,7 +34,6 @@ from .plugins import ( normalize_allowlist, ) from .transports import SetupResult, get_transport -from .transport_runtime import TransportRuntime from .utils.git import resolve_default_base, resolve_main_worktree_root from .telegram import onboarding @@ -53,19 +50,6 @@ def _load_settings_optional() -> tuple[TakopiSettings | None, Path | None]: return loaded -def _resolve_plugins_allowlist( - settings: TakopiSettings | None, -) -> list[str] | None: - if settings is None: - return None - enabled = [ - value.strip() - for value in settings.plugins.enabled - if isinstance(value, str) and value.strip() - ] - return enabled or None - - def _print_version_and_exit() -> None: typer.echo(__version__) raise typer.Exit() @@ -128,115 +112,6 @@ def _default_engine_for_setup( return value.strip() -def _resolve_default_engine( - *, - override: str | None, - settings: TakopiSettings, - config_path: Path, - engine_ids: list[str], -) -> str: - default_engine = override or settings.default_engine or "codex" - if not isinstance(default_engine, str) or not default_engine.strip(): - raise ConfigError( - f"Invalid `default_engine` in {config_path}; expected a non-empty string." - ) - default_engine = default_engine.strip() - if default_engine not in engine_ids: - available = ", ".join(sorted(engine_ids)) - raise ConfigError( - f"Unknown default engine {default_engine!r}. Available: {available}." - ) - return default_engine - - -def _build_router( - *, - settings: TakopiSettings, - config_path: Path, - backends: list[EngineBackend], - default_engine: str, -) -> AutoRouter: - entries: list[RunnerEntry] = [] - warnings: list[str] = [] - - for backend in backends: - engine_id = backend.id - issue: str | None = None - engine_cfg: dict - try: - engine_cfg = settings.engine_config(engine_id, config_path=config_path) - except ConfigError as exc: - if engine_id == default_engine: - raise - issue = str(exc) - engine_cfg = {} - - try: - runner = backend.build_runner(engine_cfg, config_path) - except Exception as exc: - if engine_id == default_engine: - raise - issue = issue or str(exc) - if engine_cfg: - try: - runner = backend.build_runner({}, config_path) - except Exception as fallback_exc: - warnings.append(f"{engine_id}: {issue or str(fallback_exc)}") - continue - else: - warnings.append(f"{engine_id}: {issue}") - continue - - cmd = backend.cli_cmd or backend.id - if shutil.which(cmd) is None: - issue = issue or f"{cmd} not found on PATH" - - if issue and engine_id == default_engine: - raise ConfigError(f"Default engine {engine_id!r} unavailable: {issue}") - - available = issue is None - if issue and engine_id != default_engine: - warnings.append(f"{engine_id}: {issue}") - - entries.append( - RunnerEntry( - engine=engine_id, - runner=runner, - available=available, - issue=issue, - ) - ) - - for warning in warnings: - logger.warning("setup.warning", issue=warning) - - return AutoRouter(entries=entries, default_engine=default_engine) - - -def _load_backends( - *, - engine_ids: list[str], - allowlist: list[str] | None, - default_engine: str, -) -> list[EngineBackend]: - backends: list[EngineBackend] = [] - load_issues: list[str] = [] - for engine_id in engine_ids: - try: - backend = get_backend(engine_id, allowlist=allowlist) - except ConfigError as exc: - if engine_id == default_engine: - raise - load_issues.append(f"{engine_id}: {exc}") - continue - backends.append(backend) - if not backends: - raise ConfigError("No engine backends are available.") - for issue in load_issues: - logger.warning("setup.warning", issue=issue) - return backends - - def _config_path_display(path: Path) -> str: home = Path.home() try: @@ -278,7 +153,7 @@ def _run_auto_router( lock_handle: LockHandle | None = None try: settings_hint, config_hint = _load_settings_optional() - allowlist = _resolve_plugins_allowlist(settings_hint) + allowlist = resolve_plugins_allowlist(settings_hint) default_engine = _default_engine_for_setup( default_engine_override, settings=settings_hint, @@ -297,7 +172,7 @@ def _run_auto_router( if not transport_backend.interactive_setup(force=True): raise typer.Exit(code=1) settings_hint, config_hint = _load_settings_optional() - allowlist = _resolve_plugins_allowlist(settings_hint) + allowlist = resolve_plugins_allowlist(settings_hint) default_engine = _default_engine_for_setup( default_engine_override, settings=settings_hint, @@ -319,7 +194,7 @@ def _run_auto_router( ) if run_onboard and transport_backend.interactive_setup(force=True): settings_hint, config_hint = _load_settings_optional() - allowlist = _resolve_plugins_allowlist(settings_hint) + allowlist = resolve_plugins_allowlist(settings_hint) default_engine = _default_engine_for_setup( default_engine_override, settings=settings_hint, @@ -332,7 +207,7 @@ def _run_auto_router( ) elif transport_backend.interactive_setup(force=False): settings_hint, config_hint = _load_settings_optional() - allowlist = _resolve_plugins_allowlist(settings_hint) + allowlist = resolve_plugins_allowlist(settings_hint) default_engine = _default_engine_for_setup( default_engine_override, settings=settings_hint, @@ -354,30 +229,12 @@ def _run_auto_router( settings, config_path = load_settings() if transport_override and transport_override != settings.transport: settings = settings.model_copy(update={"transport": transport_override}) - allowlist = _resolve_plugins_allowlist(settings) - engine_ids = list_backend_ids(allowlist=allowlist) - projects = settings.to_projects_config( + spec = build_runtime_spec( + settings=settings, config_path=config_path, - engine_ids=engine_ids, + default_engine_override=default_engine_override, reserved=("cancel",), ) - default_engine = _resolve_default_engine( - override=default_engine_override, - settings=settings, - config_path=config_path, - engine_ids=engine_ids, - ) - backends = _load_backends( - engine_ids=engine_ids, - allowlist=allowlist, - default_engine=default_engine, - ) - router = _build_router( - settings=settings, - config_path=config_path, - backends=backends, - default_engine=default_engine, - ) transport_config = settings.transport_config( settings.transport, config_path=config_path ) @@ -386,13 +243,7 @@ def _run_auto_router( config_path=config_path, ) lock_handle = acquire_config_lock(config_path, lock_token) - runtime = TransportRuntime( - router=router, - projects=projects, - allowlist=allowlist, - config_path=config_path, - plugin_configs=settings.plugins.model_extra, - ) + runtime = spec.to_runtime(config_path=config_path) transport_backend.build_and_run( final_notify=final_notify, default_engine_override=default_engine_override, @@ -467,7 +318,7 @@ def init( alias = _prompt_alias(alias, default_alias=default_alias) settings = validate_settings_data(config, config_path=config_path) - allowlist = _resolve_plugins_allowlist(settings) + allowlist = resolve_plugins_allowlist(settings) engine_ids = list_backend_ids(allowlist=allowlist) projects_cfg = settings.to_projects_config( config_path=config_path, @@ -535,8 +386,7 @@ def chat_id( settings, _ = _load_settings_optional() if settings is not None: tg = settings.transports.telegram - if tg.bot_token is not None: - token = tg.bot_token.get_secret_value().strip() or None + token = tg.bot_token or None chat = onboarding.capture_chat_id(token=token) if chat is None: raise typer.Exit(code=1) @@ -601,7 +451,7 @@ def plugins_cmd( ) -> None: """List discovered plugins and optionally validate them.""" settings_hint, _ = _load_settings_optional() - allowlist = _resolve_plugins_allowlist(settings_hint) + allowlist = resolve_plugins_allowlist(settings_hint) allowlist_set = normalize_allowlist(allowlist) engine_eps = list_entrypoints( diff --git a/src/takopi/config.py b/src/takopi/config.py index cc9cc15..e008435 100644 --- a/src/takopi/config.py +++ b/src/takopi/config.py @@ -3,7 +3,7 @@ from __future__ import annotations import tomllib from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Iterable +from typing import Any HOME_CONFIG_PATH = Path.home() / ".takopi" / "takopi.toml" @@ -12,7 +12,27 @@ class ConfigError(RuntimeError): pass -def _read_config(cfg_path: Path) -> dict: +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 read_config(cfg_path: Path) -> dict: + if cfg_path.exists() and not cfg_path.is_file(): + raise ConfigError(f"Config path {cfg_path} exists but is not a file.") from None try: raw = cfg_path.read_text(encoding="utf-8") except FileNotFoundError: @@ -31,7 +51,7 @@ def load_or_init_config(path: str | Path | None = None) -> tuple[dict, Path]: raise ConfigError(f"Config path {cfg_path} exists but is not a file.") from None if not cfg_path.exists(): return {}, cfg_path - return _read_config(cfg_path), cfg_path + return read_config(cfg_path), cfg_path @dataclass(frozen=True, slots=True) @@ -72,166 +92,6 @@ class ProjectsConfig: return tuple(self.chat_map.keys()) -def empty_projects_config() -> ProjectsConfig: - return ProjectsConfig(projects={}, default_project=None) - - -def _normalize_engine_id( - value: str, - *, - engine_ids: Iterable[str], - config_path: Path, - label: str, -) -> str: - engine_map = {engine.lower(): engine for engine in engine_ids} - cleaned = value.strip() - if not cleaned: - raise ConfigError(f"Invalid `{label}` in {config_path}; expected a string.") - engine = engine_map.get(cleaned.lower()) - if engine is None: - available = ", ".join(sorted(engine_map.values())) - raise ConfigError( - f"Unknown `{label}` {cleaned!r} in {config_path}. Available: {available}." - ) - return engine - - -def _normalize_project_path(value: str, *, config_path: Path) -> Path: - path = Path(value).expanduser() - if not path.is_absolute(): - path = config_path.parent / path - return path - - -def parse_projects_config( - config: dict[str, Any], - *, - config_path: Path, - engine_ids: Iterable[str], - reserved: Iterable[str] = ("cancel",), - default_chat_id: int | None = None, -) -> ProjectsConfig: - default_project_raw = config.get("default_project") - default_project = None - if default_project_raw is not None: - if not isinstance(default_project_raw, str) or not default_project_raw.strip(): - raise ConfigError( - f"Invalid `default_project` in {config_path}; expected a non-empty string." - ) - default_project = default_project_raw.strip() - - projects_raw = config.get("projects") or {} - if not isinstance(projects_raw, dict): - raise ConfigError(f"Invalid `projects` in {config_path}; expected a table.") - - reserved_lower = {value.lower() for value in reserved} - engine_lower = {value.lower() for value in engine_ids} - projects: dict[str, ProjectConfig] = {} - chat_map: dict[int, str] = {} - - for raw_alias, raw_entry in projects_raw.items(): - if not isinstance(raw_alias, str) or not raw_alias.strip(): - raise ConfigError( - f"Invalid project alias in {config_path}; expected a non-empty string." - ) - alias = raw_alias.strip() - alias_key = alias.lower() - if alias_key in engine_lower or alias_key in reserved_lower: - raise ConfigError( - f"Invalid project alias {alias!r} in {config_path}; " - "aliases must not match engine ids or reserved commands." - ) - if alias_key in projects: - raise ConfigError(f"Duplicate project alias {alias!r} in {config_path}.") - if not isinstance(raw_entry, dict): - raise ConfigError( - f"Invalid project entry for {alias!r} in {config_path}; expected a table." - ) - - path_value = raw_entry.get("path") - if not isinstance(path_value, str) or not path_value.strip(): - raise ConfigError(f"Missing `path` for project {alias!r} in {config_path}.") - path = _normalize_project_path(path_value.strip(), config_path=config_path) - - worktrees_dir_raw = raw_entry.get("worktrees_dir", ".worktrees") - if not isinstance(worktrees_dir_raw, str) or not worktrees_dir_raw.strip(): - raise ConfigError( - f"Invalid `worktrees_dir` for project {alias!r} in {config_path}." - ) - worktrees_dir = Path(worktrees_dir_raw.strip()).expanduser() - - default_engine_raw = raw_entry.get("default_engine") - default_engine = None - if default_engine_raw is not None: - if not isinstance(default_engine_raw, str): - raise ConfigError( - f"Invalid `projects.{alias}.default_engine` in {config_path}; " - "expected a string." - ) - default_engine = _normalize_engine_id( - default_engine_raw, - engine_ids=engine_ids, - config_path=config_path, - label=f"projects.{alias}.default_engine", - ) - - worktree_base_raw = raw_entry.get("worktree_base") - worktree_base = None - if worktree_base_raw is not None: - if not isinstance(worktree_base_raw, str) or not worktree_base_raw.strip(): - raise ConfigError( - f"Invalid `projects.{alias}.worktree_base` in {config_path}; " - "expected a string." - ) - worktree_base = worktree_base_raw.strip() - - chat_id_raw = raw_entry.get("chat_id") - chat_id = None - if chat_id_raw is not None: - if isinstance(chat_id_raw, bool) or not isinstance(chat_id_raw, int): - raise ConfigError( - f"Invalid `projects.{alias}.chat_id` in {config_path}; " - "expected an integer." - ) - chat_id = chat_id_raw - if default_chat_id is not None and chat_id == default_chat_id: - raise ConfigError( - f"Invalid `projects.{alias}.chat_id` in {config_path}; " - "must not match transports.telegram.chat_id." - ) - if chat_id in chat_map: - existing = chat_map[chat_id] - raise ConfigError( - f"Duplicate `projects.*.chat_id` {chat_id} in {config_path}; " - f"already used by {existing!r}." - ) - chat_map[chat_id] = alias_key - - projects[alias_key] = ProjectConfig( - alias=alias, - path=path, - worktrees_dir=worktrees_dir, - default_engine=default_engine, - worktree_base=worktree_base, - chat_id=chat_id, - ) - - if default_project is not None: - default_key = default_project.lower() - if default_key not in projects: - raise ConfigError( - f"Invalid `default_project` {default_project!r} in {config_path}; " - "no matching project alias found." - ) - default_project = default_key - - return ProjectsConfig( - projects=projects, - default_project=default_project, - chat_map=chat_map, - ) - - def _toml_escape(value: str) -> str: return value.replace("\\", "\\\\").replace('"', '\\"') diff --git a/src/takopi/config_migrations.py b/src/takopi/config_migrations.py index 6de024e..b6eb0f5 100644 --- a/src/takopi/config_migrations.py +++ b/src/takopi/config_migrations.py @@ -3,28 +3,24 @@ 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 .config import ConfigError, ensure_table, read_config, write_config from .logging import get_logger logger = get_logger(__name__) -def _ensure_table( - config: dict[str, Any], +def _ensure_subtable( + parent: dict[str, Any], key: str, *, config_path: Path, - label: str | None = None, -) -> dict[str, Any]: - value = config.get(key) + label: str, +) -> dict[str, Any] | None: + value = parent.get(key) if value is None: - table: dict[str, Any] = {} - config[key] = table - return table + return None if not isinstance(value, dict): - name = label or key - raise ConfigError(f"Invalid `{name}` in {config_path}; expected a table.") + raise ConfigError(f"Invalid `{label}` in {config_path}; expected a table.") return value @@ -33,15 +29,13 @@ def _migrate_legacy_telegram(config: dict[str, Any], *, config_path: Path) -> bo 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." - ) + transports = ensure_table(config, "transports", config_path=config_path) + telegram = ensure_table( + transports, + "telegram", + config_path=config_path, + label="transports.telegram", + ) if "bot_token" in config and "bot_token" not in telegram: telegram["bot_token"] = config["bot_token"] @@ -55,27 +49,32 @@ def _migrate_legacy_telegram(config: dict[str, Any], *, config_path: Path) -> bo def _migrate_topics_scope(config: dict[str, Any], *, config_path: Path) -> bool: - transports = config.get("transports") + transports = _ensure_subtable( + config, + "transports", + config_path=config_path, + label="transports", + ) if transports is None: return False - if not isinstance(transports, dict): - raise ConfigError(f"Invalid `transports` in {config_path}; expected a table.") - telegram = transports.get("telegram") + telegram = _ensure_subtable( + transports, + "telegram", + config_path=config_path, + label="transports.telegram", + ) if telegram is None: return False - if not isinstance(telegram, dict): - raise ConfigError( - f"Invalid `transports.telegram` in {config_path}; expected a table." - ) - topics = telegram.get("topics") + topics = _ensure_subtable( + telegram, + "topics", + config_path=config_path, + label="transports.telegram.topics", + ) if topics is None: return False - if not isinstance(topics, dict): - raise ConfigError( - f"Invalid `transports.telegram.topics` in {config_path}; expected a table." - ) if "mode" not in topics: return False @@ -112,10 +111,10 @@ def migrate_config(config: dict[str, Any], *, config_path: Path) -> list[str]: def migrate_config_file(path: Path) -> list[str]: - config = read_raw_toml(path) + config = read_config(path) applied = migrate_config(config, config_path=path) if applied: - write_raw_toml(config, path) + write_config(config, path) for migration in applied: logger.info( "config.migrated", diff --git a/src/takopi/config_store.py b/src/takopi/config_store.py deleted file mode 100644 index 4c48a7f..0000000 --- a/src/takopi/config_store.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations - -import tomllib -from pathlib import Path -from typing import Any - -from .config import ConfigError, dump_toml - - -def read_raw_toml(path: Path) -> dict[str, Any]: - if path.exists() and not path.is_file(): - raise ConfigError(f"Config path {path} exists but is not a file.") from None - try: - raw = path.read_text(encoding="utf-8") - except FileNotFoundError: - raise ConfigError(f"Missing config file {path}.") from None - except OSError as exc: - raise ConfigError(f"Failed to read config file {path}: {exc}") from exc - try: - return tomllib.loads(raw) - except tomllib.TOMLDecodeError as exc: - raise ConfigError(f"Malformed TOML in {path}: {exc}") from None - - -def write_raw_toml(config: dict[str, Any], path: Path) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(dump_toml(config), encoding="utf-8") diff --git a/src/takopi/config_watch.py b/src/takopi/config_watch.py index b992bc2..5fe3c92 100644 --- a/src/takopi/config_watch.py +++ b/src/takopi/config_watch.py @@ -14,6 +14,12 @@ from .transport_runtime import TransportRuntime logger = get_logger(__name__) +__all__ = [ + "ConfigReload", + "config_status", + "watch_config", +] + @dataclass(frozen=True, slots=True) class ConfigReload: @@ -22,7 +28,7 @@ class ConfigReload: config_path: Path -def _config_status(path: Path) -> tuple[str, tuple[int, int] | None]: +def config_status(path: Path) -> tuple[str, tuple[int, int] | None]: try: stat = path.stat() except FileNotFoundError: @@ -64,7 +70,7 @@ async def watch_config( reserved_tuple = tuple(reserved) config_path = config_path.expanduser().resolve() watch_root = config_path.parent - status, signature = _config_status(config_path) + status, signature = config_status(config_path) last_status = status if status != "ok": logger.warning("config.watch.unavailable", path=str(config_path), status=status) @@ -73,7 +79,7 @@ async def watch_config( if not any(Path(path) == config_path for _, path in changes): continue - status, current = _config_status(config_path) + status, current = config_status(config_path) if status != "ok": if status != last_status: logger.warning( @@ -123,6 +129,6 @@ async def watch_config( error_type=exc.__class__.__name__, ) - _, signature = _config_status(config_path) + _, signature = config_status(config_path) if signature is None: signature = current diff --git a/src/takopi/plugins.py b/src/takopi/plugins.py index d706d6e..c130ed0 100644 --- a/src/takopi/plugins.py +++ b/src/takopi/plugins.py @@ -41,8 +41,7 @@ class PluginNotFound(LookupError): super().__init__(message) -_LOAD_ERRORS: list[PluginLoadError] = [] -_LOAD_ERROR_KEYS: set[tuple[str, str, str, str | None, str]] = set() +_LOAD_ERRORS: dict[tuple[str, str, str, str | None, str], PluginLoadError] = {} _LOADED: dict[tuple[str, str], Any] = {} @@ -52,33 +51,27 @@ def _error_key(error: PluginLoadError) -> tuple[str, str, str, str | None, str]: def _record_error(error: PluginLoadError) -> None: key = _error_key(error) - if key in _LOAD_ERROR_KEYS: - return - _LOAD_ERROR_KEYS.add(key) - _LOAD_ERRORS.append(error) + _LOAD_ERRORS.setdefault(key, error) def get_load_errors() -> tuple[PluginLoadError, ...]: - return tuple(_LOAD_ERRORS) + return tuple(_LOAD_ERRORS.values()) def clear_load_errors(*, group: str | None = None, name: str | None = None) -> None: if group is None and name is None: _LOAD_ERRORS.clear() - _LOAD_ERROR_KEYS.clear() return - remaining: list[PluginLoadError] = [] - _LOAD_ERROR_KEYS.clear() - for error in _LOAD_ERRORS: + remaining: dict[tuple[str, str, str, str | None, str], PluginLoadError] = {} + for key, error in _LOAD_ERRORS.items(): if group is not None and error.group != group: - remaining.append(error) - _LOAD_ERROR_KEYS.add(_error_key(error)) + remaining[key] = error continue if name is not None and error.name != name: - remaining.append(error) - _LOAD_ERROR_KEYS.add(_error_key(error)) + remaining[key] = error continue - _LOAD_ERRORS[:] = remaining + _LOAD_ERRORS.clear() + _LOAD_ERRORS.update(remaining) def reset_plugin_state() -> None: diff --git a/src/takopi/runners/codex.py b/src/takopi/runners/codex.py index 1fb02bc..dae835d 100644 --- a/src/takopi/runners/codex.py +++ b/src/takopi/runners/codex.py @@ -20,6 +20,13 @@ logger = get_logger(__name__) ENGINE: EngineId = EngineId("codex") +__all__ = [ + "ENGINE", + "CodexRunner", + "find_exec_only_flag", + "translate_codex_event", +] + _RESUME_RE = re.compile(r"(?im)^\s*`?codex\s+resume\s+(?P[^`\s]+)`?\s*$") _RECONNECTING_RE = re.compile( r"^Reconnecting\.{3}\s*(?P\d+)/(?P\d+)\s*$", @@ -40,7 +47,7 @@ _EXEC_ONLY_PREFIXES = ( ) -def _find_exec_only_flag(extra_args: list[str]) -> str | None: +def find_exec_only_flag(extra_args: list[str]) -> str | None: for arg in extra_args: if arg in _EXEC_ONLY_FLAGS: return arg @@ -611,7 +618,7 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner: f"Invalid `codex.extra_args` in {config_path}; expected a list of strings." ) - exec_only_flag = _find_exec_only_flag(extra_args) + exec_only_flag = find_exec_only_flag(extra_args) if exec_only_flag: raise ConfigError( f"Invalid `codex.extra_args` in {config_path}; exec-only flag " diff --git a/src/takopi/runtime_loader.py b/src/takopi/runtime_loader.py index 35f556c..acbba3b 100644 --- a/src/takopi/runtime_loader.py +++ b/src/takopi/runtime_loader.py @@ -22,6 +22,7 @@ class RuntimeSpec: projects: ProjectsConfig allowlist: list[str] | None plugin_configs: Mapping[str, Any] | None + watch_config: bool = False def to_runtime(self, *, config_path: Path | None) -> TransportRuntime: return TransportRuntime( @@ -30,6 +31,7 @@ class RuntimeSpec: allowlist=self.allowlist, config_path=config_path, plugin_configs=self.plugin_configs, + watch_config=self.watch_config, ) def apply(self, runtime: TransportRuntime, *, config_path: Path | None) -> None: @@ -39,6 +41,7 @@ class RuntimeSpec: allowlist=self.allowlist, config_path=config_path, plugin_configs=self.plugin_configs, + watch_config=self.watch_config, ) @@ -47,11 +50,7 @@ def resolve_plugins_allowlist( ) -> list[str] | None: if settings is None: return None - enabled = [ - value.strip() - for value in settings.plugins.enabled - if isinstance(value, str) and value.strip() - ] + enabled = list(settings.plugins.enabled) return enabled or None @@ -63,11 +62,6 @@ def resolve_default_engine( engine_ids: list[str], ) -> str: default_engine = override or settings.default_engine or "codex" - if not isinstance(default_engine, str) or not default_engine.strip(): - raise ConfigError( - f"Invalid `default_engine` in {config_path}; expected a non-empty string." - ) - default_engine = default_engine.strip() if default_engine not in engine_ids: available = ", ".join(sorted(engine_ids)) raise ConfigError( @@ -200,4 +194,5 @@ def build_runtime_spec( projects=projects, allowlist=allowlist, plugin_configs=settings.plugins.model_extra, + watch_config=settings.watch_config, ) diff --git a/src/takopi/settings.py b/src/takopi/settings.py index 9ed1105..5cc2467 100644 --- a/src/takopi/settings.py +++ b/src/takopi/settings.py @@ -1,18 +1,18 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Iterable +from typing import Annotated, Any, Iterable, Literal from pydantic import ( BaseModel, ConfigDict, Field, - SecretStr, ValidationError, - field_serializer, + StringConstraints, field_validator, model_validator, ) +from pydantic.types import StrictInt from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings.sources import TomlConfigSettingsSource @@ -21,39 +21,52 @@ from .config import ( HOME_CONFIG_PATH, ProjectConfig, ProjectsConfig, - _normalize_engine_id, - _normalize_project_path, ) from .config_migrations import migrate_config_file +NonEmptyStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] + + +def _normalize_engine_id( + value: str, + *, + engine_ids: Iterable[str], + config_path: Path, + label: str, +) -> str: + engine_map = {engine.lower(): engine for engine in engine_ids} + engine = engine_map.get(value.lower()) + if engine is None: + available = ", ".join(sorted(engine_map.values())) + raise ConfigError( + f"Unknown `{label}` {value!r} in {config_path}. Available: {available}." + ) + return engine + + +def _normalize_project_path(value: str, *, config_path: Path) -> Path: + path = Path(value).expanduser() + if not path.is_absolute(): + path = config_path.parent / path + return path + + class TelegramTopicsSettings(BaseModel): - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) enabled: bool = False - scope: str = "auto" - - @field_validator("scope", mode="before") - @classmethod - def _validate_scope(cls, value: Any) -> str: - if not isinstance(value, str): - raise ValueError("topics.scope must be a string") - cleaned = value.strip() - if cleaned not in {"auto", "main", "projects", "all"}: - raise ValueError( - "topics.scope must be 'auto', 'main', 'projects', or 'all'" - ) - return cleaned + scope: Literal["auto", "main", "projects", "all"] = "auto" class TelegramFilesSettings(BaseModel): - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) enabled: bool = False auto_put: bool = True - uploads_dir: str = "incoming" - allowed_user_ids: list[int] = Field(default_factory=list) - deny_globs: list[str] = Field( + uploads_dir: NonEmptyStr = "incoming" + allowed_user_ids: list[StrictInt] = Field(default_factory=list) + deny_globs: list[NonEmptyStr] = Field( default_factory=lambda: [ ".git/**", ".env", @@ -63,81 +76,23 @@ class TelegramFilesSettings(BaseModel): ] ) - @field_validator("uploads_dir", mode="before") + @field_validator("uploads_dir") @classmethod - def _validate_uploads_dir(cls, value: Any) -> Any: - if value is None: - raise ValueError("files.uploads_dir must be a string") - if not isinstance(value, str): - raise ValueError("files.uploads_dir must be a string") - cleaned = value.strip() - if not cleaned: - raise ValueError("files.uploads_dir must be a non-empty string") - if Path(cleaned).is_absolute(): + def _validate_uploads_dir(cls, value: str) -> str: + if Path(value).is_absolute(): raise ValueError("files.uploads_dir must be a relative path") - return cleaned - - @field_validator("allowed_user_ids", mode="before") - @classmethod - def _validate_allowed_users(cls, value: Any) -> Any: - if value is None: - return [] - if not isinstance(value, list): - raise ValueError("files.allowed_user_ids must be a list of integers") - for item in value: - if isinstance(item, bool) or not isinstance(item, int): - raise ValueError("files.allowed_user_ids must be a list of integers") return value - @field_validator("deny_globs", mode="before") - @classmethod - def _validate_deny_globs(cls, value: Any) -> Any: - if value is None: - return [] - if not isinstance(value, list): - raise ValueError("files.deny_globs must be a list of strings") - cleaned: list[str] = [] - for item in value: - if not isinstance(item, str): - raise ValueError("files.deny_globs must be a list of strings") - stripped = item.strip() - if not stripped: - raise ValueError("files.deny_globs entries must be non-empty strings") - cleaned.append(stripped) - return cleaned - class TelegramTransportSettings(BaseModel): - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) - bot_token: SecretStr | None = None - chat_id: int | None = None + bot_token: NonEmptyStr + chat_id: StrictInt voice_transcription: bool = False topics: TelegramTopicsSettings = Field(default_factory=TelegramTopicsSettings) files: TelegramFilesSettings = Field(default_factory=TelegramFilesSettings) - @field_validator("bot_token", mode="before") - @classmethod - def _validate_bot_token(cls, value: Any) -> Any: - if value is None: - return None - if not isinstance(value, str): - raise ValueError("bot_token must be a string") - return value - - @field_validator("chat_id", mode="before") - @classmethod - def _validate_chat_id(cls, value: Any) -> Any: - if value is None: - return None - if isinstance(value, bool) or not isinstance(value, int): - raise ValueError("chat_id must be an integer") - return value - - @field_serializer("bot_token") - def _dump_token(self, value: SecretStr | None) -> str | None: - return value.get_secret_value() if value else None - class TransportsSettings(BaseModel): telegram: TelegramTransportSettings = Field( @@ -148,47 +103,19 @@ class TransportsSettings(BaseModel): class PluginsSettings(BaseModel): - enabled: list[str] = Field(default_factory=list) - auto_install: bool = False + enabled: list[NonEmptyStr] = Field(default_factory=list) - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra="allow", str_strip_whitespace=True) class ProjectSettings(BaseModel): - path: str - worktrees_dir: str = ".worktrees" - default_engine: str | None = None - worktree_base: str | None = None - chat_id: int | None = None + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) - model_config = ConfigDict(extra="allow") - - @field_validator( - "path", - "worktrees_dir", - "default_engine", - "worktree_base", - mode="before", - ) - @classmethod - def _validate_strings(cls, value: Any, info) -> Any: - if value is None: - return None - if not isinstance(value, str): - raise ValueError(f"{info.field_name} must be a string") - cleaned = value.strip() - if not cleaned: - raise ValueError(f"{info.field_name} must be a non-empty string") - return cleaned - - @field_validator("chat_id", mode="before") - @classmethod - def _validate_chat_id(cls, value: Any) -> Any: - if value is None: - return None - if isinstance(value, bool) or not isinstance(value, int): - raise ValueError("chat_id must be an integer") - return value + path: NonEmptyStr + worktrees_dir: NonEmptyStr = ".worktrees" + default_engine: NonEmptyStr | None = None + worktree_base: NonEmptyStr | None = None + chat_id: StrictInt | None = None class TakopiSettings(BaseSettings): @@ -196,14 +123,15 @@ class TakopiSettings(BaseSettings): extra="allow", env_prefix="TAKOPI__", env_nested_delimiter="__", + str_strip_whitespace=True, ) watch_config: bool = False - default_engine: str = "codex" - default_project: str | None = None + default_engine: NonEmptyStr = "codex" + default_project: NonEmptyStr | None = None projects: dict[str, ProjectSettings] = Field(default_factory=dict) - transport: str = "telegram" + transport: NonEmptyStr = "telegram" transports: TransportsSettings = Field(default_factory=TransportsSettings) plugins: PluginsSettings = Field(default_factory=PluginsSettings) @@ -218,30 +146,6 @@ class TakopiSettings(BaseSettings): ) return data - @field_validator("default_engine", "transport", mode="before") - @classmethod - def _validate_required_strings(cls, value: Any, info) -> Any: - if value is None: - raise ValueError(f"{info.field_name} must be a non-empty string") - if not isinstance(value, str): - raise ValueError(f"{info.field_name} must be a string") - cleaned = value.strip() - if not cleaned: - raise ValueError(f"{info.field_name} must be a non-empty string") - return cleaned - - @field_validator("default_project", mode="before") - @classmethod - def _validate_default_project(cls, value: Any) -> Any: - if value is None: - return None - if not isinstance(value, str): - raise ValueError("default_project must be a string") - cleaned = value.strip() - if not cleaned: - raise ValueError("default_project must be a non-empty string") - return cleaned - @classmethod def settings_customise_sources( cls, @@ -302,11 +206,7 @@ class TakopiSettings(BaseSettings): chat_map: dict[int, str] = {} for raw_alias, entry in self.projects.items(): - if not isinstance(raw_alias, str) or not raw_alias.strip(): - raise ConfigError( - f"Invalid project alias in {config_path}; expected a non-empty string." - ) - alias = raw_alias.strip() + alias = raw_alias alias_key = alias.lower() if alias_key in engine_map or alias_key in reserved_lower: raise ConfigError( @@ -318,56 +218,24 @@ class TakopiSettings(BaseSettings): f"Duplicate project alias {alias!r} in {config_path}." ) - path_value = entry.path - if not isinstance(path_value, str) or not path_value.strip(): - raise ConfigError( - f"Missing `path` for project {alias!r} in {config_path}." - ) - path = _normalize_project_path(path_value.strip(), config_path=config_path) + path = _normalize_project_path(entry.path, config_path=config_path) - worktrees_dir_raw = entry.worktrees_dir - if not isinstance(worktrees_dir_raw, str) or not worktrees_dir_raw.strip(): - raise ConfigError( - f"Invalid `worktrees_dir` for project {alias!r} in {config_path}." - ) - worktrees_dir = Path(worktrees_dir_raw.strip()).expanduser() + worktrees_dir = Path(entry.worktrees_dir).expanduser() - default_engine_raw = entry.default_engine default_engine = None - if default_engine_raw is not None: - if not isinstance(default_engine_raw, str): - raise ConfigError( - f"Invalid `projects.{alias}.default_engine` in {config_path}; " - "expected a string." - ) + if entry.default_engine is not None: default_engine = _normalize_engine_id( - default_engine_raw, + entry.default_engine, engine_ids=engine_ids, config_path=config_path, label=f"projects.{alias}.default_engine", ) - worktree_base_raw = entry.worktree_base - worktree_base = None - if worktree_base_raw is not None: - if ( - not isinstance(worktree_base_raw, str) - or not worktree_base_raw.strip() - ): - raise ConfigError( - f"Invalid `projects.{alias}.worktree_base` in {config_path}; " - "expected a string." - ) - worktree_base = worktree_base_raw.strip() + worktree_base = entry.worktree_base chat_id = entry.chat_id if chat_id is not None: - if isinstance(chat_id, bool) or not isinstance(chat_id, int): - raise ConfigError( - f"Invalid `projects.{alias}.chat_id` in {config_path}; " - "expected an integer." - ) - if default_chat_id is not None and chat_id == default_chat_id: + if chat_id == default_chat_id: raise ConfigError( f"Invalid `projects.{alias}.chat_id` in {config_path}; " "must not match transports.telegram.chat_id." @@ -442,27 +310,9 @@ def require_telegram(settings: TakopiSettings, config_path: Path) -> tuple[str, "(telegram only for now)." ) tg = settings.transports.telegram - if tg.bot_token is None or not tg.bot_token.get_secret_value().strip(): + if not tg.bot_token: raise ConfigError(f"Missing bot token in {config_path}.") - if tg.chat_id is None: - raise ConfigError(f"Missing chat_id in {config_path}.") - if isinstance(tg.chat_id, bool) or not isinstance(tg.chat_id, int): - raise ConfigError(f"Invalid `chat_id` in {config_path}; expected an integer.") - return tg.bot_token.get_secret_value().strip(), tg.chat_id - - -def require_telegram_config( - config: dict[str, object], config_path: Path -) -> tuple[str, int]: - raw_token = config.get("bot_token") - if raw_token is None or not isinstance(raw_token, str) or not raw_token.strip(): - raise ConfigError(f"Missing bot token in {config_path}.") - raw_chat_id = config.get("chat_id") - if raw_chat_id is None: - raise ConfigError(f"Missing chat_id in {config_path}.") - if isinstance(raw_chat_id, bool) or not isinstance(raw_chat_id, int): - raise ConfigError(f"Invalid `chat_id` in {config_path}; expected an integer.") - return raw_token.strip(), raw_chat_id + return tg.bot_token, tg.chat_id def _resolve_config_path(path: str | Path | None) -> Path: diff --git a/src/takopi/telegram/backend.py b/src/takopi/telegram/backend.py index 10e2f68..4916ab1 100644 --- a/src/takopi/telegram/backend.py +++ b/src/takopi/telegram/backend.py @@ -2,21 +2,14 @@ from __future__ import annotations import os from pathlib import Path +from typing import cast import anyio from ..backends import EngineBackend from ..runner_bridge import ExecBridgeConfig -from ..config import ConfigError from ..logging import get_logger -from pydantic import ValidationError -from ..settings import ( - TelegramFilesSettings, - TelegramTopicsSettings, - load_settings, - require_telegram_config, -) from ..transports import SetupResult, TransportBackend from ..transport_runtime import TransportRuntime from .bridge import ( @@ -25,7 +18,6 @@ from .bridge import ( TelegramTransport, TelegramFilesConfig, TelegramTopicsConfig, - TelegramVoiceTranscriptionConfig, run_main_loop, ) from .client import TelegramClient @@ -57,54 +49,31 @@ def _build_startup_message( ) -def _build_voice_transcription_config( - transport_config: dict[str, object], -) -> TelegramVoiceTranscriptionConfig: - return TelegramVoiceTranscriptionConfig( - enabled=bool(transport_config.get("voice_transcription", False)), - ) - - -def _build_topics_config( - transport_config: dict[str, object], - *, - config_path: Path, -) -> TelegramTopicsConfig: - raw = transport_config.get("topics") or {} - if not isinstance(raw, dict): - raise ConfigError( - f"Invalid `transports.telegram.topics` in {config_path}; expected a table." - ) - try: - settings = TelegramTopicsSettings.model_validate(raw) - except ValidationError as exc: - raise ConfigError(f"Invalid topics config in {config_path}: {exc}") from exc +def _build_topics_config(transport_config: dict[str, object]) -> TelegramTopicsConfig: + raw = cast(dict[str, object], transport_config.get("topics", {})) return TelegramTopicsConfig( - enabled=settings.enabled, - scope=settings.scope, + enabled=cast(bool, raw.get("enabled", False)), + scope=cast(str, raw.get("scope", "auto")), ) -def _build_files_config( - transport_config: dict[str, object], - *, - config_path: Path, -) -> TelegramFilesConfig: - raw = transport_config.get("files") or {} - if not isinstance(raw, dict): - raise ConfigError( - f"Invalid `transports.telegram.files` in {config_path}; expected a table." - ) - try: - settings = TelegramFilesSettings.model_validate(raw) - except ValidationError as exc: - raise ConfigError(f"Invalid files config in {config_path}: {exc}") from exc +def _build_files_config(transport_config: dict[str, object]) -> TelegramFilesConfig: + defaults = TelegramFilesConfig() + raw = cast(dict[str, object], transport_config.get("files", {})) return TelegramFilesConfig( - enabled=settings.enabled, - auto_put=settings.auto_put, - uploads_dir=settings.uploads_dir, - allowed_user_ids=frozenset(settings.allowed_user_ids), - deny_globs=tuple(settings.deny_globs), + enabled=cast(bool, raw.get("enabled", defaults.enabled)), + auto_put=cast(bool, raw.get("auto_put", defaults.auto_put)), + uploads_dir=cast(str, raw.get("uploads_dir", defaults.uploads_dir)), + max_upload_bytes=defaults.max_upload_bytes, + max_download_bytes=defaults.max_download_bytes, + allowed_user_ids=frozenset( + cast( + list[int], raw.get("allowed_user_ids", list(defaults.allowed_user_ids)) + ) + ), + deny_globs=tuple( + cast(list[str], raw.get("deny_globs", list(defaults.deny_globs))) + ), ) @@ -126,8 +95,8 @@ class TelegramBackend(TransportBackend): def lock_token( self, *, transport_config: dict[str, object], config_path: Path ) -> str | None: - token, _ = require_telegram_config(transport_config, config_path) - return token + _ = config_path + return cast(str, transport_config.get("bot_token")) def build_and_run( self, @@ -138,18 +107,8 @@ class TelegramBackend(TransportBackend): final_notify: bool, default_engine_override: str | None, ) -> None: - watch_enabled = False - try: - settings, _ = load_settings(config_path) - except ConfigError as exc: - logger.warning( - "config.watch.disabled", - error=str(exc), - ) - else: - watch_enabled = settings.watch_config - - token, chat_id = require_telegram_config(transport_config, config_path) + token = cast(str, transport_config.get("bot_token")) + chat_id = cast(int, transport_config.get("chat_id")) startup_msg = _build_startup_message( runtime, startup_pwd=os.getcwd(), @@ -162,16 +121,17 @@ class TelegramBackend(TransportBackend): presenter=presenter, final_notify=final_notify, ) - voice_transcription = _build_voice_transcription_config(transport_config) - topics = _build_topics_config(transport_config, config_path=config_path) - files = _build_files_config(transport_config, config_path=config_path) + topics = _build_topics_config(transport_config) + files = _build_files_config(transport_config) cfg = TelegramBridgeConfig( bot=bot, runtime=runtime, chat_id=chat_id, startup_msg=startup_msg, exec_cfg=exec_cfg, - voice_transcription=voice_transcription, + voice_transcription=cast( + bool, transport_config.get("voice_transcription", False) + ), topics=topics, files=files, ) @@ -179,7 +139,7 @@ class TelegramBackend(TransportBackend): async def run_loop() -> None: await run_main_loop( cfg, - watch_config=watch_enabled, + watch_config=runtime.watch_config, default_engine_override=default_engine_override, transport_id=self.id, transport_config=transport_config, diff --git a/src/takopi/telegram/bridge.py b/src/takopi/telegram/bridge.py index 35afee0..1f1c526 100644 --- a/src/takopi/telegram/bridge.py +++ b/src/takopi/telegram/bridge.py @@ -1,79 +1,37 @@ from __future__ import annotations -import os -from collections.abc import AsyncIterator, Awaitable, Callable, Sequence +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from functools import partial -from pathlib import Path +from typing import cast -import anyio - -from ..commands import ( - CommandContext, - CommandExecutor, - RunMode, - RunRequest, - RunResult, - get_command, - list_command_ids, -) -from ..context import RunContext -from ..config import ConfigError -from ..config_watch import ConfigReload, watch_config as watch_config_changes -from ..directives import DirectiveError -from ..ids import RESERVED_COMMAND_IDS, is_valid_id -from ..runner_bridge import ( - ExecBridgeConfig, - IncomingMessage as RunnerIncomingMessage, - RunningTask, - RunningTasks, - handle_message, -) -from ..logging import bind_run_context, clear_context, get_logger +from ..logging import get_logger from ..markdown import MarkdownFormatter, MarkdownParts -from ..model import EngineId, ResumeToken -from ..progress import ProgressState, ProgressTracker -from ..router import RunnerUnavailableError -from ..runner import Runner -from ..scheduler import ThreadJob, ThreadScheduler +from ..progress import ProgressState +from ..runner_bridge import ExecBridgeConfig, RunningTask, RunningTasks from ..transport import MessageRef, RenderedMessage, SendOptions, Transport -from ..plugins import COMMAND_GROUP, list_entrypoints -from ..utils.paths import reset_run_base_dir, set_run_base_dir -from ..transport_runtime import ResolvedMessage, TransportRuntime -from .client import BotClient, poll_incoming -from .files import ( - default_upload_name, - default_upload_path, - deny_reason, - file_get_usage, - file_put_usage, - format_bytes, - normalize_relative_path, - parse_file_command, - parse_file_prompt, - resolve_path_within_root, - split_command_args, - write_bytes_atomic, - ZipTooLargeError, - zip_directory, -) -from .types import ( - TelegramCallbackQuery, - TelegramDocument, - TelegramIncomingMessage, - TelegramIncomingUpdate, -) +from ..transport_runtime import TransportRuntime +from ..context import RunContext +from ..model import ResumeToken +from .client import BotClient from .render import prepare_telegram -from .topic_state import TopicStateStore, TopicThreadSnapshot, resolve_state_path -from .transcribe import transcribe_audio +from .types import TelegramCallbackQuery, TelegramIncomingMessage logger = get_logger(__name__) -_MAX_BOT_COMMANDS = 100 -_OPENAI_AUDIO_MAX_BYTES = 25 * 1024 * 1024 -_OPENAI_TRANSCRIPTION_MODEL = "gpt-4o-mini-transcribe" -_OPENAI_TRANSCRIPTION_CHUNKING = "auto" -_MEDIA_GROUP_DEBOUNCE_S = 1.0 +__all__ = [ + "TelegramBridgeConfig", + "TelegramFilesConfig", + "TelegramTopicsConfig", + "TelegramPresenter", + "TelegramTransport", + "build_bot_commands", + "handle_callback_cancel", + "handle_cancel", + "is_cancel_command", + "run_main_loop", + "send_with_resume", +] + CANCEL_CALLBACK_DATA = "takopi:cancel" CANCEL_MARKUP = { "inline_keyboard": [[{"text": "cancel", "callback_data": CANCEL_CALLBACK_DATA}]] @@ -81,349 +39,6 @@ CANCEL_MARKUP = { CLEAR_MARKUP = {"inline_keyboard": []} -def _is_cancel_command(text: str) -> bool: - stripped = text.strip() - if not stripped: - return False - command = stripped.split(maxsplit=1)[0] - return command == "/cancel" or command.startswith("/cancel@") - - -def _parse_slash_command(text: str) -> tuple[str | None, str]: - stripped = text.lstrip() - if not stripped.startswith("/"): - return None, text - lines = stripped.splitlines() - if not lines: - return None, text - first_line = lines[0] - token, _, rest = first_line.partition(" ") - command = token[1:] - if not command: - return None, text - if "@" in command: - command = command.split("@", 1)[0] - args_text = rest - if len(lines) > 1: - tail = "\n".join(lines[1:]) - args_text = f"{args_text}\n{tail}" if args_text else tail - return command.lower(), args_text - - -_TOPICS_COMMANDS = {"ctx", "new", "topic"} - - -def _resolve_topics_scope(cfg: TelegramBridgeConfig) -> tuple[str, frozenset[int]]: - scope = cfg.topics.scope - project_ids = set(cfg.runtime.project_chat_ids()) - if scope == "auto": - scope = "projects" if project_ids else "main" - if scope == "main": - return scope, frozenset({cfg.chat_id}) - if scope == "projects": - return scope, frozenset(project_ids) - if scope == "all": - return scope, frozenset({cfg.chat_id, *project_ids}) - raise ValueError(f"Invalid topics.scope: {cfg.topics.scope!r}") - - -def _topics_scope_label(cfg: TelegramBridgeConfig) -> str: - resolved, _ = _resolve_topics_scope(cfg) - if cfg.topics.scope == "auto": - return f"auto ({resolved})" - return resolved - - -def _topics_chat_project(cfg: TelegramBridgeConfig, chat_id: int) -> str | None: - context = cfg.runtime.default_context_for_chat(chat_id) - return context.project if context is not None else None - - -def _topics_chat_allowed(cfg: TelegramBridgeConfig, chat_id: int) -> bool: - if not cfg.topics.enabled: - return False - _, scope_chat_ids = _resolve_topics_scope(cfg) - return chat_id in scope_chat_ids - - -def _topics_command_error(cfg: TelegramBridgeConfig, chat_id: int) -> str | None: - if _topics_chat_allowed(cfg, chat_id): - return None - resolved, _ = _resolve_topics_scope(cfg) - if resolved == "main": - if cfg.topics.scope == "auto": - return ( - "topics commands are only available in the main chat (auto scope). " - 'to use topics in project chats, set `topics.scope = "projects"`.' - ) - return "topics commands are only available in the main chat." - if resolved == "projects": - if cfg.topics.scope == "auto": - return ( - "topics commands are only available in project chats (auto scope). " - 'to use topics in the main chat, set `topics.scope = "main"`.' - ) - return "topics commands are only available in project chats." - return "topics commands are only available in the main or project chats." - - -def _merge_topic_context( - *, chat_project: str | None, bound: RunContext | None -) -> RunContext | None: - if chat_project is None: - return bound - if bound is None: - return RunContext(project=chat_project, branch=None) - if bound.project is None: - return RunContext(project=chat_project, branch=bound.branch) - return bound - - -def _topic_key( - msg: TelegramIncomingMessage, cfg: TelegramBridgeConfig -) -> tuple[int, int] | None: - if not cfg.topics.enabled: - return None - if not _topics_chat_allowed(cfg, msg.chat_id): - return None - if msg.thread_id is None: - return None - return (msg.chat_id, msg.thread_id) - - -def _format_context(runtime: TransportRuntime, context: RunContext | None) -> str: - if context is None or context.project is None: - return "none" - project = runtime.project_alias_for_key(context.project) - if context.branch: - return f"{project} @{context.branch}" - return project - - -def _usage_ctx_set(*, chat_project: str | None) -> str: - if chat_project is not None: - return "usage: `/ctx set [@branch]`" - return "usage: `/ctx set [@branch]`" - - -def _usage_topic(*, chat_project: str | None) -> str: - if chat_project is not None: - return "usage: `/topic @branch`" - return "usage: `/topic @branch`" - - -def _parse_project_branch_args( - args_text: str, - *, - runtime: TransportRuntime, - cfg: TelegramBridgeConfig, - require_branch: bool, - chat_project: str | None, -) -> tuple[RunContext | None, str | None]: - tokens = split_command_args(args_text) - if not tokens: - return ( - None, - _usage_topic(chat_project=chat_project) - if require_branch - else _usage_ctx_set(chat_project=chat_project), - ) - if len(tokens) > 2: - return None, "too many arguments" - project_token: str | None = None - branch: str | None = None - first = tokens[0] - if first.startswith("@"): - branch = first[1:] or None - else: - project_token = first - if len(tokens) == 2: - second = tokens[1] - if not second.startswith("@"): - return None, "branch must be prefixed with @" - branch = second[1:] or None - - project_key: str | None = None - if chat_project is not None: - if project_token is None: - project_key = chat_project - else: - normalized = runtime.normalize_project_key(project_token) - if normalized is None: - return None, f"unknown project {project_token!r}" - if normalized != chat_project: - expected = runtime.project_alias_for_key(chat_project) - return None, (f"project mismatch for this chat; expected {expected!r}.") - project_key = normalized - else: - if project_token is None: - return None, "project is required" - project_key = runtime.normalize_project_key(project_token) - if project_key is None: - return None, f"unknown project {project_token!r}" - - if require_branch and not branch: - return None, "branch is required" - - return RunContext(project=project_key, branch=branch), None - - -def _format_ctx_status( - *, - cfg: TelegramBridgeConfig, - runtime: TransportRuntime, - bound: RunContext | None, - resolved: RunContext | None, - context_source: str, - snapshot: TopicThreadSnapshot | None, - chat_project: str | None, -) -> str: - lines = [ - f"topics: enabled (scope={_topics_scope_label(cfg)})", - f"bound ctx: {_format_context(runtime, bound)}", - f"resolved ctx: {_format_context(runtime, resolved)} (source: {context_source})", - ] - if chat_project is None and bound is None: - topic_usage = ( - _usage_topic(chat_project=chat_project).removeprefix("usage: ").strip() - ) - ctx_usage = ( - _usage_ctx_set(chat_project=chat_project).removeprefix("usage: ").strip() - ) - lines.append(f"note: unbound topic — bind with {topic_usage} or {ctx_usage}") - sessions = None - if snapshot is not None and snapshot.sessions: - sessions = ", ".join(sorted(snapshot.sessions)) - lines.append(f"sessions: {sessions or 'none'}") - return "\n".join(lines) - - -def _build_bot_commands( - runtime: TransportRuntime, *, include_file: bool = True -) -> list[dict[str, str]]: - commands: list[dict[str, str]] = [] - seen: set[str] = set() - for engine_id in runtime.available_engine_ids(): - cmd = engine_id.lower() - if cmd in seen: - continue - commands.append({"command": cmd, "description": f"use agent: {cmd}"}) - seen.add(cmd) - for alias in runtime.project_aliases(): - cmd = alias.lower() - if cmd in seen: - continue - if not is_valid_id(cmd): - logger.debug( - "startup.command_menu.skip_project", - alias=alias, - ) - continue - commands.append({"command": cmd, "description": f"work on: {cmd}"}) - seen.add(cmd) - allowlist = runtime.allowlist - for ep in list_entrypoints( - COMMAND_GROUP, - allowlist=allowlist, - reserved_ids=RESERVED_COMMAND_IDS, - ): - try: - backend = get_command(ep.name, allowlist=allowlist) - except ConfigError as exc: - logger.info( - "startup.command_menu.skip_command", - command=ep.name, - error=str(exc), - ) - continue - cmd = backend.id.lower() - if cmd in seen: - continue - if not is_valid_id(cmd): - logger.debug( - "startup.command_menu.skip_command_id", - command=cmd, - ) - continue - description = backend.description or f"command: {cmd}" - commands.append({"command": cmd, "description": description}) - seen.add(cmd) - if include_file and "file" not in seen: - commands.append({"command": "file", "description": "upload or fetch files"}) - seen.add("file") - if "cancel" not in seen: - commands.append({"command": "cancel", "description": "cancel run"}) - if len(commands) > _MAX_BOT_COMMANDS: - logger.warning( - "startup.command_menu.too_many", - count=len(commands), - limit=_MAX_BOT_COMMANDS, - ) - commands = commands[:_MAX_BOT_COMMANDS] - if not any(cmd["command"] == "cancel" for cmd in commands): - commands[-1] = {"command": "cancel", "description": "cancel run"} - return commands - - -def _reserved_commands(runtime: TransportRuntime) -> set[str]: - return { - *{engine.lower() for engine in runtime.engine_ids}, - *{alias.lower() for alias in runtime.project_aliases()}, - *RESERVED_COMMAND_IDS, - } - - -@dataclass(slots=True) -class RuntimeCommandCache: - command_ids: set[str] - reserved_commands: set[str] - - @classmethod - def from_runtime(cls, runtime: TransportRuntime) -> "RuntimeCommandCache": - allowlist = runtime.allowlist - return cls( - command_ids={ - command_id.lower() - for command_id in list_command_ids(allowlist=allowlist) - }, - reserved_commands=_reserved_commands(runtime), - ) - - def refresh(self, runtime: TransportRuntime) -> None: - allowlist = runtime.allowlist - self.command_ids = { - command_id.lower() for command_id in list_command_ids(allowlist=allowlist) - } - self.reserved_commands = _reserved_commands(runtime) - - -def _diff_keys(old: dict[str, object], new: dict[str, object]) -> list[str]: - keys = set(old) | set(new) - return sorted(key for key in keys if old.get(key) != new.get(key)) - - -async def _set_command_menu(cfg: TelegramBridgeConfig) -> None: - commands = _build_bot_commands(cfg.runtime, include_file=cfg.files.enabled) - if not commands: - return - try: - ok = await cfg.bot.set_my_commands(commands) - except Exception as exc: - logger.info( - "startup.command_menu.failed", - error=str(exc), - error_type=exc.__class__.__name__, - ) - return - if not ok: - logger.info("startup.command_menu.rejected") - return - logger.info( - "startup.command_menu.updated", - commands=[cmd["command"] for cmd in commands], - ) - - class TelegramPresenter: def __init__(self, *, formatter: MarkdownFormatter | None = None) -> None: self._formatter = formatter or MarkdownFormatter() @@ -470,11 +85,6 @@ def _is_cancelled_label(label: str) -> bool: return stripped.lower() == "cancelled" -@dataclass(frozen=True) -class TelegramVoiceTranscriptionConfig: - enabled: bool = False - - @dataclass(frozen=True) class TelegramFilesConfig: enabled: bool = False @@ -498,10 +108,17 @@ class TelegramTopicsConfig: scope: str = "auto" -def _as_int(value: int | str, *, label: str) -> int: - if isinstance(value, bool) or not isinstance(value, int): - raise TypeError(f"Telegram {label} must be int") - return value +@dataclass(frozen=True) +class TelegramBridgeConfig: + bot: BotClient + runtime: TransportRuntime + chat_id: int + startup_msg: str + exec_cfg: ExecBridgeConfig + voice_transcription: bool = False + files: TelegramFilesConfig = TelegramFilesConfig() + chat_ids: tuple[int, ...] | None = None + topics: TelegramTopicsConfig = TelegramTopicsConfig() class TelegramTransport: @@ -518,55 +135,49 @@ class TelegramTransport: message: RenderedMessage, options: SendOptions | None = None, ) -> MessageRef | None: - chat_id = _as_int(channel_id, label="chat_id") + chat_id = cast(int, channel_id) reply_to_message_id: int | None = None replace_message_id: int | None = None message_thread_id: int | None = None - disable_notification = None + notify = True if options is not None: - disable_notification = not options.notify - if options.reply_to is not None: - reply_to_message_id = _as_int( - options.reply_to.message_id, label="reply_to_message_id" - ) - if options.replace is not None: - replace_message_id = _as_int( - options.replace.message_id, label="replace_message_id" - ) - if options.thread_id is not None: - message_thread_id = _as_int( - options.thread_id, label="message_thread_id" - ) - entities = message.extra.get("entities") - parse_mode = message.extra.get("parse_mode") - reply_markup = message.extra.get("reply_markup") + reply_to_message_id = ( + cast(int, options.reply_to.message_id) + if options.reply_to is not None + else None + ) + replace_message_id = ( + cast(int, options.replace.message_id) + if options.replace is not None + else None + ) + notify = options.notify + message_thread_id = options.thread_id sent = await self._bot.send_message( chat_id=chat_id, text=message.text, + entities=message.extra.get("entities"), + parse_mode=message.extra.get("parse_mode"), + reply_markup=message.extra.get("reply_markup"), reply_to_message_id=reply_to_message_id, - disable_notification=disable_notification, message_thread_id=message_thread_id, - entities=entities, - parse_mode=parse_mode, - reply_markup=reply_markup, replace_message_id=replace_message_id, + disable_notification=not notify, ) if sent is None: return None - message_id = sent.get("message_id") - if message_id is None: - return None + message_id = cast(int, sent["message_id"]) return MessageRef( channel_id=chat_id, - message_id=_as_int(message_id, label="message_id"), + message_id=message_id, raw=sent, ) async def edit( self, *, ref: MessageRef, message: RenderedMessage, wait: bool = True ) -> MessageRef | None: - chat_id = _as_int(ref.channel_id, label="chat_id") - message_id = _as_int(ref.message_id, label="message_id") + chat_id = cast(int, ref.channel_id) + message_id = cast(int, ref.message_id) entities = message.extra.get("entities") parse_mode = message.extra.get("parse_mode") reply_markup = message.extra.get("reply_markup") @@ -581,41 +192,21 @@ class TelegramTransport: ) if edited is None: return ref if not wait else None - message_id = edited.get("message_id", message_id) + message_id = cast(int, edited.get("message_id", message_id)) return MessageRef( channel_id=chat_id, - message_id=_as_int(message_id, label="message_id"), + message_id=message_id, raw=edited, ) async def delete(self, *, ref: MessageRef) -> bool: return await self._bot.delete_message( - chat_id=_as_int(ref.channel_id, label="chat_id"), - message_id=_as_int(ref.message_id, label="message_id"), + chat_id=cast(int, ref.channel_id), + message_id=cast(int, ref.message_id), ) -@dataclass(frozen=True) -class TelegramBridgeConfig: - bot: BotClient - runtime: TransportRuntime - chat_id: int - startup_msg: str - exec_cfg: ExecBridgeConfig - voice_transcription: TelegramVoiceTranscriptionConfig | None = None - files: TelegramFilesConfig = TelegramFilesConfig() - chat_ids: tuple[int, ...] | None = None - topics: TelegramTopicsConfig = TelegramTopicsConfig() - - -def _allowed_chat_ids(cfg: TelegramBridgeConfig) -> set[int]: - allowed = set(cfg.chat_ids or ()) - allowed.add(cfg.chat_id) - allowed.update(cfg.runtime.project_chat_ids()) - return allowed - - -async def _send_plain( +async def send_plain( transport: Transport, *, chat_id: int, @@ -633,1446 +224,39 @@ async def _send_plain( ) -async def _send_startup(cfg: TelegramBridgeConfig) -> None: - logger.debug("startup.message", text=cfg.startup_msg) - parts = MarkdownParts(header=cfg.startup_msg) - text, entities = prepare_telegram(parts) - message = RenderedMessage(text=text, extra={"entities": entities}) - sent = await cfg.exec_cfg.transport.send( - channel_id=cfg.chat_id, - message=message, - ) - if sent is not None: - logger.info("startup.sent", chat_id=cfg.chat_id) +def build_bot_commands(runtime: TransportRuntime, *, include_file: bool = True): + from .commands import build_bot_commands as _build + + return _build(runtime, include_file=include_file) -async def _validate_topics_setup(cfg: TelegramBridgeConfig) -> None: - if not cfg.topics.enabled: - return - me = await cfg.bot.get_me() - bot_id = me.get("id") if isinstance(me, dict) else None - if not isinstance(bot_id, int): - raise ConfigError("failed to fetch bot id for topics validation.") - scope, chat_ids = _resolve_topics_scope(cfg) - if scope == "projects" and not chat_ids: - raise ConfigError( - "topics enabled but no project chats are configured; " - 'set projects..chat_id for forum chats or use scope="main".' - ) +def is_cancel_command(text: str) -> bool: + from .commands import is_cancel_command as _is_cancel_command - for chat_id in chat_ids: - chat = await cfg.bot.get_chat(chat_id) - if not isinstance(chat, dict): - raise ConfigError( - f"failed to fetch chat info for topics validation ({chat_id})." - ) - chat_type = chat.get("type") - is_forum = chat.get("is_forum") - if chat_type != "supergroup": - raise ConfigError( - "topics enabled but chat is not a supergroup " - f"(chat_id={chat_id}); convert the group and enable topics." - ) - if is_forum is not True: - raise ConfigError( - "topics enabled but chat does not have topics enabled " - f"(chat_id={chat_id}); turn on topics in group settings." - ) - member = await cfg.bot.get_chat_member(chat_id, bot_id) - if not isinstance(member, dict): - raise ConfigError( - "failed to fetch bot permissions " - f"(chat_id={chat_id}); promote the bot to admin with manage topics." - ) - status = member.get("status") - if status == "creator": - continue - if status != "administrator": - raise ConfigError( - "topics enabled but bot is not an admin " - f"(chat_id={chat_id}); promote it and grant manage topics." - ) - if member.get("can_manage_topics") is not True: - raise ConfigError( - "topics enabled but bot lacks manage topics permission " - f"(chat_id={chat_id}); grant can_manage_topics." - ) + return _is_cancel_command(text) -async def _drain_backlog(cfg: TelegramBridgeConfig, offset: int | None) -> int | None: - drained = 0 - while True: - updates = await cfg.bot.get_updates( - offset=offset, - timeout_s=0, - allowed_updates=["message", "callback_query"], - ) - if updates is None: - logger.info("startup.backlog.failed") - return offset - logger.debug("startup.backlog.updates", updates=updates) - if not updates: - if drained: - logger.info("startup.backlog.drained", count=drained) - return offset - offset = updates[-1]["update_id"] + 1 - drained += len(updates) - - -async def poll_updates( - cfg: TelegramBridgeConfig, -) -> AsyncIterator[TelegramIncomingUpdate]: - offset: int | None = None - offset = await _drain_backlog(cfg, offset) - await _send_startup(cfg) - - async for msg in poll_incoming( - cfg.bot, - chat_ids=lambda: _allowed_chat_ids(cfg), - offset=offset, - ): - yield msg - - -def _resolve_openai_api_key( - cfg: TelegramVoiceTranscriptionConfig, -) -> str | None: - env_key = os.environ.get("OPENAI_API_KEY") - if isinstance(env_key, str): - env_key = env_key.strip() - if env_key: - return env_key - return None - - -def _normalize_voice_filename(file_path: str | None, mime_type: str | None) -> str: - name = Path(file_path).name if file_path else "" - if not name: - if mime_type == "audio/ogg": - return "voice.ogg" - return "voice.dat" - if name.endswith(".oga"): - return f"{name[:-4]}.ogg" - return name - - -async def _transcribe_voice( - cfg: TelegramBridgeConfig, - msg: TelegramIncomingMessage, -) -> str | None: - voice = msg.voice - if voice is None: - return msg.text - settings = cfg.voice_transcription - if settings is None or not settings.enabled: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="voice transcription is disabled.", - thread_id=msg.thread_id, - ) - return None - api_key = _resolve_openai_api_key(settings) - if not api_key: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="voice transcription requires OPENAI_API_KEY.", - thread_id=msg.thread_id, - ) - return None - if voice.file_size is not None and voice.file_size > _OPENAI_AUDIO_MAX_BYTES: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="voice message is too large to transcribe.", - thread_id=msg.thread_id, - ) - return None - file_info = await cfg.bot.get_file(voice.file_id) - if not isinstance(file_info, dict): - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="failed to fetch voice file.", - thread_id=msg.thread_id, - ) - return None - file_path = file_info.get("file_path") - if not isinstance(file_path, str) or not file_path: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="failed to fetch voice file.", - thread_id=msg.thread_id, - ) - return None - audio_bytes = await cfg.bot.download_file(file_path) - if not audio_bytes: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="failed to download voice message.", - thread_id=msg.thread_id, - ) - return None - if len(audio_bytes) > _OPENAI_AUDIO_MAX_BYTES: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="voice message is too large to transcribe.", - thread_id=msg.thread_id, - ) - return None - filename = _normalize_voice_filename(file_path, voice.mime_type) - transcript = await transcribe_audio( - audio_bytes, - filename=filename, - api_key=api_key, - model=_OPENAI_TRANSCRIPTION_MODEL, - chunking_strategy=_OPENAI_TRANSCRIPTION_CHUNKING, - mime_type=voice.mime_type, - ) - if transcript is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="voice transcription failed.", - thread_id=msg.thread_id, - ) - return None - transcript = transcript.strip() - if not transcript: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="voice transcription returned empty text.", - thread_id=msg.thread_id, - ) - return None - return transcript - - -@dataclass(slots=True) -class _FilePutPlan: - resolved: ResolvedMessage - run_root: Path - path_value: str | None - force: bool - - -@dataclass(slots=True) -class _FilePutResult: - name: str - rel_path: Path | None - size: int | None - error: str | None - - -@dataclass(slots=True) -class _MediaGroupState: - messages: list[TelegramIncomingMessage] - token: int = 0 - - -async def _check_file_permissions( - cfg: TelegramBridgeConfig, msg: TelegramIncomingMessage -) -> bool: - sender_id = msg.sender_id - if sender_id is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="cannot verify sender for file transfer.", - thread_id=msg.thread_id, - ) - return False - if cfg.files.allowed_user_ids: - if sender_id not in cfg.files.allowed_user_ids: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="file transfer is not allowed for this user.", - thread_id=msg.thread_id, - ) - return False - return True - is_private = msg.chat_type == "private" - if msg.chat_type is None: - is_private = msg.chat_id > 0 - if is_private: - return True - member = await cfg.bot.get_chat_member(msg.chat_id, sender_id) - if not isinstance(member, dict): - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="failed to verify file transfer permissions.", - thread_id=msg.thread_id, - ) - return False - status = member.get("status") - if status in {"creator", "administrator"}: - return True - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="file transfer is restricted to group admins.", - thread_id=msg.thread_id, - ) - return False - - -async def _prepare_file_put_plan( - cfg: TelegramBridgeConfig, - msg: TelegramIncomingMessage, - args_text: str, - ambient_context: RunContext | None, - topic_store: TopicStateStore | None, -) -> _FilePutPlan | None: - if not await _check_file_permissions(cfg, msg): - return None - try: - resolved = cfg.runtime.resolve_message( - text=args_text, - reply_text=msg.reply_to_text, - ambient_context=ambient_context, - chat_id=msg.chat_id, - ) - except DirectiveError as exc: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=f"error:\n{exc}", - thread_id=msg.thread_id, - ) - return None - topic_key = _topic_key(msg, cfg) if topic_store is not None else None - await _maybe_update_topic_context( - cfg=cfg, - topic_store=topic_store, - topic_key=topic_key, - context=resolved.context, - context_source=resolved.context_source, - ) - if resolved.context is None or resolved.context.project is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="no project context available for file upload.", - thread_id=msg.thread_id, - ) - return None - try: - run_root = cfg.runtime.resolve_run_cwd(resolved.context) - except ConfigError as exc: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=f"error:\n{exc}", - thread_id=msg.thread_id, - ) - return None - if run_root is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="no project context available for file upload.", - thread_id=msg.thread_id, - ) - return None - path_value, force, error = parse_file_prompt(resolved.prompt, allow_empty=True) - if error is not None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=error, - thread_id=msg.thread_id, - ) - return None - return _FilePutPlan( - resolved=resolved, - run_root=run_root, - path_value=path_value, - force=force, - ) - - -async def _save_document_payload( - cfg: TelegramBridgeConfig, - *, - document: TelegramDocument, - run_root: Path, - rel_path: Path | None, - base_dir: Path | None, - force: bool, -) -> _FilePutResult: - name = default_upload_name(document.file_name, None) - if ( - document.file_size is not None - and document.file_size > cfg.files.max_upload_bytes - ): - return _FilePutResult( - name=name, - rel_path=None, - size=None, - error="file is too large to upload.", - ) - file_info = await cfg.bot.get_file(document.file_id) - if not isinstance(file_info, dict): - return _FilePutResult( - name=name, - rel_path=None, - size=None, - error="failed to fetch file metadata.", - ) - file_path = file_info.get("file_path") - if not isinstance(file_path, str) or not file_path: - return _FilePutResult( - name=name, - rel_path=None, - size=None, - error="failed to fetch file metadata.", - ) - name = default_upload_name(document.file_name, file_path) - resolved_path = rel_path - if resolved_path is None: - if base_dir is None: - resolved_path = default_upload_path( - cfg.files.uploads_dir, document.file_name, file_path - ) - else: - resolved_path = base_dir / name - deny_rule = deny_reason(resolved_path, cfg.files.deny_globs) - if deny_rule is not None: - return _FilePutResult( - name=name, - rel_path=None, - size=None, - error=f"path denied by rule: {deny_rule}", - ) - target = resolve_path_within_root(run_root, resolved_path) - if target is None: - return _FilePutResult( - name=name, - rel_path=None, - size=None, - error="upload path escapes the repo root.", - ) - if target.exists(): - if target.is_dir(): - return _FilePutResult( - name=name, - rel_path=None, - size=None, - error="upload target is a directory.", - ) - if not force: - return _FilePutResult( - name=name, - rel_path=None, - size=None, - error="file already exists; use --force to overwrite.", - ) - payload = await cfg.bot.download_file(file_path) - if payload is None: - return _FilePutResult( - name=name, - rel_path=None, - size=None, - error="failed to download file.", - ) - if len(payload) > cfg.files.max_upload_bytes: - return _FilePutResult( - name=name, - rel_path=None, - size=None, - error="file is too large to upload.", - ) - try: - write_bytes_atomic(target, payload) - except OSError as exc: - return _FilePutResult( - name=name, - rel_path=None, - size=None, - error=f"failed to write file: {exc}", - ) - return _FilePutResult( - name=name, - rel_path=resolved_path, - size=len(payload), - error=None, - ) - - -async def _maybe_update_topic_context( - *, - cfg: TelegramBridgeConfig, - topic_store: TopicStateStore | None, - topic_key: tuple[int, int] | None, - context: RunContext | None, - context_source: str, -) -> None: - if ( - topic_store is None - or topic_key is None - or context is None - or context_source != "directives" - ): - return - await topic_store.set_context(topic_key[0], topic_key[1], context) - await _maybe_rename_topic( - cfg, - topic_store, - chat_id=topic_key[0], - thread_id=topic_key[1], - context=context, - ) - - -async def _handle_file_command( - cfg: TelegramBridgeConfig, - msg: TelegramIncomingMessage, - args_text: str, - ambient_context: RunContext | None, - topic_store: TopicStateStore | None, -) -> None: - command, rest, error = parse_file_command(args_text) - if error is not None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=error, - thread_id=msg.thread_id, - ) - return - if command == "put": - await _handle_file_put(cfg, msg, rest, ambient_context, topic_store) - else: - await _handle_file_get(cfg, msg, rest, ambient_context, topic_store) - - -async def _handle_file_put_default( - cfg: TelegramBridgeConfig, - msg: TelegramIncomingMessage, - ambient_context: RunContext | None, - topic_store: TopicStateStore | None, -) -> None: - await _handle_file_put(cfg, msg, "", ambient_context, topic_store) - - -async def _handle_file_put( - cfg: TelegramBridgeConfig, - msg: TelegramIncomingMessage, - args_text: str, - ambient_context: RunContext | None, - topic_store: TopicStateStore | None, -) -> None: - document = msg.document - if document is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=file_put_usage(), - thread_id=msg.thread_id, - ) - return - plan = await _prepare_file_put_plan( - cfg, - msg, - args_text, - ambient_context, - topic_store, - ) - if plan is None: - return - rel_path: Path | None = None - base_dir: Path | None = None - if plan.path_value: - if plan.path_value.endswith("/"): - base_dir = normalize_relative_path(plan.path_value) - if base_dir is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="invalid upload path.", - thread_id=msg.thread_id, - ) - return - deny_rule = deny_reason(base_dir, cfg.files.deny_globs) - if deny_rule is not None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=f"path denied by rule: {deny_rule}", - thread_id=msg.thread_id, - ) - return - base_target = resolve_path_within_root(plan.run_root, base_dir) - if base_target is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="upload path escapes the repo root.", - thread_id=msg.thread_id, - ) - return - if base_target.exists() and not base_target.is_dir(): - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="upload path is a file.", - thread_id=msg.thread_id, - ) - return - else: - rel_path = normalize_relative_path(plan.path_value) - if rel_path is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="invalid upload path.", - thread_id=msg.thread_id, - ) - return - result = await _save_document_payload( - cfg, - document=document, - run_root=plan.run_root, - rel_path=rel_path, - base_dir=base_dir, - force=plan.force, - ) - if result.error is not None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=result.error, - thread_id=msg.thread_id, - ) - return - if result.rel_path is None or result.size is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="failed to save file.", - thread_id=msg.thread_id, - ) - return - context_label = _format_context(cfg.runtime, plan.resolved.context) - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=( - f"saved `{result.rel_path.as_posix()}` " - f"in `{context_label}` ({format_bytes(result.size)})" - ), - thread_id=msg.thread_id, - ) - - -async def _handle_file_put_group( - cfg: TelegramBridgeConfig, - msg: TelegramIncomingMessage, - args_text: str, - messages: Sequence[TelegramIncomingMessage], - ambient_context: RunContext | None, - topic_store: TopicStateStore | None, -) -> None: - documents = [item.document for item in messages if item.document is not None] - if not documents: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=file_put_usage(), - thread_id=msg.thread_id, - ) - return - plan = await _prepare_file_put_plan( - cfg, - msg, - args_text, - ambient_context, - topic_store, - ) - if plan is None: - return - base_dir: Path | None = None - if plan.path_value: - base_dir = normalize_relative_path(plan.path_value) - if base_dir is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="invalid upload path.", - thread_id=msg.thread_id, - ) - return - deny_rule = deny_reason(base_dir, cfg.files.deny_globs) - if deny_rule is not None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=f"path denied by rule: {deny_rule}", - thread_id=msg.thread_id, - ) - return - base_target = resolve_path_within_root(plan.run_root, base_dir) - if base_target is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="upload path escapes the repo root.", - thread_id=msg.thread_id, - ) - return - if base_target.exists() and not base_target.is_dir(): - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="upload path is a file.", - thread_id=msg.thread_id, - ) - return - saved: list[_FilePutResult] = [] - failed: list[_FilePutResult] = [] - for document in documents: - result = await _save_document_payload( - cfg, - document=document, - run_root=plan.run_root, - rel_path=None, - base_dir=base_dir, - force=plan.force, - ) - if result.error is None: - saved.append(result) - else: - failed.append(result) - context_label = _format_context(cfg.runtime, plan.resolved.context) - total_bytes = sum(item.size or 0 for item in saved) - dir_label: Path | None = base_dir - if dir_label is None and saved: - first_path = saved[0].rel_path - if first_path is not None: - dir_label = first_path.parent - if saved: - saved_names = ", ".join(f"`{item.name}`" for item in saved) - if dir_label is not None: - dir_text = dir_label.as_posix() - if not dir_text.endswith("/"): - dir_text = f"{dir_text}/" - text = ( - f"saved {saved_names} to `{dir_text}` " - f"in `{context_label}` ({format_bytes(total_bytes)})" - ) - else: - text = ( - f"saved {saved_names} in `{context_label}` " - f"({format_bytes(total_bytes)})" - ) - else: - text = "failed to upload files." - if failed: - errors = ", ".join( - f"`{item.name}` ({item.error})" for item in failed if item.error is not None - ) - if errors: - text = f"{text}\n\nfailed: {errors}" - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=text, - thread_id=msg.thread_id, - ) - - -async def _handle_media_group( - cfg: TelegramBridgeConfig, - messages: Sequence[TelegramIncomingMessage], - topic_store: TopicStateStore | None, -) -> None: - if not messages: - return - ordered = sorted(messages, key=lambda item: item.message_id) - command_msg = next( - (item for item in ordered if item.text.strip()), - ordered[0], - ) - topic_key = _topic_key(command_msg, cfg) if topic_store is not None else None - chat_project = ( - _topics_chat_project(cfg, command_msg.chat_id) if cfg.topics.enabled else None - ) - bound_context = ( - await topic_store.get_context(*topic_key) - if topic_store is not None and topic_key is not None - else None - ) - ambient_context = _merge_topic_context( - chat_project=chat_project, - bound=bound_context, - ) - command_id, args_text = _parse_slash_command(command_msg.text) - if command_id == "file": - if not cfg.files.enabled: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=command_msg.chat_id, - user_msg_id=command_msg.message_id, - text=("file transfer disabled; enable `[transports.telegram.files]`."), - thread_id=command_msg.thread_id, - ) - return - command, rest, error = parse_file_command(args_text) - if error is not None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=command_msg.chat_id, - user_msg_id=command_msg.message_id, - text=error, - thread_id=command_msg.thread_id, - ) - return - if command == "put": - await _handle_file_put_group( - cfg, - command_msg, - rest, - ordered, - ambient_context, - topic_store, - ) - else: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=command_msg.chat_id, - user_msg_id=command_msg.message_id, - text=file_put_usage(), - thread_id=command_msg.thread_id, - ) - return - if cfg.files.enabled and cfg.files.auto_put and not command_msg.text.strip(): - await _handle_file_put_group( - cfg, - command_msg, - "", - ordered, - ambient_context, - topic_store, - ) - return - if cfg.files.enabled: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=command_msg.chat_id, - user_msg_id=command_msg.message_id, - text=file_put_usage(), - thread_id=command_msg.thread_id, - ) - - -async def _handle_file_get( - cfg: TelegramBridgeConfig, - msg: TelegramIncomingMessage, - args_text: str, - ambient_context: RunContext | None, - topic_store: TopicStateStore | None, -) -> None: - if not await _check_file_permissions(cfg, msg): - return - try: - resolved = cfg.runtime.resolve_message( - text=args_text, - reply_text=msg.reply_to_text, - ambient_context=ambient_context, - chat_id=msg.chat_id, - ) - except DirectiveError as exc: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=f"error:\n{exc}", - thread_id=msg.thread_id, - ) - return - topic_key = _topic_key(msg, cfg) if topic_store is not None else None - await _maybe_update_topic_context( - cfg=cfg, - topic_store=topic_store, - topic_key=topic_key, - context=resolved.context, - context_source=resolved.context_source, - ) - if resolved.context is None or resolved.context.project is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="no project context available for file fetch.", - thread_id=msg.thread_id, - ) - return - try: - run_root = cfg.runtime.resolve_run_cwd(resolved.context) - except ConfigError as exc: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=f"error:\n{exc}", - thread_id=msg.thread_id, - ) - return - if run_root is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="no project context available for file fetch.", - thread_id=msg.thread_id, - ) - return - path_value, _, error = parse_file_prompt(resolved.prompt, allow_empty=False) - if error is not None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=file_get_usage(), - thread_id=msg.thread_id, - ) - return - rel_path = normalize_relative_path(path_value or "") - if rel_path is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="invalid file path.", - thread_id=msg.thread_id, - ) - return - deny_rule = deny_reason(rel_path, cfg.files.deny_globs) - if deny_rule is not None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=f"path denied by rule: {deny_rule}", - thread_id=msg.thread_id, - ) - return - target = resolve_path_within_root(run_root, rel_path) - if target is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="requested path escapes the repo root.", - thread_id=msg.thread_id, - ) - return - if not target.exists(): - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="file not found.", - thread_id=msg.thread_id, - ) - return - payload: bytes - filename: str - if target.is_dir(): - try: - payload = zip_directory( - run_root, - rel_path, - cfg.files.deny_globs, - max_bytes=cfg.files.max_download_bytes, - ) - except ZipTooLargeError: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="file is too large to send.", - thread_id=msg.thread_id, - ) - return - except OSError as exc: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=f"failed to read directory: {exc}", - thread_id=msg.thread_id, - ) - return - filename = f"{rel_path.name or 'archive'}.zip" - else: - try: - size = target.stat().st_size - if size > cfg.files.max_download_bytes: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="file is too large to send.", - thread_id=msg.thread_id, - ) - return - payload = target.read_bytes() - except OSError as exc: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=f"failed to read file: {exc}", - thread_id=msg.thread_id, - ) - return - filename = target.name - if len(payload) > cfg.files.max_download_bytes: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="file is too large to send.", - thread_id=msg.thread_id, - ) - return - sent = await cfg.bot.send_document( - chat_id=msg.chat_id, - filename=filename, - content=payload, - reply_to_message_id=msg.message_id, - message_thread_id=msg.thread_id, - ) - if sent is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="failed to send file.", - thread_id=msg.thread_id, - ) - return - - -def _topic_title( - *, cfg: TelegramBridgeConfig, runtime: TransportRuntime, context: RunContext -) -> str: - project = ( - runtime.project_alias_for_key(context.project) - if context.project is not None - else "" - ) - if context.branch: - if project: - return f"{project} @{context.branch}" - return f"@{context.branch}" - return project or "topic" - - -async def _maybe_rename_topic( - cfg: TelegramBridgeConfig, - store: TopicStateStore, - *, - chat_id: int, - thread_id: int, - context: RunContext, - snapshot: TopicThreadSnapshot | None = None, -) -> None: - title = _topic_title(cfg=cfg, runtime=cfg.runtime, context=context) - if snapshot is None: - snapshot = await store.get_thread(chat_id, thread_id) - if snapshot is not None and snapshot.topic_title == title: - return - updated = await cfg.bot.edit_forum_topic( - chat_id=chat_id, - message_thread_id=thread_id, - name=title, - ) - if not updated: - logger.warning( - "topics.rename.failed", - chat_id=chat_id, - thread_id=thread_id, - title=title, - ) - return - await store.set_context(chat_id, thread_id, context, topic_title=title) - - -async def _handle_ctx_command( - cfg: TelegramBridgeConfig, - msg: TelegramIncomingMessage, - args_text: str, - store: TopicStateStore, -) -> None: - error = _topics_command_error(cfg, msg.chat_id) - if error is not None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=error, - thread_id=msg.thread_id, - ) - return - chat_project = _topics_chat_project(cfg, msg.chat_id) - tkey = _topic_key(msg, cfg) - if tkey is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="this command only works inside a topic.", - thread_id=msg.thread_id, - ) - return - tokens = split_command_args(args_text) - action = tokens[0].lower() if tokens else "show" - if action in {"show", ""}: - snapshot = await store.get_thread(*tkey) - bound = snapshot.context if snapshot is not None else None - ambient = _merge_topic_context(chat_project=chat_project, bound=bound) - resolved = cfg.runtime.resolve_message( - text="", - reply_text=msg.reply_to_text, - chat_id=msg.chat_id, - ambient_context=ambient, - ) - text = _format_ctx_status( - cfg=cfg, - runtime=cfg.runtime, - bound=bound, - resolved=resolved.context, - context_source=resolved.context_source, - snapshot=snapshot, - chat_project=chat_project, - ) - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=text, - thread_id=msg.thread_id, - ) - return - if action == "set": - rest = " ".join(tokens[1:]) - context, error = _parse_project_branch_args( - rest, - runtime=cfg.runtime, - cfg=cfg, - require_branch=False, - chat_project=chat_project, - ) - if error is not None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=f"error:\n{error}\n{_usage_ctx_set(chat_project=chat_project)}", - thread_id=msg.thread_id, - ) - return - if context is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=f"error:\n{_usage_ctx_set(chat_project=chat_project)}", - thread_id=msg.thread_id, - ) - return - await store.set_context(*tkey, context) - await _maybe_rename_topic( - cfg, - store, - chat_id=tkey[0], - thread_id=tkey[1], - context=context, - ) - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=f"topic bound to `{_format_context(cfg.runtime, context)}`", - thread_id=msg.thread_id, - ) - return - if action == "clear": - await store.clear_context(*tkey) - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="topic binding cleared.", - thread_id=msg.thread_id, - ) - return - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="unknown `/ctx` command. use `/ctx`, `/ctx set`, or `/ctx clear`.", - thread_id=msg.thread_id, - ) - - -async def _handle_new_command( - cfg: TelegramBridgeConfig, - msg: TelegramIncomingMessage, - store: TopicStateStore, -) -> None: - error = _topics_command_error(cfg, msg.chat_id) - if error is not None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=error, - thread_id=msg.thread_id, - ) - return - tkey = _topic_key(msg, cfg) - if tkey is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="this command only works inside a topic.", - thread_id=msg.thread_id, - ) - return - await store.clear_sessions(*tkey) - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="cleared stored sessions for this topic.", - thread_id=msg.thread_id, - ) - - -async def _handle_topic_command( - cfg: TelegramBridgeConfig, - msg: TelegramIncomingMessage, - args_text: str, - store: TopicStateStore, -) -> None: - error = _topics_command_error(cfg, msg.chat_id) - if error is not None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=error, - thread_id=msg.thread_id, - ) - return - chat_project = _topics_chat_project(cfg, msg.chat_id) - context, error = _parse_project_branch_args( - args_text, - runtime=cfg.runtime, - cfg=cfg, - require_branch=True, - chat_project=chat_project, - ) - if error is not None or context is None: - usage = _usage_topic(chat_project=chat_project) - text = f"error:\n{error}\n{usage}" if error else usage - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=text, - thread_id=msg.thread_id, - ) - return - target_chat_id = msg.chat_id - existing = await store.find_thread_for_context(target_chat_id, context) - if existing is not None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=f"topic already exists for {_format_context(cfg.runtime, context)} " - "in this chat.", - thread_id=msg.thread_id, - ) - return - title = _topic_title(cfg=cfg, runtime=cfg.runtime, context=context) - created = await cfg.bot.create_forum_topic(target_chat_id, title) - thread_id = created.get("message_thread_id") if isinstance(created, dict) else None - if isinstance(thread_id, bool) or not isinstance(thread_id, int): - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text="failed to create topic.", - thread_id=msg.thread_id, - ) - return - await store.set_context( - target_chat_id, - thread_id, - context, - topic_title=title, - created_by_bot=True, - ) - await _send_plain( - cfg.exec_cfg.transport, - chat_id=msg.chat_id, - user_msg_id=msg.message_id, - text=f"created topic `{title}`.", - thread_id=msg.thread_id, - ) - await cfg.exec_cfg.transport.send( - channel_id=target_chat_id, - message=RenderedMessage( - text=f"topic bound to `{_format_context(cfg.runtime, context)}`" - ), - options=SendOptions(thread_id=thread_id), - ) - - -async def _handle_cancel( +async def handle_cancel( cfg: TelegramBridgeConfig, msg: TelegramIncomingMessage, running_tasks: RunningTasks, ) -> None: - chat_id = msg.chat_id - user_msg_id = msg.message_id - reply_id = msg.reply_to_message_id + from .commands import handle_cancel as _handle_cancel - if reply_id is None: - if msg.reply_to_text: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=chat_id, - user_msg_id=user_msg_id, - text="nothing is currently running for that message.", - thread_id=msg.thread_id, - ) - return - await _send_plain( - cfg.exec_cfg.transport, - chat_id=chat_id, - user_msg_id=user_msg_id, - text="reply to the progress message to cancel.", - thread_id=msg.thread_id, - ) - return - - progress_ref = MessageRef(channel_id=chat_id, message_id=reply_id) - running_task = running_tasks.get(progress_ref) - if running_task is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=chat_id, - user_msg_id=user_msg_id, - text="nothing is currently running for that message.", - thread_id=msg.thread_id, - ) - return - - logger.info( - "cancel.requested", - chat_id=chat_id, - progress_message_id=reply_id, - ) - running_task.cancel_requested.set() + await _handle_cancel(cfg, msg, running_tasks) -async def _handle_callback_cancel( +async def handle_callback_cancel( cfg: TelegramBridgeConfig, query: TelegramCallbackQuery, running_tasks: RunningTasks, ) -> None: - progress_ref = MessageRef(channel_id=query.chat_id, message_id=query.message_id) - running_task = running_tasks.get(progress_ref) - if running_task is None: - await cfg.bot.answer_callback_query( - callback_query_id=query.callback_query_id, - text="nothing is currently running for that message.", - ) - return - logger.info( - "cancel.requested", - chat_id=query.chat_id, - progress_message_id=query.message_id, - ) - running_task.cancel_requested.set() - await cfg.bot.answer_callback_query( - callback_query_id=query.callback_query_id, - text="cancelling...", - ) + from .commands import handle_callback_cancel as _handle_callback_cancel + + await _handle_callback_cancel(cfg, query, running_tasks) -async def _wait_for_resume(running_task: RunningTask) -> ResumeToken | None: - if running_task.resume is not None: - return running_task.resume - resume: ResumeToken | None = None - - async with anyio.create_task_group() as tg: - - async def wait_resume() -> None: - nonlocal resume - await running_task.resume_ready.wait() - resume = running_task.resume - tg.cancel_scope.cancel() - - async def wait_done() -> None: - await running_task.done.wait() - tg.cancel_scope.cancel() - - tg.start_soon(wait_resume) - tg.start_soon(wait_done) - - return resume - - -async def _send_with_resume( +async def send_with_resume( cfg: TelegramBridgeConfig, enqueue: Callable[ [int, int, str, ResumeToken, RunContext | None, int | None], Awaitable[None] @@ -2083,784 +267,44 @@ async def _send_with_resume( thread_id: int | None, text: str, ) -> None: - resume = await _wait_for_resume(running_task) - if resume is None: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=chat_id, - user_msg_id=user_msg_id, - text="resume token not ready yet; try replying to the final message.", - notify=False, - thread_id=thread_id, - ) - return - await enqueue( + from .loop import send_with_resume as _send_with_resume + + await _send_with_resume( + cfg, + enqueue, + running_task, chat_id, user_msg_id, - text, - resume, - running_task.context, thread_id, + text, ) -async def _send_runner_unavailable( - exec_cfg: ExecBridgeConfig, - *, - chat_id: int, - user_msg_id: int, - resume_token: ResumeToken | None, - runner: Runner, - reason: str, - thread_id: int | None = None, -) -> None: - tracker = ProgressTracker(engine=runner.engine) - tracker.set_resume(resume_token) - state = tracker.snapshot(resume_formatter=runner.format_resume) - message = exec_cfg.presenter.render_final( - state, - elapsed_s=0.0, - status="error", - answer=f"error:\n{reason}", - ) - reply_to = MessageRef(channel_id=chat_id, message_id=user_msg_id) - await exec_cfg.transport.send( - channel_id=chat_id, - message=message, - options=SendOptions(reply_to=reply_to, notify=True, thread_id=thread_id), - ) - - -async def _run_engine( - *, - exec_cfg: ExecBridgeConfig, - runtime: TransportRuntime, - running_tasks: RunningTasks | None, - chat_id: int, - user_msg_id: int, - text: str, - resume_token: ResumeToken | None, - context: RunContext | None, - reply_ref: MessageRef | None = None, - on_thread_known: Callable[[ResumeToken, anyio.Event], Awaitable[None]] - | None = None, - engine_override: EngineId | None = None, - thread_id: int | None = None, -) -> None: - try: - try: - entry = runtime.resolve_runner( - resume_token=resume_token, - engine_override=engine_override, - ) - except RunnerUnavailableError as exc: - await _send_plain( - exec_cfg.transport, - chat_id=chat_id, - user_msg_id=user_msg_id, - text=f"error:\n{exc}", - thread_id=thread_id, - ) - return - if not entry.available: - reason = entry.issue or "engine unavailable" - await _send_runner_unavailable( - exec_cfg, - chat_id=chat_id, - user_msg_id=user_msg_id, - resume_token=resume_token, - runner=entry.runner, - reason=reason, - thread_id=thread_id, - ) - return - try: - cwd = runtime.resolve_run_cwd(context) - except ConfigError as exc: - await _send_plain( - exec_cfg.transport, - chat_id=chat_id, - user_msg_id=user_msg_id, - text=f"error:\n{exc}", - thread_id=thread_id, - ) - return - run_base_token = set_run_base_dir(cwd) - try: - run_fields = { - "chat_id": chat_id, - "user_msg_id": user_msg_id, - "engine": entry.runner.engine, - "resume": resume_token.value if resume_token else None, - } - if context is not None: - run_fields["project"] = context.project - run_fields["branch"] = context.branch - if cwd is not None: - run_fields["cwd"] = str(cwd) - bind_run_context(**run_fields) - context_line = runtime.format_context_line(context) - incoming = RunnerIncomingMessage( - channel_id=chat_id, - message_id=user_msg_id, - text=text, - reply_to=reply_ref, - thread_id=thread_id, - ) - await handle_message( - exec_cfg, - runner=entry.runner, - incoming=incoming, - resume_token=resume_token, - context=context, - context_line=context_line, - strip_resume_line=runtime.is_resume_line, - running_tasks=running_tasks, - on_thread_known=on_thread_known, - ) - finally: - reset_run_base_dir(run_base_token) - except Exception as exc: - logger.exception( - "handle.worker_failed", - error=str(exc), - error_type=exc.__class__.__name__, - ) - finally: - clear_context() - - -class _CaptureTransport: - def __init__(self) -> None: - self._next_id = 1 - self.last_message: RenderedMessage | None = None - - async def send( - self, - *, - channel_id: int | str, - message: RenderedMessage, - options: SendOptions | None = None, - ) -> MessageRef: - _ = options - ref = MessageRef(channel_id=channel_id, message_id=self._next_id) - self._next_id += 1 - self.last_message = message - return ref - - async def edit( - self, *, ref: MessageRef, message: RenderedMessage, wait: bool = True - ) -> MessageRef: - _ = ref, wait - self.last_message = message - return ref - - async def delete(self, *, ref: MessageRef) -> bool: - _ = ref - return True - - async def close(self) -> None: - return None - - -class _TelegramCommandExecutor(CommandExecutor): - def __init__( - self, - *, - exec_cfg: ExecBridgeConfig, - runtime: TransportRuntime, - running_tasks: RunningTasks, - scheduler: ThreadScheduler, - chat_id: int, - user_msg_id: int, - thread_id: int | None, - ) -> None: - self._exec_cfg = exec_cfg - self._runtime = runtime - self._running_tasks = running_tasks - self._scheduler = scheduler - self._chat_id = chat_id - self._user_msg_id = user_msg_id - self._thread_id = thread_id - self._reply_ref = MessageRef(channel_id=chat_id, message_id=user_msg_id) - - def _apply_default_context(self, request: RunRequest) -> RunRequest: - if request.context is not None: - return request - context = self._runtime.default_context_for_chat(self._chat_id) - if context is None: - return request - return RunRequest( - prompt=request.prompt, - engine=request.engine, - context=context, - ) - - async def send( - self, - message: RenderedMessage | str, - *, - reply_to: MessageRef | None = None, - notify: bool = True, - ) -> MessageRef | None: - rendered = ( - message - if isinstance(message, RenderedMessage) - else RenderedMessage(text=message) - ) - reply_ref = self._reply_ref if reply_to is None else reply_to - return await self._exec_cfg.transport.send( - channel_id=self._chat_id, - message=rendered, - options=SendOptions( - reply_to=reply_ref, - notify=notify, - thread_id=self._thread_id, - ), - ) - - async def run_one( - self, request: RunRequest, *, mode: RunMode = "emit" - ) -> RunResult: - request = self._apply_default_context(request) - engine = self._runtime.resolve_engine( - engine_override=request.engine, - context=request.context, - ) - if mode == "capture": - capture = _CaptureTransport() - exec_cfg = ExecBridgeConfig( - transport=capture, - presenter=self._exec_cfg.presenter, - final_notify=False, - ) - await _run_engine( - exec_cfg=exec_cfg, - runtime=self._runtime, - running_tasks={}, - chat_id=self._chat_id, - user_msg_id=self._user_msg_id, - text=request.prompt, - resume_token=None, - context=request.context, - reply_ref=self._reply_ref, - on_thread_known=None, - engine_override=engine, - thread_id=self._thread_id, - ) - return RunResult(engine=engine, message=capture.last_message) - await _run_engine( - exec_cfg=self._exec_cfg, - runtime=self._runtime, - running_tasks=self._running_tasks, - chat_id=self._chat_id, - user_msg_id=self._user_msg_id, - text=request.prompt, - resume_token=None, - context=request.context, - reply_ref=self._reply_ref, - on_thread_known=self._scheduler.note_thread_known, - engine_override=engine, - thread_id=self._thread_id, - ) - return RunResult(engine=engine, message=None) - - async def run_many( - self, - requests: Sequence[RunRequest], - *, - mode: RunMode = "emit", - parallel: bool = False, - ) -> list[RunResult]: - if not parallel: - return [await self.run_one(request, mode=mode) for request in requests] - results: list[RunResult | None] = [None] * len(requests) - - async with anyio.create_task_group() as tg: - - async def run_idx(idx: int, request: RunRequest) -> None: - results[idx] = await self.run_one(request, mode=mode) - - for idx, request in enumerate(requests): - tg.start_soon(run_idx, idx, request) - - return [result for result in results if result is not None] - - -async def _dispatch_command( - cfg: TelegramBridgeConfig, - msg: TelegramIncomingMessage, - text: str, - command_id: str, - args_text: str, - running_tasks: RunningTasks, - scheduler: ThreadScheduler, -) -> None: - allowlist = cfg.runtime.allowlist - chat_id = msg.chat_id - user_msg_id = msg.message_id - reply_ref = ( - MessageRef(channel_id=chat_id, message_id=msg.reply_to_message_id) - if msg.reply_to_message_id is not None - else None - ) - executor = _TelegramCommandExecutor( - exec_cfg=cfg.exec_cfg, - runtime=cfg.runtime, - running_tasks=running_tasks, - scheduler=scheduler, - chat_id=chat_id, - user_msg_id=user_msg_id, - thread_id=msg.thread_id, - ) - message_ref = MessageRef(channel_id=chat_id, message_id=user_msg_id) - try: - backend = get_command(command_id, allowlist=allowlist, required=False) - except ConfigError as exc: - await executor.send(f"error:\n{exc}", reply_to=message_ref, notify=True) - return - if backend is None: - return - try: - plugin_config = cfg.runtime.plugin_config(command_id) - except ConfigError as exc: - await executor.send(f"error:\n{exc}", reply_to=message_ref, notify=True) - return - ctx = CommandContext( - command=command_id, - text=text, - args_text=args_text, - args=split_command_args(args_text), - message=message_ref, - reply_to=reply_ref, - reply_text=msg.reply_to_text, - config_path=cfg.runtime.config_path, - plugin_config=plugin_config, - runtime=cfg.runtime, - executor=executor, - ) - try: - result = await backend.handle(ctx) - except Exception as exc: - logger.exception( - "command.failed", - command=command_id, - error=str(exc), - error_type=exc.__class__.__name__, - ) - await executor.send(f"error:\n{exc}", reply_to=message_ref, notify=True) - return - if result is not None: - reply_to = message_ref if result.reply_to is None else result.reply_to - await executor.send(result.text, reply_to=reply_to, notify=result.notify) - return None - - async def run_main_loop( cfg: TelegramBridgeConfig, - poller: Callable[ - [TelegramBridgeConfig], AsyncIterator[TelegramIncomingUpdate] - ] = poll_updates, + poller=None, *, watch_config: bool | None = None, default_engine_override: str | None = None, transport_id: str | None = None, transport_config: dict[str, object] | None = None, ) -> None: - running_tasks: RunningTasks = {} - command_cache = RuntimeCommandCache.from_runtime(cfg.runtime) - transport_snapshot = ( - dict(transport_config) if transport_config is not None else None - ) - topic_store: TopicStateStore | None = None - media_groups: dict[tuple[int, str], _MediaGroupState] = {} + from .loop import run_main_loop as _run_main_loop - try: - if cfg.topics.enabled: - config_path = cfg.runtime.config_path - if config_path is None: - raise ConfigError( - "topics enabled but config path is not set; cannot locate state file." - ) - topic_store = TopicStateStore(resolve_state_path(config_path)) - await _validate_topics_setup(cfg) - resolved_scope, _ = _resolve_topics_scope(cfg) - logger.info( - "topics.enabled", - scope=cfg.topics.scope, - resolved_scope=resolved_scope, - state_path=str(resolve_state_path(config_path)), - ) - await _set_command_menu(cfg) - async with anyio.create_task_group() as tg: - config_path = cfg.runtime.config_path - watch_enabled = bool(watch_config) and config_path is not None - - async def handle_reload(reload: ConfigReload) -> None: - nonlocal transport_snapshot, transport_id - command_cache.refresh(cfg.runtime) - await _set_command_menu(cfg) - if transport_snapshot is not None: - new_snapshot = reload.settings.transports.telegram.model_dump() - changed = _diff_keys(transport_snapshot, new_snapshot) - if changed: - logger.warning( - "config.reload.transport_config_changed", - transport="telegram", - keys=changed, - restart_required=True, - ) - transport_snapshot = new_snapshot - if ( - transport_id is not None - and reload.settings.transport != transport_id - ): - logger.warning( - "config.reload.transport_changed", - old=transport_id, - new=reload.settings.transport, - restart_required=True, - ) - transport_id = reload.settings.transport - - if watch_enabled and config_path is not None: - - async def run_config_watch() -> None: - await watch_config_changes( - config_path=config_path, - runtime=cfg.runtime, - default_engine_override=default_engine_override, - on_reload=handle_reload, - ) - - tg.start_soon(run_config_watch) - - def wrap_on_thread_known( - base_cb: Callable[[ResumeToken, anyio.Event], Awaitable[None]] | None, - topic_key: tuple[int, int] | None, - ) -> Callable[[ResumeToken, anyio.Event], Awaitable[None]] | None: - if base_cb is None and topic_key is None: - return None - - async def _wrapped(token: ResumeToken, done: anyio.Event) -> None: - if base_cb is not None: - await base_cb(token, done) - if topic_store is not None and topic_key is not None: - await topic_store.set_session_resume( - topic_key[0], topic_key[1], token - ) - - return _wrapped - - async def run_job( - chat_id: int, - user_msg_id: int, - text: str, - resume_token: ResumeToken | None, - context: RunContext | None, - thread_id: int | None = None, - reply_ref: MessageRef | None = None, - on_thread_known: Callable[[ResumeToken, anyio.Event], Awaitable[None]] - | None = None, - engine_override: EngineId | None = None, - ) -> None: - topic_key = ( - (chat_id, thread_id) - if topic_store is not None - and thread_id is not None - and _topics_chat_allowed(cfg, chat_id) - else None - ) - await _run_engine( - exec_cfg=cfg.exec_cfg, - runtime=cfg.runtime, - running_tasks=running_tasks, - chat_id=chat_id, - user_msg_id=user_msg_id, - text=text, - resume_token=resume_token, - context=context, - reply_ref=reply_ref, - on_thread_known=wrap_on_thread_known(on_thread_known, topic_key), - engine_override=engine_override, - thread_id=thread_id, - ) - - async def run_thread_job(job: ThreadJob) -> None: - await run_job( - job.chat_id, - job.user_msg_id, - job.text, - job.resume_token, - job.context, - job.thread_id, - None, - scheduler.note_thread_known, - ) - - scheduler = ThreadScheduler(task_group=tg, run_job=run_thread_job) - - async def flush_media_group(key: tuple[int, str]) -> None: - while True: - state = media_groups.get(key) - if state is None: - return - token = state.token - await anyio.sleep(_MEDIA_GROUP_DEBOUNCE_S) - state = media_groups.get(key) - if state is None: - return - if state.token != token: - continue - messages = list(state.messages) - del media_groups[key] - await _handle_media_group(cfg, messages, topic_store) - return - - async for msg in poller(cfg): - if isinstance(msg, TelegramCallbackQuery): - if msg.data == CANCEL_CALLBACK_DATA: - tg.start_soon(_handle_callback_cancel, cfg, msg, running_tasks) - else: - tg.start_soon( - cfg.bot.answer_callback_query, - msg.callback_query_id, - ) - continue - text = msg.text - if msg.voice is not None: - text = await _transcribe_voice(cfg, msg) - if text is None: - continue - user_msg_id = msg.message_id - chat_id = msg.chat_id - reply_id = msg.reply_to_message_id - reply_ref = ( - MessageRef(channel_id=chat_id, message_id=reply_id) - if reply_id is not None - else None - ) - topic_key = _topic_key(msg, cfg) if topic_store is not None else None - chat_project = ( - _topics_chat_project(cfg, chat_id) if cfg.topics.enabled else None - ) - bound_context = ( - await topic_store.get_context(*topic_key) - if topic_store is not None and topic_key is not None - else None - ) - ambient_context = _merge_topic_context( - chat_project=chat_project, bound=bound_context - ) - - if ( - cfg.files.enabled - and msg.document is not None - and msg.media_group_id is not None - ): - key = (chat_id, msg.media_group_id) - state = media_groups.get(key) - if state is None: - state = _MediaGroupState(messages=[]) - media_groups[key] = state - tg.start_soon(flush_media_group, key) - state.messages.append(msg) - state.token += 1 - continue - - if _is_cancel_command(text): - tg.start_soon(_handle_cancel, cfg, msg, running_tasks) - continue - - command_id, args_text = _parse_slash_command(text) - if command_id == "file": - if not cfg.files.enabled: - tg.start_soon( - partial( - _send_plain, - cfg.exec_cfg.transport, - chat_id=chat_id, - user_msg_id=user_msg_id, - text=( - "file transfer disabled; enable " - "`[transports.telegram.files]`." - ), - thread_id=msg.thread_id, - ) - ) - else: - tg.start_soon( - _handle_file_command, - cfg, - msg, - args_text, - ambient_context, - topic_store, - ) - continue - if msg.document is not None: - if cfg.files.enabled and cfg.files.auto_put and not text.strip(): - tg.start_soon( - _handle_file_put_default, - cfg, - msg, - ambient_context, - topic_store, - ) - elif cfg.files.enabled: - tg.start_soon( - partial( - _send_plain, - cfg.exec_cfg.transport, - chat_id=chat_id, - user_msg_id=user_msg_id, - text=file_put_usage(), - thread_id=msg.thread_id, - ) - ) - continue - if ( - cfg.topics.enabled - and topic_store is not None - and command_id in _TOPICS_COMMANDS - ): - if command_id == "ctx": - tg.start_soon( - _handle_ctx_command, cfg, msg, args_text, topic_store - ) - elif command_id == "new": - tg.start_soon(_handle_new_command, cfg, msg, topic_store) - else: - tg.start_soon( - _handle_topic_command, cfg, msg, args_text, topic_store - ) - continue - if ( - command_id is not None - and command_id not in command_cache.reserved_commands - ): - if command_id not in command_cache.command_ids: - command_cache.refresh(cfg.runtime) - if command_id in command_cache.command_ids: - tg.start_soon( - _dispatch_command, - cfg, - msg, - text, - command_id, - args_text, - running_tasks, - scheduler, - ) - continue - - reply_text = msg.reply_to_text - try: - resolved = cfg.runtime.resolve_message( - text=text, - reply_text=reply_text, - ambient_context=ambient_context, - chat_id=chat_id, - ) - except DirectiveError as exc: - await _send_plain( - cfg.exec_cfg.transport, - chat_id=chat_id, - user_msg_id=user_msg_id, - text=f"error:\n{exc}", - thread_id=msg.thread_id, - ) - continue - - text = resolved.prompt - resume_token = resolved.resume_token - engine_override = resolved.engine_override - context = resolved.context - if ( - topic_store is not None - and topic_key is not None - and resolved.context is not None - and resolved.context_source == "directives" - ): - await topic_store.set_context(*topic_key, resolved.context) - await _maybe_rename_topic( - cfg, - topic_store, - chat_id=topic_key[0], - thread_id=topic_key[1], - context=resolved.context, - ) - ambient_context = resolved.context - if ( - topic_store is not None - and topic_key is not None - and ambient_context is None - and resolved.context_source not in {"directives", "reply_ctx"} - ): - await _send_plain( - cfg.exec_cfg.transport, - chat_id=chat_id, - user_msg_id=user_msg_id, - text=( - "this topic isn't bound to a project yet.\n" - f"{_usage_ctx_set(chat_project=chat_project)} or " - f"{_usage_topic(chat_project=chat_project)}" - ), - thread_id=msg.thread_id, - ) - continue - if resume_token is None and reply_id is not None: - running_task = running_tasks.get( - MessageRef(channel_id=chat_id, message_id=reply_id) - ) - if running_task is not None: - tg.start_soon( - _send_with_resume, - cfg, - scheduler.enqueue_resume, - running_task, - chat_id, - user_msg_id, - msg.thread_id, - text, - ) - continue - if ( - resume_token is None - and topic_store is not None - and topic_key is not None - ): - engine_for_session = cfg.runtime.resolve_engine( - engine_override=engine_override, - context=context, - ) - stored = await topic_store.get_session_resume( - topic_key[0], topic_key[1], engine_for_session - ) - if stored is not None: - resume_token = stored - - if resume_token is None: - tg.start_soon( - run_job, - chat_id, - user_msg_id, - text, - None, - context, - msg.thread_id, - reply_ref, - scheduler.note_thread_known, - engine_override, - ) - else: - await scheduler.enqueue_resume( - chat_id, - user_msg_id, - text, - resume_token, - context, - msg.thread_id, - ) - finally: - await cfg.exec_cfg.transport.close() + if poller is None: + await _run_main_loop( + cfg, + watch_config=watch_config, + default_engine_override=default_engine_override, + transport_id=transport_id, + transport_config=transport_config, + ) + else: + await _run_main_loop( + cfg, + poller=poller, + watch_config=watch_config, + default_engine_override=default_engine_override, + transport_id=transport_id, + transport_config=transport_config, + ) diff --git a/src/takopi/telegram/client.py b/src/takopi/telegram/client.py index 055d384..f64d9d9 100644 --- a/src/takopi/telegram/client.py +++ b/src/takopi/telegram/client.py @@ -412,7 +412,6 @@ class OutboxOp: execute: Callable[[], Awaitable[Any]] priority: int queued_at: float - updated_at: float chat_id: int | None label: str | None = None done: anyio.Event = field(default_factory=anyio.Event) @@ -465,8 +464,6 @@ class TelegramOutbox: if previous is not None: op.queued_at = previous.queued_at previous.set_result(None) - else: - op.queued_at = op.updated_at self._pending[key] = op self._cond.notify() if not wait: @@ -661,8 +658,7 @@ class TelegramClient: request = OutboxOp( execute=execute, priority=priority, - queued_at=0.0, - updated_at=self._clock(), + queued_at=self._clock(), chat_id=chat_id, label=label, ) diff --git a/src/takopi/telegram/commands.py b/src/takopi/telegram/commands.py new file mode 100644 index 0000000..373a971 --- /dev/null +++ b/src/takopi/telegram/commands.py @@ -0,0 +1,1399 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass +from functools import partial +from pathlib import Path + +import anyio + +from ..commands import ( + CommandContext, + CommandExecutor, + RunMode, + RunRequest, + RunResult, + get_command, +) +from ..context import RunContext +from ..config import ConfigError +from ..directives import DirectiveError +from ..ids import RESERVED_COMMAND_IDS, is_valid_id +from ..logging import bind_run_context, clear_context, get_logger +from ..markdown import MarkdownParts +from ..model import EngineId, ResumeToken +from ..plugins import COMMAND_GROUP, list_entrypoints +from ..progress import ProgressTracker +from ..router import RunnerUnavailableError +from ..runner import Runner +from ..runner_bridge import ( + ExecBridgeConfig, + IncomingMessage as RunnerIncomingMessage, + RunningTasks, + handle_message, +) +from ..scheduler import ThreadScheduler +from ..transport import MessageRef, RenderedMessage, SendOptions +from ..transport_runtime import ResolvedMessage, TransportRuntime +from ..utils.paths import reset_run_base_dir, set_run_base_dir +from .bridge import send_plain +from .context import ( + _format_context, + _format_ctx_status, + _merge_topic_context, + _parse_project_branch_args, + _usage_ctx_set, + _usage_topic, +) +from .files import ( + default_upload_name, + default_upload_path, + deny_reason, + format_bytes, + normalize_relative_path, + parse_file_command, + parse_file_prompt, + resolve_path_within_root, + split_command_args, + write_bytes_atomic, + ZipTooLargeError, + zip_directory, +) +from .render import prepare_telegram +from .topic_state import TopicStateStore +from .topics import ( + _maybe_rename_topic, + _maybe_update_topic_context, + _topic_key, + _topic_title, + _topics_chat_project, + _topics_command_error, +) +from .types import TelegramCallbackQuery, TelegramDocument, TelegramIncomingMessage + +logger = get_logger(__name__) + +__all__ = [ + "FILE_GET_USAGE", + "FILE_PUT_USAGE", + "_dispatch_command", + "_handle_file_command", + "_handle_file_get", + "_handle_file_put", + "_handle_file_put_default", + "_handle_media_group", + "_parse_slash_command", + "_reserved_commands", + "_set_command_menu", + "build_bot_commands", + "handle_callback_cancel", + "handle_cancel", + "is_cancel_command", +] + +_MAX_BOT_COMMANDS = 100 +FILE_PUT_USAGE = "usage: `/file put `" +FILE_GET_USAGE = "usage: `/file get `" + + +def is_cancel_command(text: str) -> bool: + stripped = text.strip() + if not stripped: + return False + command = stripped.split(maxsplit=1)[0] + return command == "/cancel" or command.startswith("/cancel@") + + +def _parse_slash_command(text: str) -> tuple[str | None, str]: + stripped = text.lstrip() + if not stripped.startswith("/"): + return None, text + lines = stripped.splitlines() + if not lines: + return None, text + first_line = lines[0] + token, _, rest = first_line.partition(" ") + command = token[1:] + if not command: + return None, text + if "@" in command: + command = command.split("@", 1)[0] + args_text = rest + if len(lines) > 1: + tail = "\n".join(lines[1:]) + args_text = f"{args_text}\n{tail}" if args_text else tail + return command.lower(), args_text + + +def build_bot_commands( + runtime: TransportRuntime, *, include_file: bool = True +) -> list[dict[str, str]]: + commands: list[dict[str, str]] = [] + seen: set[str] = set() + for engine_id in runtime.available_engine_ids(): + cmd = engine_id.lower() + if cmd in seen: + continue + commands.append({"command": cmd, "description": f"use agent: {cmd}"}) + seen.add(cmd) + for alias in runtime.project_aliases(): + cmd = alias.lower() + if cmd in seen: + continue + if not is_valid_id(cmd): + logger.debug( + "startup.command_menu.skip_project", + alias=alias, + ) + continue + commands.append({"command": cmd, "description": f"work on: {cmd}"}) + seen.add(cmd) + allowlist = runtime.allowlist + for ep in list_entrypoints( + COMMAND_GROUP, + allowlist=allowlist, + reserved_ids=RESERVED_COMMAND_IDS, + ): + try: + backend = get_command(ep.name, allowlist=allowlist) + except ConfigError as exc: + logger.info( + "startup.command_menu.skip_command", + command=ep.name, + error=str(exc), + ) + continue + cmd = backend.id.lower() + if cmd in seen: + continue + if not is_valid_id(cmd): + logger.debug( + "startup.command_menu.skip_command_id", + command=cmd, + ) + continue + description = backend.description or f"command: {cmd}" + commands.append({"command": cmd, "description": description}) + seen.add(cmd) + if include_file and "file" not in seen: + commands.append({"command": "file", "description": "upload or fetch files"}) + seen.add("file") + if "cancel" not in seen: + commands.append({"command": "cancel", "description": "cancel run"}) + if len(commands) > _MAX_BOT_COMMANDS: + logger.warning( + "startup.command_menu.too_many", + count=len(commands), + limit=_MAX_BOT_COMMANDS, + ) + commands = commands[:_MAX_BOT_COMMANDS] + if not any(cmd["command"] == "cancel" for cmd in commands): + commands[-1] = {"command": "cancel", "description": "cancel run"} + return commands + + +def _reserved_commands(runtime: TransportRuntime) -> set[str]: + return { + *{engine.lower() for engine in runtime.engine_ids}, + *{alias.lower() for alias in runtime.project_aliases()}, + *RESERVED_COMMAND_IDS, + } + + +async def _set_command_menu(cfg) -> None: + commands = build_bot_commands(cfg.runtime, include_file=cfg.files.enabled) + if not commands: + return + try: + ok = await cfg.bot.set_my_commands(commands) + except Exception as exc: + logger.info( + "startup.command_menu.failed", + error=str(exc), + error_type=exc.__class__.__name__, + ) + return + if not ok: + logger.info("startup.command_menu.rejected") + return + logger.info( + "startup.command_menu.updated", + commands=[cmd["command"] for cmd in commands], + ) + + +@dataclass(slots=True) +class _FilePutPlan: + resolved: ResolvedMessage + run_root: Path + path_value: str | None + force: bool + + +@dataclass(slots=True) +class _FilePutResult: + name: str + rel_path: Path | None + size: int | None + error: str | None + + +def resolve_file_put_paths( + plan: _FilePutPlan, + *, + cfg, + require_dir: bool, +) -> tuple[Path | None, Path | None, str | None]: + path_value = plan.path_value + if not path_value: + return None, None, None + if require_dir or path_value.endswith("/"): + base_dir = normalize_relative_path(path_value) + if base_dir is None: + return None, None, "invalid upload path." + deny_rule = deny_reason(base_dir, cfg.files.deny_globs) + if deny_rule is not None: + return None, None, f"path denied by rule: {deny_rule}" + base_target = resolve_path_within_root(plan.run_root, base_dir) + if base_target is None: + return None, None, "upload path escapes the repo root." + if base_target.exists() and not base_target.is_dir(): + return None, None, "upload path is a file." + return base_dir, None, None + rel_path = normalize_relative_path(path_value) + if rel_path is None: + return None, None, "invalid upload path." + return None, rel_path, None + + +async def _check_file_permissions(cfg, msg: TelegramIncomingMessage) -> bool: + reply = partial( + send_plain, + cfg.exec_cfg.transport, + chat_id=msg.chat_id, + user_msg_id=msg.message_id, + thread_id=msg.thread_id, + ) + sender_id = msg.sender_id + if sender_id is None: + await reply(text="cannot verify sender for file transfer.") + return False + if cfg.files.allowed_user_ids: + if sender_id not in cfg.files.allowed_user_ids: + await reply(text="file transfer is not allowed for this user.") + return False + return True + is_private = msg.chat_type == "private" + if msg.chat_type is None: + is_private = msg.chat_id > 0 + if is_private: + return True + member = await cfg.bot.get_chat_member(msg.chat_id, sender_id) + if not isinstance(member, dict): + await reply(text="failed to verify file transfer permissions.") + return False + status = member.get("status") + if status in {"creator", "administrator"}: + return True + await reply(text="file transfer is restricted to group admins.") + return False + + +async def _prepare_file_put_plan( + cfg, + msg: TelegramIncomingMessage, + args_text: str, + ambient_context: RunContext | None, + topic_store: TopicStateStore | None, +) -> _FilePutPlan | None: + reply = partial( + send_plain, + cfg.exec_cfg.transport, + chat_id=msg.chat_id, + user_msg_id=msg.message_id, + thread_id=msg.thread_id, + ) + if not await _check_file_permissions(cfg, msg): + return None + try: + resolved = cfg.runtime.resolve_message( + text=args_text, + reply_text=msg.reply_to_text, + ambient_context=ambient_context, + chat_id=msg.chat_id, + ) + except DirectiveError as exc: + await reply(text=f"error:\n{exc}") + return None + topic_key = _topic_key(msg, cfg) if topic_store is not None else None + await _maybe_update_topic_context( + cfg=cfg, + topic_store=topic_store, + topic_key=topic_key, + context=resolved.context, + context_source=resolved.context_source, + ) + if resolved.context is None or resolved.context.project is None: + await reply(text="no project context available for file upload.") + return None + try: + run_root = cfg.runtime.resolve_run_cwd(resolved.context) + except ConfigError as exc: + await reply(text=f"error:\n{exc}") + return None + if run_root is None: + await reply(text="no project context available for file upload.") + return None + path_value, force, error = parse_file_prompt(resolved.prompt, allow_empty=True) + if error is not None: + await reply(text=error) + return None + return _FilePutPlan( + resolved=resolved, + run_root=run_root, + path_value=path_value, + force=force, + ) + + +async def _save_document_payload( + cfg, + *, + document: TelegramDocument, + run_root: Path, + rel_path: Path | None, + base_dir: Path | None, + force: bool, +) -> _FilePutResult: + name = default_upload_name(document.file_name, None) + if ( + document.file_size is not None + and document.file_size > cfg.files.max_upload_bytes + ): + return _FilePutResult( + name=name, + rel_path=None, + size=None, + error="file is too large to upload.", + ) + file_info = await cfg.bot.get_file(document.file_id) + if not isinstance(file_info, dict): + return _FilePutResult( + name=name, + rel_path=None, + size=None, + error="failed to fetch file metadata.", + ) + file_path = file_info.get("file_path") + if not isinstance(file_path, str) or not file_path: + return _FilePutResult( + name=name, + rel_path=None, + size=None, + error="failed to fetch file metadata.", + ) + name = default_upload_name(document.file_name, file_path) + resolved_path = rel_path + if resolved_path is None: + if base_dir is None: + resolved_path = default_upload_path( + cfg.files.uploads_dir, document.file_name, file_path + ) + else: + resolved_path = base_dir / name + deny_rule = deny_reason(resolved_path, cfg.files.deny_globs) + if deny_rule is not None: + return _FilePutResult( + name=name, + rel_path=None, + size=None, + error=f"path denied by rule: {deny_rule}", + ) + target = resolve_path_within_root(run_root, resolved_path) + if target is None: + return _FilePutResult( + name=name, + rel_path=None, + size=None, + error="upload path escapes the repo root.", + ) + if target.exists(): + if target.is_dir(): + return _FilePutResult( + name=name, + rel_path=None, + size=None, + error="upload target is a directory.", + ) + if not force: + return _FilePutResult( + name=name, + rel_path=None, + size=None, + error="file already exists; use --force to overwrite.", + ) + payload = await cfg.bot.download_file(file_path) + if payload is None: + return _FilePutResult( + name=name, + rel_path=None, + size=None, + error="failed to download file.", + ) + if len(payload) > cfg.files.max_upload_bytes: + return _FilePutResult( + name=name, + rel_path=None, + size=None, + error="file is too large to upload.", + ) + try: + write_bytes_atomic(target, payload) + except OSError as exc: + return _FilePutResult( + name=name, + rel_path=None, + size=None, + error=f"failed to write file: {exc}", + ) + return _FilePutResult( + name=name, + rel_path=resolved_path, + size=len(payload), + error=None, + ) + + +async def _handle_file_command( + cfg, + msg: TelegramIncomingMessage, + args_text: str, + ambient_context: RunContext | None, + topic_store: TopicStateStore | None, +) -> None: + reply = partial( + send_plain, + cfg.exec_cfg.transport, + chat_id=msg.chat_id, + user_msg_id=msg.message_id, + thread_id=msg.thread_id, + ) + command, rest, error = parse_file_command(args_text) + if error is not None: + await reply(text=error) + return + if command == "put": + await _handle_file_put(cfg, msg, rest, ambient_context, topic_store) + else: + await _handle_file_get(cfg, msg, rest, ambient_context, topic_store) + + +async def _handle_file_put_default( + cfg, + msg: TelegramIncomingMessage, + ambient_context: RunContext | None, + topic_store: TopicStateStore | None, +) -> None: + await _handle_file_put(cfg, msg, "", ambient_context, topic_store) + + +async def _handle_file_put( + cfg, + msg: TelegramIncomingMessage, + args_text: str, + ambient_context: RunContext | None, + topic_store: TopicStateStore | None, +) -> None: + reply = partial( + send_plain, + cfg.exec_cfg.transport, + chat_id=msg.chat_id, + user_msg_id=msg.message_id, + thread_id=msg.thread_id, + ) + document = msg.document + if document is None: + await reply(text=FILE_PUT_USAGE) + return + plan = await _prepare_file_put_plan( + cfg, + msg, + args_text, + ambient_context, + topic_store, + ) + if plan is None: + return + base_dir, rel_path, error = resolve_file_put_paths( + plan, + cfg=cfg, + require_dir=False, + ) + if error is not None: + await reply(text=error) + return + result = await _save_document_payload( + cfg, + document=document, + run_root=plan.run_root, + rel_path=rel_path, + base_dir=base_dir, + force=plan.force, + ) + if result.error is not None: + await reply(text=result.error) + return + if result.rel_path is None or result.size is None: + await reply(text="failed to save file.") + return + context_label = _format_context(cfg.runtime, plan.resolved.context) + await reply( + text=( + f"saved `{result.rel_path.as_posix()}` " + f"in `{context_label}` ({format_bytes(result.size)})" + ), + ) + + +async def _handle_file_put_group( + cfg, + msg: TelegramIncomingMessage, + args_text: str, + messages: Sequence[TelegramIncomingMessage], + ambient_context: RunContext | None, + topic_store: TopicStateStore | None, +) -> None: + reply = partial( + send_plain, + cfg.exec_cfg.transport, + chat_id=msg.chat_id, + user_msg_id=msg.message_id, + thread_id=msg.thread_id, + ) + documents = [item.document for item in messages if item.document is not None] + if not documents: + await reply(text=FILE_PUT_USAGE) + return + plan = await _prepare_file_put_plan( + cfg, + msg, + args_text, + ambient_context, + topic_store, + ) + if plan is None: + return + base_dir, _, error = resolve_file_put_paths( + plan, + cfg=cfg, + require_dir=True, + ) + if error is not None: + await reply(text=error) + return + saved: list[_FilePutResult] = [] + failed: list[_FilePutResult] = [] + for document in documents: + result = await _save_document_payload( + cfg, + document=document, + run_root=plan.run_root, + rel_path=None, + base_dir=base_dir, + force=plan.force, + ) + if result.error is None: + saved.append(result) + else: + failed.append(result) + context_label = _format_context(cfg.runtime, plan.resolved.context) + total_bytes = sum(item.size or 0 for item in saved) + dir_label: Path | None = base_dir + if dir_label is None and saved: + first_path = saved[0].rel_path + if first_path is not None: + dir_label = first_path.parent + if saved: + saved_names = ", ".join(f"`{item.name}`" for item in saved) + if dir_label is not None: + dir_text = dir_label.as_posix() + if not dir_text.endswith("/"): + dir_text = f"{dir_text}/" + text = ( + f"saved {saved_names} to `{dir_text}` " + f"in `{context_label}` ({format_bytes(total_bytes)})" + ) + else: + text = ( + f"saved {saved_names} in `{context_label}` " + f"({format_bytes(total_bytes)})" + ) + else: + text = "failed to upload files." + if failed: + errors = ", ".join( + f"`{item.name}` ({item.error})" for item in failed if item.error is not None + ) + if errors: + text = f"{text}\n\nfailed: {errors}" + await reply(text=text) + + +async def _handle_media_group( + cfg, + messages: Sequence[TelegramIncomingMessage], + topic_store: TopicStateStore | None, +) -> None: + if not messages: + return + ordered = sorted(messages, key=lambda item: item.message_id) + command_msg = next( + (item for item in ordered if item.text.strip()), + ordered[0], + ) + reply = partial( + send_plain, + cfg.exec_cfg.transport, + chat_id=command_msg.chat_id, + user_msg_id=command_msg.message_id, + thread_id=command_msg.thread_id, + ) + topic_key = _topic_key(command_msg, cfg) if topic_store is not None else None + chat_project = _topics_chat_project(cfg, command_msg.chat_id) + bound_context = ( + await topic_store.get_context(*topic_key) + if topic_store is not None and topic_key is not None + else None + ) + ambient_context = _merge_topic_context( + chat_project=chat_project, bound=bound_context + ) + command_id, args_text = _parse_slash_command(command_msg.text) + if command_id == "file": + command, rest, error = parse_file_command(args_text) + if error is not None: + await reply(text=error) + return + if command == "put": + await _handle_file_put_group( + cfg, + command_msg, + rest, + ordered, + ambient_context, + topic_store, + ) + return + if cfg.files.enabled and cfg.files.auto_put and not command_msg.text.strip(): + await _handle_file_put_group( + cfg, + command_msg, + "", + ordered, + ambient_context, + topic_store, + ) + return + await reply(text=FILE_PUT_USAGE) + + +async def _handle_file_get( + cfg, + msg: TelegramIncomingMessage, + args_text: str, + ambient_context: RunContext | None, + topic_store: TopicStateStore | None, +) -> None: + reply = partial( + send_plain, + cfg.exec_cfg.transport, + chat_id=msg.chat_id, + user_msg_id=msg.message_id, + thread_id=msg.thread_id, + ) + if not await _check_file_permissions(cfg, msg): + return + try: + resolved = cfg.runtime.resolve_message( + text=args_text, + reply_text=msg.reply_to_text, + ambient_context=ambient_context, + chat_id=msg.chat_id, + ) + except DirectiveError as exc: + await reply(text=f"error:\n{exc}") + return + topic_key = _topic_key(msg, cfg) if topic_store is not None else None + await _maybe_update_topic_context( + cfg=cfg, + topic_store=topic_store, + topic_key=topic_key, + context=resolved.context, + context_source=resolved.context_source, + ) + if resolved.context is None or resolved.context.project is None: + await reply(text="no project context available for file download.") + return + try: + run_root = cfg.runtime.resolve_run_cwd(resolved.context) + except ConfigError as exc: + await reply(text=f"error:\n{exc}") + return + if run_root is None: + await reply(text="no project context available for file download.") + return + path_value = resolved.prompt + if not path_value.strip(): + await reply(text=FILE_GET_USAGE) + return + rel_path = normalize_relative_path(path_value) + if rel_path is None: + await reply(text="invalid download path.") + return + deny_rule = deny_reason(rel_path, cfg.files.deny_globs) + if deny_rule is not None: + await reply(text=f"path denied by rule: {deny_rule}") + return + target = resolve_path_within_root(run_root, rel_path) + if target is None: + await reply(text="download path escapes the repo root.") + return + if not target.exists(): + await reply(text="file does not exist.") + return + if target.is_dir(): + try: + payload = zip_directory( + run_root, + rel_path, + cfg.files.deny_globs, + max_bytes=cfg.files.max_download_bytes, + ) + except ZipTooLargeError: + await reply(text="file is too large to send.") + return + except OSError as exc: + await reply(text=f"failed to read directory: {exc}") + return + filename = f"{rel_path.name or 'archive'}.zip" + else: + try: + size = target.stat().st_size + if size > cfg.files.max_download_bytes: + await reply(text="file is too large to send.") + return + payload = target.read_bytes() + except OSError as exc: + await reply(text=f"failed to read file: {exc}") + return + filename = target.name + if len(payload) > cfg.files.max_download_bytes: + await reply(text="file is too large to send.") + return + sent = await cfg.bot.send_document( + chat_id=msg.chat_id, + filename=filename, + content=payload, + reply_to_message_id=msg.message_id, + message_thread_id=msg.thread_id, + ) + if sent is None: + await reply(text="failed to send file.") + return + + +async def _handle_ctx_command( + cfg, + msg: TelegramIncomingMessage, + args_text: str, + store: TopicStateStore, + *, + resolved_scope: str | None = None, + scope_chat_ids: frozenset[int] | None = None, +) -> None: + reply = partial( + send_plain, + cfg.exec_cfg.transport, + chat_id=msg.chat_id, + user_msg_id=msg.message_id, + thread_id=msg.thread_id, + ) + error = _topics_command_error( + cfg, + msg.chat_id, + resolved_scope=resolved_scope, + scope_chat_ids=scope_chat_ids, + ) + if error is not None: + await reply(text=error) + return + chat_project = _topics_chat_project(cfg, msg.chat_id) + tkey = _topic_key(msg, cfg, scope_chat_ids=scope_chat_ids) + if tkey is None: + await reply(text="this command only works inside a topic.") + return + tokens = split_command_args(args_text) + action = tokens[0].lower() if tokens else "show" + if action in {"show", ""}: + snapshot = await store.get_thread(*tkey) + bound = snapshot.context if snapshot is not None else None + ambient = _merge_topic_context(chat_project=chat_project, bound=bound) + resolved = cfg.runtime.resolve_message( + text="", + reply_text=msg.reply_to_text, + chat_id=msg.chat_id, + ambient_context=ambient, + ) + text = _format_ctx_status( + cfg=cfg, + runtime=cfg.runtime, + bound=bound, + resolved=resolved.context, + context_source=resolved.context_source, + snapshot=snapshot, + chat_project=chat_project, + ) + await reply(text=text) + return + if action == "set": + rest = " ".join(tokens[1:]) + context, error = _parse_project_branch_args( + rest, + runtime=cfg.runtime, + require_branch=False, + chat_project=chat_project, + ) + if error is not None: + await reply( + text=f"error:\n{error}\n{_usage_ctx_set(chat_project=chat_project)}", + ) + return + if context is None: + await reply( + text=f"error:\n{_usage_ctx_set(chat_project=chat_project)}", + ) + return + await store.set_context(*tkey, context) + await _maybe_rename_topic( + cfg, + store, + chat_id=tkey[0], + thread_id=tkey[1], + context=context, + ) + await reply( + text=f"topic bound to `{_format_context(cfg.runtime, context)}`", + ) + return + if action == "clear": + await store.clear_context(*tkey) + await reply(text="topic binding cleared.") + return + await reply( + text="unknown `/ctx` command. use `/ctx`, `/ctx set`, or `/ctx clear`.", + ) + + +async def _handle_new_command( + cfg, + msg: TelegramIncomingMessage, + store: TopicStateStore, + *, + resolved_scope: str | None = None, + scope_chat_ids: frozenset[int] | None = None, +) -> None: + reply = partial( + send_plain, + cfg.exec_cfg.transport, + chat_id=msg.chat_id, + user_msg_id=msg.message_id, + thread_id=msg.thread_id, + ) + error = _topics_command_error( + cfg, + msg.chat_id, + resolved_scope=resolved_scope, + scope_chat_ids=scope_chat_ids, + ) + if error is not None: + await reply(text=error) + return + tkey = _topic_key(msg, cfg, scope_chat_ids=scope_chat_ids) + if tkey is None: + await reply(text="this command only works inside a topic.") + return + await store.clear_sessions(*tkey) + await reply(text="cleared stored sessions for this topic.") + + +async def _handle_topic_command( + cfg, + msg: TelegramIncomingMessage, + args_text: str, + store: TopicStateStore, + *, + resolved_scope: str | None = None, + scope_chat_ids: frozenset[int] | None = None, +) -> None: + reply = partial( + send_plain, + cfg.exec_cfg.transport, + chat_id=msg.chat_id, + user_msg_id=msg.message_id, + thread_id=msg.thread_id, + ) + error = _topics_command_error( + cfg, + msg.chat_id, + resolved_scope=resolved_scope, + scope_chat_ids=scope_chat_ids, + ) + if error is not None: + await reply(text=error) + return + chat_project = _topics_chat_project(cfg, msg.chat_id) + context, error = _parse_project_branch_args( + args_text, + runtime=cfg.runtime, + require_branch=True, + chat_project=chat_project, + ) + if error is not None or context is None: + usage = _usage_topic(chat_project=chat_project) + text = f"error:\n{error}\n{usage}" if error else usage + await reply(text=text) + return + existing = await store.find_thread_for_context(msg.chat_id, context) + if existing is not None: + await reply( + text=f"topic already exists for {_format_context(cfg.runtime, context)} " + "in this chat.", + ) + return + title = _topic_title(runtime=cfg.runtime, context=context) + created = await cfg.bot.create_forum_topic(msg.chat_id, title) + thread_id = created.get("message_thread_id") if isinstance(created, dict) else None + if isinstance(thread_id, bool) or not isinstance(thread_id, int): + await reply(text="failed to create topic.") + return + await store.set_context( + msg.chat_id, + thread_id, + context, + topic_title=title, + ) + await reply(text=f"created topic `{title}`.") + bound_text = f"topic bound to `{_format_context(cfg.runtime, context)}`" + rendered_text, entities = prepare_telegram(MarkdownParts(header=bound_text)) + await cfg.exec_cfg.transport.send( + channel_id=msg.chat_id, + message=RenderedMessage(text=rendered_text, extra={"entities": entities}), + options=SendOptions(thread_id=thread_id), + ) + + +async def handle_cancel( + cfg, + msg: TelegramIncomingMessage, + running_tasks: RunningTasks, +) -> None: + reply = partial( + send_plain, + cfg.exec_cfg.transport, + chat_id=msg.chat_id, + user_msg_id=msg.message_id, + thread_id=msg.thread_id, + ) + chat_id = msg.chat_id + reply_id = msg.reply_to_message_id + + if reply_id is None: + if msg.reply_to_text: + await reply(text="nothing is currently running for that message.") + return + await reply(text="reply to the progress message to cancel.") + return + + progress_ref = MessageRef(channel_id=chat_id, message_id=reply_id) + running_task = running_tasks.get(progress_ref) + if running_task is None: + await reply(text="nothing is currently running for that message.") + return + + logger.info( + "cancel.requested", + chat_id=chat_id, + progress_message_id=reply_id, + ) + running_task.cancel_requested.set() + + +async def handle_callback_cancel( + cfg, + query: TelegramCallbackQuery, + running_tasks: RunningTasks, +) -> None: + progress_ref = MessageRef(channel_id=query.chat_id, message_id=query.message_id) + running_task = running_tasks.get(progress_ref) + if running_task is None: + await cfg.bot.answer_callback_query( + callback_query_id=query.callback_query_id, + text="nothing is currently running for that message.", + ) + return + logger.info( + "cancel.requested", + chat_id=query.chat_id, + progress_message_id=query.message_id, + ) + running_task.cancel_requested.set() + await cfg.bot.answer_callback_query( + callback_query_id=query.callback_query_id, + text="cancelling...", + ) + + +async def _send_runner_unavailable( + exec_cfg: ExecBridgeConfig, + *, + chat_id: int, + user_msg_id: int, + resume_token: ResumeToken | None, + runner: Runner, + reason: str, + thread_id: int | None = None, +) -> None: + tracker = ProgressTracker(engine=runner.engine) + tracker.set_resume(resume_token) + state = tracker.snapshot(resume_formatter=runner.format_resume) + message = exec_cfg.presenter.render_final( + state, + elapsed_s=0.0, + status="error", + answer=f"error:\n{reason}", + ) + reply_to = MessageRef(channel_id=chat_id, message_id=user_msg_id) + await exec_cfg.transport.send( + channel_id=chat_id, + message=message, + options=SendOptions(reply_to=reply_to, notify=True, thread_id=thread_id), + ) + + +async def _run_engine( + *, + exec_cfg: ExecBridgeConfig, + runtime: TransportRuntime, + running_tasks: RunningTasks | None, + chat_id: int, + user_msg_id: int, + text: str, + resume_token: ResumeToken | None, + context: RunContext | None, + reply_ref: MessageRef | None = None, + on_thread_known: Callable[[ResumeToken, anyio.Event], Awaitable[None]] + | None = None, + engine_override: EngineId | None = None, + thread_id: int | None = None, +) -> None: + reply = partial( + send_plain, + exec_cfg.transport, + chat_id=chat_id, + user_msg_id=user_msg_id, + thread_id=thread_id, + ) + try: + try: + entry = runtime.resolve_runner( + resume_token=resume_token, + engine_override=engine_override, + ) + except RunnerUnavailableError as exc: + await reply(text=f"error:\n{exc}") + return + if not entry.available: + reason = entry.issue or "engine unavailable" + await _send_runner_unavailable( + exec_cfg, + chat_id=chat_id, + user_msg_id=user_msg_id, + resume_token=resume_token, + runner=entry.runner, + reason=reason, + thread_id=thread_id, + ) + return + try: + cwd = runtime.resolve_run_cwd(context) + except ConfigError as exc: + await reply(text=f"error:\n{exc}") + return + run_base_token = set_run_base_dir(cwd) + try: + run_fields = { + "chat_id": chat_id, + "user_msg_id": user_msg_id, + "engine": entry.runner.engine, + "resume": resume_token.value if resume_token else None, + } + if context is not None: + run_fields["project"] = context.project + run_fields["branch"] = context.branch + if cwd is not None: + run_fields["cwd"] = str(cwd) + bind_run_context(**run_fields) + context_line = runtime.format_context_line(context) + incoming = RunnerIncomingMessage( + channel_id=chat_id, + message_id=user_msg_id, + text=text, + reply_to=reply_ref, + thread_id=thread_id, + ) + await handle_message( + exec_cfg, + runner=entry.runner, + incoming=incoming, + resume_token=resume_token, + context=context, + context_line=context_line, + strip_resume_line=runtime.is_resume_line, + running_tasks=running_tasks, + on_thread_known=on_thread_known, + ) + finally: + reset_run_base_dir(run_base_token) + except Exception as exc: + logger.exception( + "handle.worker_failed", + error=str(exc), + error_type=exc.__class__.__name__, + ) + finally: + clear_context() + + +class _CaptureTransport: + def __init__(self) -> None: + self._next_id = 1 + self.last_message: RenderedMessage | None = None + + async def send( + self, + *, + channel_id: int | str, + message: RenderedMessage, + options: SendOptions | None = None, + ) -> MessageRef: + _ = options + ref = MessageRef(channel_id=channel_id, message_id=self._next_id) + self._next_id += 1 + self.last_message = message + return ref + + async def edit( + self, *, ref: MessageRef, message: RenderedMessage, wait: bool = True + ) -> MessageRef: + _ = ref, wait + self.last_message = message + return ref + + async def delete(self, *, ref: MessageRef) -> bool: + _ = ref + return True + + async def close(self) -> None: + return None + + +class _TelegramCommandExecutor(CommandExecutor): + def __init__( + self, + *, + exec_cfg: ExecBridgeConfig, + runtime: TransportRuntime, + running_tasks: RunningTasks, + scheduler: ThreadScheduler, + chat_id: int, + user_msg_id: int, + thread_id: int | None, + ) -> None: + self._exec_cfg = exec_cfg + self._runtime = runtime + self._running_tasks = running_tasks + self._scheduler = scheduler + self._chat_id = chat_id + self._user_msg_id = user_msg_id + self._thread_id = thread_id + self._reply_ref = MessageRef(channel_id=chat_id, message_id=user_msg_id) + + def _apply_default_context(self, request: RunRequest) -> RunRequest: + if request.context is not None: + return request + context = self._runtime.default_context_for_chat(self._chat_id) + if context is None: + return request + return RunRequest( + prompt=request.prompt, + engine=request.engine, + context=context, + ) + + async def send( + self, + message: RenderedMessage | str, + *, + reply_to: MessageRef | None = None, + notify: bool = True, + ) -> MessageRef | None: + rendered = ( + message + if isinstance(message, RenderedMessage) + else RenderedMessage(text=message) + ) + reply_ref = self._reply_ref if reply_to is None else reply_to + return await self._exec_cfg.transport.send( + channel_id=self._chat_id, + message=rendered, + options=SendOptions( + reply_to=reply_ref, + notify=notify, + thread_id=self._thread_id, + ), + ) + + async def run_one( + self, request: RunRequest, *, mode: RunMode = "emit" + ) -> RunResult: + request = self._apply_default_context(request) + engine = self._runtime.resolve_engine( + engine_override=request.engine, + context=request.context, + ) + if mode == "capture": + capture = _CaptureTransport() + exec_cfg = ExecBridgeConfig( + transport=capture, + presenter=self._exec_cfg.presenter, + final_notify=False, + ) + await _run_engine( + exec_cfg=exec_cfg, + runtime=self._runtime, + running_tasks={}, + chat_id=self._chat_id, + user_msg_id=self._user_msg_id, + text=request.prompt, + resume_token=None, + context=request.context, + reply_ref=self._reply_ref, + on_thread_known=None, + engine_override=engine, + thread_id=self._thread_id, + ) + return RunResult(engine=engine, message=capture.last_message) + await _run_engine( + exec_cfg=self._exec_cfg, + runtime=self._runtime, + running_tasks=self._running_tasks, + chat_id=self._chat_id, + user_msg_id=self._user_msg_id, + text=request.prompt, + resume_token=None, + context=request.context, + reply_ref=self._reply_ref, + on_thread_known=self._scheduler.note_thread_known, + engine_override=engine, + thread_id=self._thread_id, + ) + return RunResult(engine=engine, message=None) + + async def run_many( + self, + requests: Sequence[RunRequest], + *, + mode: RunMode = "emit", + parallel: bool = False, + ) -> list[RunResult]: + if not parallel: + return [await self.run_one(request, mode=mode) for request in requests] + results: list[RunResult | None] = [None] * len(requests) + + async with anyio.create_task_group() as tg: + + async def run_idx(idx: int, request: RunRequest) -> None: + results[idx] = await self.run_one(request, mode=mode) + + for idx, request in enumerate(requests): + tg.start_soon(run_idx, idx, request) + + return [result for result in results if result is not None] + + +async def _dispatch_command( + cfg, + msg: TelegramIncomingMessage, + text: str, + command_id: str, + args_text: str, + running_tasks: RunningTasks, + scheduler: ThreadScheduler, +) -> None: + allowlist = cfg.runtime.allowlist + chat_id = msg.chat_id + user_msg_id = msg.message_id + reply_ref = ( + MessageRef(channel_id=chat_id, message_id=msg.reply_to_message_id) + if msg.reply_to_message_id is not None + else None + ) + executor = _TelegramCommandExecutor( + exec_cfg=cfg.exec_cfg, + runtime=cfg.runtime, + running_tasks=running_tasks, + scheduler=scheduler, + chat_id=chat_id, + user_msg_id=user_msg_id, + thread_id=msg.thread_id, + ) + message_ref = MessageRef(channel_id=chat_id, message_id=user_msg_id) + try: + backend = get_command(command_id, allowlist=allowlist, required=False) + except ConfigError as exc: + await executor.send(f"error:\n{exc}", reply_to=message_ref, notify=True) + return + if backend is None: + return + try: + plugin_config = cfg.runtime.plugin_config(command_id) + except ConfigError as exc: + await executor.send(f"error:\n{exc}", reply_to=message_ref, notify=True) + return + ctx = CommandContext( + command=command_id, + text=text, + args_text=args_text, + args=split_command_args(args_text), + message=message_ref, + reply_to=reply_ref, + reply_text=msg.reply_to_text, + config_path=cfg.runtime.config_path, + plugin_config=plugin_config, + runtime=cfg.runtime, + executor=executor, + ) + try: + result = await backend.handle(ctx) + except Exception as exc: + logger.exception( + "command.failed", + command=command_id, + error=str(exc), + error_type=exc.__class__.__name__, + ) + await executor.send(f"error:\n{exc}", reply_to=message_ref, notify=True) + return + if result is not None: + reply_to = message_ref if result.reply_to is None else result.reply_to + await executor.send(result.text, reply_to=reply_to, notify=result.notify) diff --git a/src/takopi/telegram/context.py b/src/takopi/telegram/context.py new file mode 100644 index 0000000..cf162db --- /dev/null +++ b/src/takopi/telegram/context.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..context import RunContext +from ..transport_runtime import TransportRuntime +from .topic_state import TopicThreadSnapshot +from .topics import _topics_scope_label + +if TYPE_CHECKING: + from .bridge import TelegramBridgeConfig + +__all__ = [ + "_format_context", + "_format_ctx_status", + "_merge_topic_context", + "_parse_project_branch_args", + "_usage_ctx_set", + "_usage_topic", +] + + +def _format_context(runtime: TransportRuntime, context: RunContext | None) -> str: + if context is None or context.project is None: + return "none" + project = runtime.project_alias_for_key(context.project) + if context.branch: + return f"{project} @{context.branch}" + return project + + +def _usage_ctx_set(*, chat_project: str | None) -> str: + if chat_project is not None: + return "usage: `/ctx set [@branch]`" + return "usage: `/ctx set [@branch]`" + + +def _usage_topic(*, chat_project: str | None) -> str: + if chat_project is not None: + return "usage: `/topic @branch`" + return "usage: `/topic @branch`" + + +def _parse_project_branch_args( + args_text: str, + *, + runtime: TransportRuntime, + require_branch: bool, + chat_project: str | None, +) -> tuple[RunContext | None, str | None]: + from .files import split_command_args + + tokens = split_command_args(args_text) + if not tokens: + return ( + None, + _usage_topic(chat_project=chat_project) + if require_branch + else _usage_ctx_set(chat_project=chat_project), + ) + if len(tokens) > 2: + return None, "too many arguments" + project_token: str | None = None + branch: str | None = None + first = tokens[0] + if first.startswith("@"): + branch = first[1:] or None + else: + project_token = first + if len(tokens) == 2: + second = tokens[1] + if not second.startswith("@"): + return None, "branch must be prefixed with @" + branch = second[1:] or None + + project_key: str | None = None + if chat_project is not None: + if project_token is None: + project_key = chat_project + else: + normalized = runtime.normalize_project_key(project_token) + if normalized is None: + return None, f"unknown project {project_token!r}" + if normalized != chat_project: + expected = runtime.project_alias_for_key(chat_project) + return None, (f"project mismatch for this chat; expected {expected!r}.") + project_key = normalized + else: + if project_token is None: + return None, "project is required" + project_key = runtime.normalize_project_key(project_token) + if project_key is None: + return None, f"unknown project {project_token!r}" + + if require_branch and not branch: + return None, "branch is required" + + return RunContext(project=project_key, branch=branch), None + + +def _format_ctx_status( + *, + cfg: TelegramBridgeConfig, + runtime: TransportRuntime, + bound: RunContext | None, + resolved: RunContext | None, + context_source: str, + snapshot: TopicThreadSnapshot | None, + chat_project: str | None, +) -> str: + lines = [ + f"topics: enabled (scope={_topics_scope_label(cfg)})", + f"bound ctx: {_format_context(runtime, bound)}", + f"resolved ctx: {_format_context(runtime, resolved)} (source: {context_source})", + ] + if chat_project is None and bound is None: + topic_usage = ( + _usage_topic(chat_project=chat_project).removeprefix("usage: ").strip() + ) + ctx_usage = ( + _usage_ctx_set(chat_project=chat_project).removeprefix("usage: ").strip() + ) + lines.append(f"note: unbound topic — bind with {topic_usage} or {ctx_usage}") + sessions = None + if snapshot is not None and snapshot.sessions: + sessions = ", ".join(sorted(snapshot.sessions)) + lines.append(f"sessions: {sessions or 'none'}") + return "\n".join(lines) + + +def _merge_topic_context( + *, chat_project: str | None, bound: RunContext | None +) -> RunContext | None: + if chat_project is None: + return bound + if bound is None: + return RunContext(project=chat_project, branch=None) + if bound.project is None: + return RunContext(project=chat_project, branch=bound.branch) + return bound diff --git a/src/takopi/telegram/files.py b/src/takopi/telegram/files.py index bf907eb..b9ec0d2 100644 --- a/src/takopi/telegram/files.py +++ b/src/takopi/telegram/files.py @@ -8,6 +8,22 @@ import zipfile from collections.abc import Sequence from pathlib import Path, PurePosixPath +__all__ = [ + "ZipTooLargeError", + "default_upload_name", + "default_upload_path", + "deny_reason", + "file_usage", + "format_bytes", + "normalize_relative_path", + "parse_file_command", + "parse_file_prompt", + "resolve_path_within_root", + "split_command_args", + "write_bytes_atomic", + "zip_directory", +] + def split_command_args(text: str) -> tuple[str, ...]: if not text.strip(): @@ -22,14 +38,6 @@ def file_usage() -> str: return "usage: `/file put ` or `/file get `" -def file_put_usage() -> str: - return "usage: `/file put `" - - -def file_get_usage() -> str: - return "usage: `/file get `" - - def parse_file_command(args_text: str) -> tuple[str | None, str, str | None]: tokens = split_command_args(args_text) if not tokens: diff --git a/src/takopi/telegram/loop.py b/src/takopi/telegram/loop.py new file mode 100644 index 0000000..ac0437f --- /dev/null +++ b/src/takopi/telegram/loop.py @@ -0,0 +1,660 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable +from dataclasses import dataclass +from functools import partial + +import anyio +from anyio.abc import TaskGroup + +from ..config import ConfigError +from ..config_watch import ConfigReload, watch_config as watch_config_changes +from ..commands import list_command_ids +from ..directives import DirectiveError +from ..logging import get_logger +from ..model import EngineId, ResumeToken +from ..scheduler import ThreadJob, ThreadScheduler +from ..transport import MessageRef +from ..context import RunContext +from .bridge import CANCEL_CALLBACK_DATA, TelegramBridgeConfig, send_plain +from .commands import ( + FILE_PUT_USAGE, + _dispatch_command, + _handle_ctx_command, + _handle_file_command, + _handle_file_put_default, + _handle_media_group, + _handle_new_command, + _handle_topic_command, + _parse_slash_command, + _reserved_commands, + _run_engine, + _set_command_menu, + handle_callback_cancel, + handle_cancel, + is_cancel_command, +) +from .context import _merge_topic_context, _usage_ctx_set, _usage_topic +from .topics import ( + _maybe_rename_topic, + _resolve_topics_scope, + _topic_key, + _topics_chat_allowed, + _topics_chat_project, + _validate_topics_setup, +) +from .client import poll_incoming +from .topic_state import TopicStateStore, resolve_state_path +from .types import ( + TelegramCallbackQuery, + TelegramIncomingMessage, + TelegramIncomingUpdate, +) +from .voice import transcribe_voice + +logger = get_logger(__name__) + +__all__ = ["poll_updates", "run_main_loop", "send_with_resume"] + +_MEDIA_GROUP_DEBOUNCE_S = 1.0 + + +def _allowed_chat_ids(cfg: TelegramBridgeConfig) -> set[int]: + allowed = set(cfg.chat_ids or ()) + allowed.add(cfg.chat_id) + allowed.update(cfg.runtime.project_chat_ids()) + return allowed + + +async def _send_startup(cfg: TelegramBridgeConfig) -> None: + from ..markdown import MarkdownParts + from ..transport import RenderedMessage + from .render import prepare_telegram + + logger.debug("startup.message", text=cfg.startup_msg) + parts = MarkdownParts(header=cfg.startup_msg) + text, entities = prepare_telegram(parts) + message = RenderedMessage(text=text, extra={"entities": entities}) + sent = await cfg.exec_cfg.transport.send( + channel_id=cfg.chat_id, + message=message, + ) + if sent is not None: + logger.info("startup.sent", chat_id=cfg.chat_id) + + +def _dispatch_builtin_command( + *, + cfg: TelegramBridgeConfig, + msg: TelegramIncomingMessage, + command_id: str, + args_text: str, + ambient_context: RunContext | None, + topic_store: TopicStateStore | None, + resolved_scope: str | None, + scope_chat_ids: frozenset[int], + reply: Callable[..., Awaitable[None]], + task_group: TaskGroup, +) -> bool: + handlers: dict[str, Callable[[], Awaitable[None]]] = {} + + if command_id == "file": + if not cfg.files.enabled: + handlers["file"] = partial( + reply, + text="file transfer disabled; enable `[transports.telegram.files]`.", + ) + else: + handlers["file"] = partial( + _handle_file_command, + cfg, + msg, + args_text, + ambient_context, + topic_store, + ) + + if cfg.topics.enabled and topic_store is not None: + handlers.update( + { + "ctx": partial( + _handle_ctx_command, + cfg, + msg, + args_text, + topic_store, + resolved_scope=resolved_scope, + scope_chat_ids=scope_chat_ids, + ), + "new": partial( + _handle_new_command, + cfg, + msg, + topic_store, + resolved_scope=resolved_scope, + scope_chat_ids=scope_chat_ids, + ), + "topic": partial( + _handle_topic_command, + cfg, + msg, + args_text, + topic_store, + resolved_scope=resolved_scope, + scope_chat_ids=scope_chat_ids, + ), + } + ) + + handler = handlers.get(command_id) + if handler is None: + return False + task_group.start_soon(handler) + return True + + +async def _drain_backlog(cfg: TelegramBridgeConfig, offset: int | None) -> int | None: + drained = 0 + while True: + updates = await cfg.bot.get_updates( + offset=offset, + timeout_s=0, + allowed_updates=["message", "callback_query"], + ) + if updates is None: + logger.info("startup.backlog.failed") + return offset + logger.debug("startup.backlog.updates", updates=updates) + if not updates: + if drained: + logger.info("startup.backlog.drained", count=drained) + return offset + offset = updates[-1]["update_id"] + 1 + drained += len(updates) + + +async def poll_updates( + cfg: TelegramBridgeConfig, +) -> AsyncIterator[TelegramIncomingUpdate]: + offset: int | None = None + offset = await _drain_backlog(cfg, offset) + await _send_startup(cfg) + + async for msg in poll_incoming( + cfg.bot, + chat_ids=lambda: _allowed_chat_ids(cfg), + offset=offset, + ): + yield msg + + +@dataclass(slots=True) +class _MediaGroupState: + messages: list[TelegramIncomingMessage] + token: int = 0 + + +def _diff_keys(old: dict[str, object], new: dict[str, object]) -> list[str]: + keys = set(old) | set(new) + return sorted(key for key in keys if old.get(key) != new.get(key)) + + +async def _wait_for_resume(running_task) -> ResumeToken | None: + if running_task.resume is not None: + return running_task.resume + resume: ResumeToken | None = None + + async with anyio.create_task_group() as tg: + + async def wait_resume() -> None: + nonlocal resume + await running_task.resume_ready.wait() + resume = running_task.resume + tg.cancel_scope.cancel() + + async def wait_done() -> None: + await running_task.done.wait() + tg.cancel_scope.cancel() + + tg.start_soon(wait_resume) + tg.start_soon(wait_done) + + return resume + + +async def send_with_resume( + cfg: TelegramBridgeConfig, + enqueue: Callable[ + [int, int, str, ResumeToken, RunContext | None, int | None], Awaitable[None] + ], + running_task, + chat_id: int, + user_msg_id: int, + thread_id: int | None, + text: str, +) -> None: + reply = partial( + send_plain, + cfg.exec_cfg.transport, + chat_id=chat_id, + user_msg_id=user_msg_id, + thread_id=thread_id, + ) + resume = await _wait_for_resume(running_task) + if resume is None: + await reply( + text="resume token not ready yet; try replying to the final message.", + notify=False, + ) + return + await enqueue( + chat_id, + user_msg_id, + text, + resume, + running_task.context, + thread_id, + ) + + +async def run_main_loop( + cfg: TelegramBridgeConfig, + poller: Callable[ + [TelegramBridgeConfig], AsyncIterator[TelegramIncomingUpdate] + ] = poll_updates, + *, + watch_config: bool | None = None, + default_engine_override: str | None = None, + transport_id: str | None = None, + transport_config: dict[str, object] | None = None, +) -> None: + from ..runner_bridge import RunningTasks + + running_tasks: RunningTasks = {} + command_ids = { + command_id.lower() + for command_id in list_command_ids(allowlist=cfg.runtime.allowlist) + } + reserved_commands = _reserved_commands(cfg.runtime) + transport_snapshot = ( + dict(transport_config) if transport_config is not None else None + ) + topic_store: TopicStateStore | None = None + media_groups: dict[tuple[int, str], _MediaGroupState] = {} + resolved_topics_scope: str | None = None + topics_chat_ids: frozenset[int] = frozenset() + + def refresh_topics_scope() -> None: + nonlocal resolved_topics_scope, topics_chat_ids + if cfg.topics.enabled: + resolved_topics_scope, topics_chat_ids = _resolve_topics_scope(cfg) + else: + resolved_topics_scope = None + topics_chat_ids = frozenset() + + def refresh_commands() -> None: + nonlocal command_ids, reserved_commands + allowlist = cfg.runtime.allowlist + command_ids = { + command_id.lower() for command_id in list_command_ids(allowlist=allowlist) + } + reserved_commands = _reserved_commands(cfg.runtime) + + try: + if cfg.topics.enabled: + config_path = cfg.runtime.config_path + if config_path is None: + raise ConfigError( + "topics enabled but config path is not set; cannot locate state file." + ) + topic_store = TopicStateStore(resolve_state_path(config_path)) + await _validate_topics_setup(cfg) + refresh_topics_scope() + logger.info( + "topics.enabled", + scope=cfg.topics.scope, + resolved_scope=resolved_topics_scope, + state_path=str(resolve_state_path(config_path)), + ) + await _set_command_menu(cfg) + async with anyio.create_task_group() as tg: + config_path = cfg.runtime.config_path + watch_enabled = bool(watch_config) and config_path is not None + + async def handle_reload(reload: ConfigReload) -> None: + nonlocal transport_snapshot, transport_id + refresh_commands() + refresh_topics_scope() + await _set_command_menu(cfg) + if transport_snapshot is not None: + new_snapshot = reload.settings.transports.telegram.model_dump() + changed = _diff_keys(transport_snapshot, new_snapshot) + if changed: + logger.warning( + "config.reload.transport_config_changed", + transport="telegram", + keys=changed, + restart_required=True, + ) + transport_snapshot = new_snapshot + if ( + transport_id is not None + and reload.settings.transport != transport_id + ): + logger.warning( + "config.reload.transport_changed", + old=transport_id, + new=reload.settings.transport, + restart_required=True, + ) + transport_id = reload.settings.transport + + if watch_enabled and config_path is not None: + + async def run_config_watch() -> None: + await watch_config_changes( + config_path=config_path, + runtime=cfg.runtime, + default_engine_override=default_engine_override, + on_reload=handle_reload, + ) + + tg.start_soon(run_config_watch) + + def wrap_on_thread_known( + base_cb: Callable[[ResumeToken, anyio.Event], Awaitable[None]] | None, + topic_key: tuple[int, int] | None, + ) -> Callable[[ResumeToken, anyio.Event], Awaitable[None]] | None: + if base_cb is None and topic_key is None: + return None + + async def _wrapped(token: ResumeToken, done: anyio.Event) -> None: + if base_cb is not None: + await base_cb(token, done) + if topic_store is not None and topic_key is not None: + await topic_store.set_session_resume( + topic_key[0], topic_key[1], token + ) + + return _wrapped + + async def run_job( + chat_id: int, + user_msg_id: int, + text: str, + resume_token: ResumeToken | None, + context: RunContext | None, + thread_id: int | None = None, + reply_ref: MessageRef | None = None, + on_thread_known: Callable[[ResumeToken, anyio.Event], Awaitable[None]] + | None = None, + engine_override: EngineId | None = None, + ) -> None: + topic_key = ( + (chat_id, thread_id) + if topic_store is not None + and thread_id is not None + and _topics_chat_allowed( + cfg, chat_id, scope_chat_ids=topics_chat_ids + ) + else None + ) + await _run_engine( + exec_cfg=cfg.exec_cfg, + runtime=cfg.runtime, + running_tasks=running_tasks, + chat_id=chat_id, + user_msg_id=user_msg_id, + text=text, + resume_token=resume_token, + context=context, + reply_ref=reply_ref, + on_thread_known=wrap_on_thread_known(on_thread_known, topic_key), + engine_override=engine_override, + thread_id=thread_id, + ) + + async def run_thread_job(job: ThreadJob) -> None: + await run_job( + job.chat_id, + job.user_msg_id, + job.text, + job.resume_token, + job.context, + job.thread_id, + None, + scheduler.note_thread_known, + ) + + scheduler = ThreadScheduler(task_group=tg, run_job=run_thread_job) + + async def flush_media_group(key: tuple[int, str]) -> None: + while True: + state = media_groups.get(key) + if state is None: + return + token = state.token + await anyio.sleep(_MEDIA_GROUP_DEBOUNCE_S) + state = media_groups.get(key) + if state is None: + return + if state.token != token: + continue + messages = list(state.messages) + del media_groups[key] + await _handle_media_group(cfg, messages, topic_store) + return + + async for msg in poller(cfg): + if isinstance(msg, TelegramCallbackQuery): + if msg.data == CANCEL_CALLBACK_DATA: + tg.start_soon(handle_callback_cancel, cfg, msg, running_tasks) + else: + tg.start_soon( + cfg.bot.answer_callback_query, + msg.callback_query_id, + ) + continue + user_msg_id = msg.message_id + chat_id = msg.chat_id + reply_id = msg.reply_to_message_id + reply_ref = ( + MessageRef(channel_id=chat_id, message_id=reply_id) + if reply_id is not None + else None + ) + reply = partial( + send_plain, + cfg.exec_cfg.transport, + chat_id=chat_id, + user_msg_id=user_msg_id, + thread_id=msg.thread_id, + ) + text = msg.text + if msg.voice is not None: + text = await transcribe_voice( + bot=cfg.bot, + msg=msg, + enabled=cfg.voice_transcription, + reply=reply, + ) + if text is None: + continue + topic_key = ( + _topic_key(msg, cfg, scope_chat_ids=topics_chat_ids) + if topic_store is not None + else None + ) + chat_project = ( + _topics_chat_project(cfg, chat_id) if cfg.topics.enabled else None + ) + bound_context = ( + await topic_store.get_context(*topic_key) + if topic_store is not None and topic_key is not None + else None + ) + ambient_context = _merge_topic_context( + chat_project=chat_project, bound=bound_context + ) + + if ( + cfg.files.enabled + and msg.document is not None + and msg.media_group_id is not None + ): + key = (chat_id, msg.media_group_id) + state = media_groups.get(key) + if state is None: + state = _MediaGroupState(messages=[]) + media_groups[key] = state + tg.start_soon(flush_media_group, key) + state.messages.append(msg) + state.token += 1 + continue + + if is_cancel_command(text): + tg.start_soon(handle_cancel, cfg, msg, running_tasks) + continue + + command_id, args_text = _parse_slash_command(text) + if command_id is not None and _dispatch_builtin_command( + cfg=cfg, + msg=msg, + command_id=command_id, + args_text=args_text, + ambient_context=ambient_context, + topic_store=topic_store, + resolved_scope=resolved_topics_scope, + scope_chat_ids=topics_chat_ids, + reply=reply, + task_group=tg, + ): + continue + if msg.document is not None: + if cfg.files.enabled and cfg.files.auto_put and not text.strip(): + tg.start_soon( + _handle_file_put_default, + cfg, + msg, + ambient_context, + topic_store, + ) + elif cfg.files.enabled: + tg.start_soon( + partial(reply, text=FILE_PUT_USAGE), + ) + continue + if command_id is not None and command_id not in reserved_commands: + if command_id not in command_ids: + refresh_commands() + if command_id in command_ids: + tg.start_soon( + _dispatch_command, + cfg, + msg, + text, + command_id, + args_text, + running_tasks, + scheduler, + ) + continue + + reply_text = msg.reply_to_text + try: + resolved = cfg.runtime.resolve_message( + text=text, + reply_text=reply_text, + ambient_context=ambient_context, + chat_id=chat_id, + ) + except DirectiveError as exc: + await reply(text=f"error:\n{exc}") + continue + + text = resolved.prompt + resume_token = resolved.resume_token + engine_override = resolved.engine_override + context = resolved.context + if ( + topic_store is not None + and topic_key is not None + and resolved.context is not None + and resolved.context_source == "directives" + ): + await topic_store.set_context(*topic_key, resolved.context) + await _maybe_rename_topic( + cfg, + topic_store, + chat_id=topic_key[0], + thread_id=topic_key[1], + context=resolved.context, + ) + ambient_context = resolved.context + if ( + topic_store is not None + and topic_key is not None + and ambient_context is None + and resolved.context_source not in {"directives", "reply_ctx"} + ): + await reply( + text="this topic isn't bound to a project yet.\n" + f"{_usage_ctx_set(chat_project=chat_project)} or " + f"{_usage_topic(chat_project=chat_project)}", + ) + continue + if resume_token is None and reply_id is not None: + running_task = running_tasks.get( + MessageRef(channel_id=chat_id, message_id=reply_id) + ) + if running_task is not None: + tg.start_soon( + send_with_resume, + cfg, + scheduler.enqueue_resume, + running_task, + chat_id, + user_msg_id, + msg.thread_id, + text, + ) + continue + if ( + resume_token is None + and topic_store is not None + and topic_key is not None + ): + engine_for_session = cfg.runtime.resolve_engine( + engine_override=engine_override, + context=context, + ) + stored = await topic_store.get_session_resume( + topic_key[0], topic_key[1], engine_for_session + ) + if stored is not None: + resume_token = stored + + if resume_token is None: + tg.start_soon( + run_job, + chat_id, + user_msg_id, + text, + None, + context, + msg.thread_id, + reply_ref, + scheduler.note_thread_known, + engine_override, + ) + else: + await scheduler.enqueue_resume( + chat_id, + user_msg_id, + text, + resume_token, + context, + msg.thread_id, + ) + finally: + await cfg.exec_cfg.transport.close() diff --git a/src/takopi/telegram/onboarding.py b/src/takopi/telegram/onboarding.py index aa3145a..dea4cef 100644 --- a/src/takopi/telegram/onboarding.py +++ b/src/takopi/telegram/onboarding.py @@ -22,14 +22,28 @@ from rich.table import Table from ..backends import EngineBackend, SetupIssue from ..backends_helpers import install_issue -from ..config import ConfigError -from ..config_store import read_raw_toml, write_raw_toml +from ..config import ( + ConfigError, + dump_toml, + ensure_table, + read_config, + write_config, +) 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 +__all__ = [ + "ChatInfo", + "check_setup", + "interactive_setup", + "mask_token", + "get_bot_info", + "wait_for_chat", +] + @dataclass(frozen=True, slots=True) class ChatInfo: @@ -110,49 +124,14 @@ def check_setup( return SetupResult(issues=issues, config_path=config_path) -def _mask_token(token: str) -> str: +def mask_token(token: str) -> str: token = token.strip() if len(token) <= 12: return "*" * len(token) return f"{token[:9]}...{token[-5:]}" -def _toml_escape(value: str) -> str: - return value.replace("\\", "\\\\").replace('"', '\\"') - - -def _render_config(token: str, chat_id: int, default_engine: str | None) -> str: - lines: list[str] = [] - if default_engine: - lines.append(f'default_engine = "{_toml_escape(default_engine)}"') - lines.append("") - lines.append('transport = "telegram"') - lines.append("") - lines.append("[transports.telegram]") - lines.append(f'bot_token = "{_toml_escape(token)}"') - lines.append(f"chat_id = {chat_id}") - return "\n".join(lines) + "\n" - - -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 - - -async def _get_bot_info(token: str) -> dict[str, Any] | None: +async def get_bot_info(token: str) -> dict[str, Any] | None: bot = TelegramClient(token) try: for _ in range(3): @@ -165,7 +144,7 @@ async def _get_bot_info(token: str) -> dict[str, Any] | None: await bot.close() -async def _wait_for_chat(token: str) -> ChatInfo: +async def wait_for_chat(token: str) -> ChatInfo: bot = TelegramClient(token) try: offset: int | None = None @@ -329,7 +308,7 @@ def _prompt_token(console: Console) -> tuple[str, dict[str, Any]] | None: console.print(" token cannot be empty") continue console.print(" validating...") - info = anyio.run(_get_bot_info, token) + info = anyio.run(get_bot_info, token) if info: username = info.get("username") if isinstance(username, str) and username: @@ -353,7 +332,7 @@ def capture_chat_id(*, token: str | None = None) -> ChatInfo | None: console.print(" token cannot be empty") return None console.print(" validating...") - info = anyio.run(_get_bot_info, token) + info = anyio.run(get_bot_info, token) if not info: console.print(" failed to connect, check the token and try again") return None @@ -368,7 +347,7 @@ def capture_chat_id(*, token: str | None = None) -> ChatInfo | None: console.print(f" send /start to {bot_ref} (works in groups too)") console.print(" waiting...") try: - chat = anyio.run(_wait_for_chat, token) + chat = anyio.run(wait_for_chat, token) except KeyboardInterrupt: console.print(" cancelled") return None @@ -428,7 +407,7 @@ def interactive_setup(*, force: bool) -> bool: console.print(f" send /start to {bot_ref} (works in groups too)") console.print(" waiting...") try: - chat = anyio.run(_wait_for_chat, token) + chat = anyio.run(wait_for_chat, token) except KeyboardInterrupt: console.print(" cancelled") return False @@ -461,11 +440,17 @@ def interactive_setup(*, force: bool) -> bool: if not save_anyway: return False - config_preview = _render_config( - _mask_token(token), - chat.chat_id, - default_engine, - ).rstrip() + preview_config: dict[str, Any] = {} + if default_engine is not None: + preview_config["default_engine"] = default_engine + preview_config["transport"] = "telegram" + preview_config["transports"] = { + "telegram": { + "bot_token": mask_token(token), + "chat_id": chat.chat_id, + } + } + config_preview = dump_toml(preview_config).rstrip() console.print("\nstep 3: save configuration\n") console.print(f" {_display_path(config_path)}\n") for line in config_preview.splitlines(): @@ -482,7 +467,7 @@ def interactive_setup(*, force: bool) -> bool: raw_config: dict[str, Any] = {} if config_path.exists(): try: - raw_config = read_raw_toml(config_path) + raw_config = read_config(config_path) except ConfigError as exc: console.print(f"[yellow]warning:[/] config is malformed: {exc}") backup = config_path.with_suffix(".toml.bak") @@ -499,8 +484,8 @@ def interactive_setup(*, force: bool) -> bool: if default_engine is not None: merged["default_engine"] = default_engine merged["transport"] = "telegram" - transports = _ensure_table(merged, "transports", config_path=config_path) - telegram = _ensure_table( + transports = ensure_table(merged, "transports", config_path=config_path) + telegram = ensure_table( transports, "telegram", config_path=config_path, @@ -510,7 +495,7 @@ def interactive_setup(*, force: bool) -> bool: telegram["chat_id"] = chat.chat_id merged.pop("bot_token", None) merged.pop("chat_id", None) - write_raw_toml(merged, config_path) + write_config(merged, config_path) console.print(f" config saved to {_display_path(config_path)}") done_panel = Panel( diff --git a/src/takopi/telegram/topic_state.py b/src/takopi/telegram/topic_state.py index f925438..f9737bc 100644 --- a/src/takopi/telegram/topic_state.py +++ b/src/takopi/telegram/topic_state.py @@ -2,7 +2,6 @@ from __future__ import annotations import json import os -import time from dataclasses import dataclass from pathlib import Path from typing import Any, cast @@ -26,8 +25,6 @@ class TopicThreadSnapshot: context: RunContext | None sessions: dict[str, str] topic_title: str | None - created_by_bot: bool | None - updated_at: float | None def resolve_state_path(config_path: Path) -> Path: @@ -104,7 +101,6 @@ class TopicStateStore: context: RunContext, *, topic_title: str | None = None, - created_by_bot: bool | None = None, ) -> None: async with self._lock: self._reload_locked_if_needed() @@ -112,9 +108,6 @@ class TopicStateStore: thread["context"] = _dump_context(context) if topic_title is not None: thread["topic_title"] = topic_title - if created_by_bot is not None: - thread["created_by_bot"] = created_by_bot - thread["updated_at"] = time.time() self._save_locked() async def clear_context(self, chat_id: int, thread_id: int) -> None: @@ -124,7 +117,6 @@ class TopicStateStore: if thread is None: return thread.pop("context", None) - thread["updated_at"] = time.time() self._save_locked() async def get_session_resume( @@ -158,9 +150,7 @@ class TopicStateStore: thread["sessions"] = sessions sessions[token.engine] = { "resume": token.value, - "updated_at": time.time(), } - thread["updated_at"] = time.time() self._save_locked() async def clear_sessions(self, chat_id: int, thread_id: int) -> None: @@ -170,7 +160,6 @@ class TopicStateStore: if thread is None: return thread.pop("sessions", None) - thread["updated_at"] = time.time() self._save_locked() async def find_thread_for_context( @@ -210,23 +199,15 @@ class TopicStateStore: value = entry.get("resume") if isinstance(value, str) and value: sessions[engine] = value - updated_at = thread.get("updated_at") - if not isinstance(updated_at, (int, float)): - updated_at = None topic_title = thread.get("topic_title") if not isinstance(topic_title, str): topic_title = None - created_by_bot = thread.get("created_by_bot") - if not isinstance(created_by_bot, bool): - created_by_bot = None return TopicThreadSnapshot( chat_id=chat_id, thread_id=thread_id, context=_parse_context(thread.get("context")), sessions=sessions, topic_title=topic_title, - created_by_bot=created_by_bot, - updated_at=updated_at, ) def _stat_mtime_ns(self) -> int | None: @@ -302,6 +283,6 @@ class TopicStateStore: entry = threads.get(key) if isinstance(entry, dict): return entry - entry = {"chat_id": chat_id, "thread_id": thread_id} + entry = {} threads[key] = entry return entry diff --git a/src/takopi/telegram/topics.py b/src/takopi/telegram/topics.py new file mode 100644 index 0000000..5174255 --- /dev/null +++ b/src/takopi/telegram/topics.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..config import ConfigError +from ..context import RunContext +from ..transport_runtime import TransportRuntime +from .topic_state import TopicStateStore, TopicThreadSnapshot +from .types import TelegramIncomingMessage + +if TYPE_CHECKING: + from .bridge import TelegramBridgeConfig + +__all__ = [ + "_TOPICS_COMMANDS", + "_maybe_rename_topic", + "_maybe_update_topic_context", + "_resolve_topics_scope", + "_topic_key", + "_topic_title", + "_topics_chat_allowed", + "_topics_chat_project", + "_topics_command_error", + "_topics_scope_label", + "_validate_topics_setup", +] + +_TOPICS_COMMANDS = {"ctx", "new", "topic"} + + +def _resolve_topics_scope(cfg: TelegramBridgeConfig) -> tuple[str, frozenset[int]]: + scope = cfg.topics.scope + project_ids = set(cfg.runtime.project_chat_ids()) + if scope == "auto": + scope = "projects" if project_ids else "main" + if scope == "main": + return scope, frozenset({cfg.chat_id}) + if scope == "projects": + return scope, frozenset(project_ids) + if scope == "all": + return scope, frozenset({cfg.chat_id, *project_ids}) + raise ValueError(f"Invalid topics.scope: {cfg.topics.scope!r}") + + +def _topics_scope_label(cfg: TelegramBridgeConfig) -> str: + resolved, _ = _resolve_topics_scope(cfg) + if cfg.topics.scope == "auto": + return f"auto ({resolved})" + return resolved + + +def _topics_chat_project(cfg: TelegramBridgeConfig, chat_id: int) -> str | None: + context = cfg.runtime.default_context_for_chat(chat_id) + return context.project if context is not None else None + + +def _topics_chat_allowed( + cfg: TelegramBridgeConfig, + chat_id: int, + *, + scope_chat_ids: frozenset[int] | None = None, +) -> bool: + if not cfg.topics.enabled: + return False + if scope_chat_ids is None: + _, scope_chat_ids = _resolve_topics_scope(cfg) + return chat_id in scope_chat_ids + + +def _topics_command_error( + cfg: TelegramBridgeConfig, + chat_id: int, + *, + resolved_scope: str | None = None, + scope_chat_ids: frozenset[int] | None = None, +) -> str | None: + if resolved_scope is None or scope_chat_ids is None: + resolved_scope, scope_chat_ids = _resolve_topics_scope(cfg) + if cfg.topics.enabled and chat_id in scope_chat_ids: + return None + if resolved_scope == "main": + if cfg.topics.scope == "auto": + return ( + "topics commands are only available in the main chat (auto scope). " + 'to use topics in project chats, set `topics.scope = "projects"`.' + ) + return "topics commands are only available in the main chat." + if resolved_scope == "projects": + if cfg.topics.scope == "auto": + return ( + "topics commands are only available in project chats (auto scope). " + 'to use topics in the main chat, set `topics.scope = "main"`.' + ) + return "topics commands are only available in project chats." + return "topics commands are only available in the main or project chats." + + +def _topic_key( + msg: TelegramIncomingMessage, + cfg: TelegramBridgeConfig, + *, + scope_chat_ids: frozenset[int] | None = None, +) -> tuple[int, int] | None: + if not cfg.topics.enabled: + return None + if not _topics_chat_allowed(cfg, msg.chat_id, scope_chat_ids=scope_chat_ids): + return None + if msg.thread_id is None: + return None + return (msg.chat_id, msg.thread_id) + + +def _topic_title(*, runtime: TransportRuntime, context: RunContext) -> str: + project = ( + runtime.project_alias_for_key(context.project) + if context.project is not None + else "" + ) + if context.branch: + if project: + return f"{project} @{context.branch}" + return f"@{context.branch}" + return project or "topic" + + +async def _maybe_rename_topic( + cfg: TelegramBridgeConfig, + store: TopicStateStore, + *, + chat_id: int, + thread_id: int, + context: RunContext, + snapshot: TopicThreadSnapshot | None = None, +) -> None: + title = _topic_title(runtime=cfg.runtime, context=context) + if snapshot is None: + snapshot = await store.get_thread(chat_id, thread_id) + if snapshot is not None and snapshot.topic_title == title: + return + updated = await cfg.bot.edit_forum_topic( + chat_id=chat_id, + message_thread_id=thread_id, + name=title, + ) + if not updated: + from ..logging import get_logger + + logger = get_logger(__name__) + logger.warning( + "topics.rename.failed", + chat_id=chat_id, + thread_id=thread_id, + title=title, + ) + return + await store.set_context(chat_id, thread_id, context, topic_title=title) + + +async def _maybe_update_topic_context( + *, + cfg: TelegramBridgeConfig, + topic_store: TopicStateStore | None, + topic_key: tuple[int, int] | None, + context: RunContext | None, + context_source: str, +) -> None: + if ( + topic_store is None + or topic_key is None + or context is None + or context_source != "directives" + ): + return + await topic_store.set_context(topic_key[0], topic_key[1], context) + await _maybe_rename_topic( + cfg, + topic_store, + chat_id=topic_key[0], + thread_id=topic_key[1], + context=context, + ) + + +async def _validate_topics_setup(cfg: TelegramBridgeConfig) -> None: + if not cfg.topics.enabled: + return + me = await cfg.bot.get_me() + bot_id = me.get("id") if isinstance(me, dict) else None + if not isinstance(bot_id, int): + raise ConfigError("failed to fetch bot id for topics validation.") + scope, chat_ids = _resolve_topics_scope(cfg) + if scope == "projects" and not chat_ids: + raise ConfigError( + "topics enabled but no project chats are configured; " + 'set projects..chat_id for forum chats or use scope="main".' + ) + + for chat_id in chat_ids: + chat = await cfg.bot.get_chat(chat_id) + if not isinstance(chat, dict): + raise ConfigError( + f"failed to fetch chat info for topics validation ({chat_id})." + ) + chat_type = chat.get("type") + is_forum = chat.get("is_forum") + if chat_type != "supergroup": + raise ConfigError( + "topics enabled but chat is not a supergroup " + f"(chat_id={chat_id}); convert the group and enable topics." + ) + if is_forum is not True: + raise ConfigError( + "topics enabled but chat does not have topics enabled " + f"(chat_id={chat_id}); turn on topics in group settings." + ) + member = await cfg.bot.get_chat_member(chat_id, bot_id) + if not isinstance(member, dict): + raise ConfigError( + "failed to fetch bot permissions " + f"(chat_id={chat_id}); promote the bot to admin with manage topics." + ) + status = member.get("status") + if status == "creator": + continue + if status != "administrator": + raise ConfigError( + "topics enabled but bot is not an admin " + f"(chat_id={chat_id}); promote it and grant manage topics." + ) + if member.get("can_manage_topics") is not True: + raise ConfigError( + "topics enabled but bot lacks manage topics permission " + f"(chat_id={chat_id}); grant can_manage_topics." + ) diff --git a/src/takopi/telegram/transcribe.py b/src/takopi/telegram/transcribe.py deleted file mode 100644 index e3db960..0000000 --- a/src/takopi/telegram/transcribe.py +++ /dev/null @@ -1,100 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import httpx - -from ..logging import get_logger - -logger = get_logger(__name__) - -OPENAI_TRANSCRIBE_URL = "https://api.openai.com/v1/audio/transcriptions" - - -async def transcribe_audio( - audio_bytes: bytes, - *, - filename: str, - api_key: str, - model: str, - language: str | None = None, - prompt: str | None = None, - chunking_strategy: str | None = "auto", - mime_type: str | None = None, - timeout_s: float = 120, - http_client: httpx.AsyncClient | None = None, -) -> str | None: - data: dict[str, Any] = {"model": model} - if language: - data["language"] = language - if prompt: - data["prompt"] = prompt - if chunking_strategy: - data["chunking_strategy"] = chunking_strategy - - files = { - "file": ( - filename, - audio_bytes, - mime_type or "application/octet-stream", - ) - } - - headers = {"Authorization": f"Bearer {api_key}"} - close_client = False - client = http_client - if client is None: - client = httpx.AsyncClient(timeout=timeout_s) - close_client = True - try: - try: - resp = await client.post( - OPENAI_TRANSCRIBE_URL, - data=data, - files=files, - headers=headers, - ) - except httpx.HTTPError as exc: - request_url = getattr(exc.request, "url", None) - logger.error( - "openai.transcribe.network_error", - url=str(request_url) if request_url is not None else None, - error=str(exc), - error_type=exc.__class__.__name__, - ) - return None - try: - resp.raise_for_status() - except httpx.HTTPStatusError as exc: - logger.error( - "openai.transcribe.http_error", - status=resp.status_code, - url=str(resp.request.url), - error=str(exc), - body=resp.text, - ) - return None - try: - payload = resp.json() - except Exception as exc: - logger.error( - "openai.transcribe.bad_response", - status=resp.status_code, - url=str(resp.request.url), - error=str(exc), - error_type=exc.__class__.__name__, - body=resp.text, - ) - return None - finally: - if close_client: - await client.aclose() - - text = payload.get("text") - if not isinstance(text, str): - logger.error( - "openai.transcribe.invalid_payload", - payload=payload, - ) - return None - return text diff --git a/src/takopi/telegram/voice.py b/src/takopi/telegram/voice.py new file mode 100644 index 0000000..9d36981 --- /dev/null +++ b/src/takopi/telegram/voice.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import io +from collections.abc import Awaitable, Callable +from typing import cast + +from ..logging import get_logger +from openai import AsyncOpenAI, OpenAIError + +from .client import BotClient +from .types import TelegramIncomingMessage + +logger = get_logger(__name__) + +__all__ = ["transcribe_voice"] + +OPENAI_TRANSCRIPTION_MODEL = "gpt-4o-mini-transcribe" +VOICE_TRANSCRIPTION_DISABLED_HINT = ( + "voice transcription is disabled. enable it in config:\n" + "```toml\n" + "[transports.telegram]\n" + "voice_transcription = true\n" + "```" +) + + +async def transcribe_voice( + *, + bot: BotClient, + msg: TelegramIncomingMessage, + enabled: bool, + reply: Callable[..., Awaitable[None]], +) -> str | None: + voice = msg.voice + if voice is None: + return msg.text + if not enabled: + await reply(text=VOICE_TRANSCRIPTION_DISABLED_HINT) + return None + file_info = cast(dict[str, object], await bot.get_file(voice.file_id)) + file_path = cast(str, file_info["file_path"]) + audio_bytes = cast(bytes, await bot.download_file(file_path)) + audio_file = io.BytesIO(audio_bytes) + audio_file.name = "voice.ogg" + async with AsyncOpenAI(timeout=120) as client: + try: + response = await client.audio.transcriptions.create( + model=OPENAI_TRANSCRIPTION_MODEL, + file=audio_file, + ) + except OpenAIError as exc: + logger.error( + "openai.transcribe.error", + error=str(exc), + error_type=exc.__class__.__name__, + ) + await reply(text=str(exc).strip() or "voice transcription failed") + return None + + return response.text diff --git a/src/takopi/transport_runtime.py b/src/takopi/transport_runtime.py index 9189ff7..7d6282a 100644 --- a/src/takopi/transport_runtime.py +++ b/src/takopi/transport_runtime.py @@ -45,6 +45,7 @@ class TransportRuntime: "_allowlist", "_config_path", "_plugin_configs", + "_watch_config", ) def __init__( @@ -55,12 +56,14 @@ class TransportRuntime: allowlist: Iterable[str] | None = None, config_path: Path | None = None, plugin_configs: Mapping[str, Any] | None = None, + watch_config: bool = False, ) -> None: self._router = router self._projects = projects self._allowlist = normalize_allowlist(allowlist) self._config_path = config_path self._plugin_configs = dict(plugin_configs or {}) + self._watch_config = watch_config def update( self, @@ -70,12 +73,14 @@ class TransportRuntime: allowlist: Iterable[str] | None = None, config_path: Path | None = None, plugin_configs: Mapping[str, Any] | None = None, + watch_config: bool = False, ) -> None: self._router = router self._projects = projects self._allowlist = normalize_allowlist(allowlist) self._config_path = config_path self._plugin_configs = dict(plugin_configs or {}) + self._watch_config = watch_config @property def default_engine(self) -> EngineId: @@ -119,6 +124,10 @@ class TransportRuntime: def config_path(self) -> Path | None: return self._config_path + @property + def watch_config(self) -> bool: + return self._watch_config + def plugin_config(self, plugin_id: str) -> dict[str, Any]: if not self._plugin_configs: return {} diff --git a/src/takopi/utils/subprocess.py b/src/takopi/utils/subprocess.py index f1a4e14..64ef849 100644 --- a/src/takopi/utils/subprocess.py +++ b/src/takopi/utils/subprocess.py @@ -2,7 +2,7 @@ from __future__ import annotations import os import signal -from collections.abc import AsyncIterator, Sequence +from collections.abc import AsyncIterator, Callable, Sequence from contextlib import asynccontextmanager from typing import Any @@ -21,45 +21,47 @@ async def wait_for_process(proc: Process, timeout: float) -> bool: def terminate_process(proc: Process) -> None: - if proc.returncode is not None: - return - if os.name == "posix" and proc.pid is not None: - try: - os.killpg(proc.pid, signal.SIGTERM) - return - except ProcessLookupError: - return - except Exception as e: - logger.debug( - "subprocess.terminate.failed", - error=str(e), - error_type=e.__class__.__name__, - pid=proc.pid, - ) - try: - proc.terminate() - except ProcessLookupError: - return + _signal_process( + proc, + signal.SIGTERM, + fallback=proc.terminate, + log_event="subprocess.terminate.failed", + ) def kill_process(proc: Process) -> None: + _signal_process( + proc, + signal.SIGKILL, + fallback=proc.kill, + log_event="subprocess.kill.failed", + ) + + +def _signal_process( + proc: Process, + sig: signal.Signals, + *, + fallback: Callable[[], None], + log_event: str, +) -> None: if proc.returncode is not None: return if os.name == "posix" and proc.pid is not None: try: - os.killpg(proc.pid, signal.SIGKILL) + os.killpg(proc.pid, sig) return except ProcessLookupError: return - except Exception as e: + except Exception as exc: logger.debug( - "subprocess.kill.failed", - error=str(e), - error_type=e.__class__.__name__, + log_event, + error=str(exc), + error_type=exc.__class__.__name__, pid=proc.pid, ) try: - proc.kill() + fallback() except ProcessLookupError: return diff --git a/tests/test_cli_chat_id.py b/tests/test_cli_chat_id.py index 65cbb11..28725a7 100644 --- a/tests/test_cli_chat_id.py +++ b/tests/test_cli_chat_id.py @@ -45,7 +45,7 @@ def test_chat_id_command_uses_config_token(monkeypatch) -> None: settings = TakopiSettings.model_validate( { "transport": "telegram", - "transports": {"telegram": {"bot_token": "config-token"}}, + "transports": {"telegram": {"bot_token": "config-token", "chat_id": 123}}, } ) monkeypatch.setattr(cli, "_load_settings_optional", lambda: (settings, Path("x"))) diff --git a/tests/test_config_store.py b/tests/test_config_store.py index 625a6b2..eff5238 100644 --- a/tests/test_config_store.py +++ b/tests/test_config_store.py @@ -4,38 +4,37 @@ from pathlib import Path import pytest -from takopi.config import ConfigError -from takopi.config_store import read_raw_toml, write_raw_toml +from takopi.config import ConfigError, read_config, write_config -def test_read_write_raw_toml_round_trip(tmp_path: Path) -> None: +def test_read_write_config_round_trip(tmp_path: Path) -> None: config_path = tmp_path / "takopi.toml" payload = { "default_engine": "codex", "projects": {"z80": {"path": "/tmp/repo"}}, } - write_raw_toml(payload, config_path) - loaded = read_raw_toml(config_path) + write_config(payload, config_path) + loaded = read_config(config_path) assert loaded == payload -def test_read_raw_toml_missing_file(tmp_path: Path) -> None: +def test_read_config_missing_file(tmp_path: Path) -> None: config_path = tmp_path / "missing.toml" with pytest.raises(ConfigError, match="Missing config file"): - read_raw_toml(config_path) + read_config(config_path) -def test_read_raw_toml_invalid_toml(tmp_path: Path) -> None: +def test_read_config_invalid_toml(tmp_path: Path) -> None: config_path = tmp_path / "takopi.toml" config_path.write_text("nope = [", encoding="utf-8") with pytest.raises(ConfigError, match="Malformed TOML"): - read_raw_toml(config_path) + read_config(config_path) -def test_read_raw_toml_non_file(tmp_path: Path) -> None: +def test_read_config_non_file(tmp_path: Path) -> None: config_path = tmp_path / "config_dir" config_path.mkdir() with pytest.raises(ConfigError, match="exists but is not a file"): - read_raw_toml(config_path) + read_config(config_path) diff --git a/tests/test_config_watch.py b/tests/test_config_watch.py index 259a0fa..b8cf8c8 100644 --- a/tests/test_config_watch.py +++ b/tests/test_config_watch.py @@ -4,8 +4,8 @@ import anyio import pytest import takopi.config_watch as config_watch -from takopi.config_watch import ConfigReload, _config_status, watch_config -from takopi.config import empty_projects_config +from takopi.config_watch import ConfigReload, config_status, watch_config +from takopi.config import ProjectsConfig from takopi.router import AutoRouter, RunnerEntry from takopi.runtime_loader import RuntimeSpec from takopi.runners.mock import Return, ScriptRunner @@ -15,19 +15,23 @@ from takopi.transport_runtime import TransportRuntime def test_config_status_variants(tmp_path: Path) -> None: missing = tmp_path / "missing.toml" - status, signature = _config_status(missing) + status, signature = config_status(missing) assert status == "missing" assert signature is None directory = tmp_path / "config.d" directory.mkdir() - status, signature = _config_status(directory) + status, signature = config_status(directory) assert status == "invalid" assert signature is None config_file = tmp_path / "takopi.toml" - config_file.write_text('transport = "telegram"\n', encoding="utf-8") - status, signature = _config_status(config_file) + config_file.write_text( + 'transport = "telegram"\n\n[transports.telegram]\n' + 'bot_token = "token"\nchat_id = 123\n', + encoding="utf-8", + ) + status, signature = config_status(config_file) assert status == "ok" assert signature is not None @@ -47,7 +51,7 @@ async def test_watch_config_applies_runtime( ) runtime = TransportRuntime( router=router, - projects=empty_projects_config(), + projects=ProjectsConfig(projects={}, default_project=None), config_path=resolved_path, ) @@ -58,12 +62,17 @@ async def test_watch_config_applies_runtime( ) new_spec = RuntimeSpec( router=new_router, - projects=empty_projects_config(), + projects=ProjectsConfig(projects={}, default_project=None), allowlist=None, plugin_configs=None, ) reload = ConfigReload( - settings=TakopiSettings.model_validate({"transport": "telegram"}), + settings=TakopiSettings.model_validate( + { + "transport": "telegram", + "transports": {"telegram": {"bot_token": "token", "chat_id": 123}}, + } + ), runtime_spec=new_spec, config_path=resolved_path, ) diff --git a/tests/test_exec_bridge.py b/tests/test_exec_bridge.py index b05bbd8..9e96a32 100644 --- a/tests/test_exec_bridge.py +++ b/tests/test_exec_bridge.py @@ -87,7 +87,7 @@ def test_require_telegram_rejects_empty_token(tmp_path) -> None: encoding="utf-8", ) - with pytest.raises(ConfigError, match="bot token"): + with pytest.raises(ConfigError, match="bot_token"): settings, _ = load_settings(config_path) require_telegram(settings, config_path) diff --git a/tests/test_exec_runner.py b/tests/test_exec_runner.py index 3cb6e02..8a696c5 100644 --- a/tests/test_exec_runner.py +++ b/tests/test_exec_runner.py @@ -12,7 +12,7 @@ from takopi.model import ( StartedEvent, TakopiEvent, ) -from takopi.runners.codex import CodexRunner, _find_exec_only_flag +from takopi.runners.codex import CodexRunner, find_exec_only_flag CODEX_ENGINE = EngineId("codex") @@ -159,7 +159,7 @@ def test_codex_exec_flags_after_exec() -> None: ], ) def test_find_exec_only_flag(extra_args: list[str], expected: str | None) -> None: - assert _find_exec_only_flag(extra_args) == expected + assert find_exec_only_flag(extra_args) == expected @pytest.mark.anyio diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 7c0c8c3..f924d1c 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -49,9 +49,13 @@ def test_check_setup_marks_missing_config(monkeypatch, tmp_path: Path) -> None: assert result.config_path == onboarding.HOME_CONFIG_PATH -def test_check_setup_marks_invalid_chat_id(monkeypatch, tmp_path: Path) -> None: +def test_check_setup_marks_invalid_bot_token(monkeypatch, tmp_path: Path) -> None: backend = engines.get_backend("codex") monkeypatch.setattr(onboarding.shutil, "which", lambda _name: "/usr/bin/codex") + + def _fail_require(*_args, **_kwargs): + raise onboarding.ConfigError("Missing bot token") + monkeypatch.setattr( onboarding, "load_settings", @@ -59,12 +63,13 @@ def test_check_setup_marks_invalid_chat_id(monkeypatch, tmp_path: Path) -> None: TakopiSettings.model_validate( { "transport": "telegram", - "transports": {"telegram": {"bot_token": "token", "chat_id": None}}, + "transports": {"telegram": {"bot_token": "token", "chat_id": 123}}, } ), tmp_path / "takopi.toml", ), ) + monkeypatch.setattr(onboarding, "require_telegram", _fail_require) result = onboarding.check_setup(backend) diff --git a/tests/test_onboarding_interactive.py b/tests/test_onboarding_interactive.py index c1c2e68..772b5a6 100644 --- a/tests/test_onboarding_interactive.py +++ b/tests/test_onboarding_interactive.py @@ -1,26 +1,34 @@ from __future__ import annotations +from takopi.config import dump_toml from takopi.telegram import onboarding from takopi.backends import EngineBackend def test_mask_token_short() -> None: - assert onboarding._mask_token("short") == "*****" + assert onboarding.mask_token("short") == "*****" def test_mask_token_long() -> None: token = "123456789:ABCdefGH" - masked = onboarding._mask_token(token) + masked = onboarding.mask_token(token) assert masked.startswith("123456789") assert masked.endswith("defGH") assert "..." in masked def test_render_config_escapes() -> None: - config = onboarding._render_config( - 'token"with\\quote', - 123, - "codex", + config = dump_toml( + { + "default_engine": "codex", + "transport": "telegram", + "transports": { + "telegram": { + "bot_token": 'token"with\\quote', + "chat_id": 123, + } + }, + } ) assert 'default_engine = "codex"' in config assert 'transport = "telegram"' in config @@ -82,9 +90,9 @@ def test_interactive_setup_writes_config(monkeypatch, tmp_path) -> None: monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"])) def _fake_run(func, *args, **kwargs): - if func is onboarding._get_bot_info: + if func is onboarding.get_bot_info: return {"username": "my_bot"} - if func is onboarding._wait_for_chat: + if func is onboarding.wait_for_chat: return onboarding.ChatInfo( chat_id=123, username="alice", @@ -127,9 +135,9 @@ def test_interactive_setup_preserves_projects(monkeypatch, tmp_path) -> None: monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"])) def _fake_run(func, *args, **kwargs): - if func is onboarding._get_bot_info: + if func is onboarding.get_bot_info: return {"username": "my_bot"} - if func is onboarding._wait_for_chat: + if func is onboarding.wait_for_chat: return onboarding.ChatInfo( chat_id=123, username="alice", @@ -164,9 +172,9 @@ def test_interactive_setup_no_agents_aborts(monkeypatch, tmp_path) -> None: ) def _fake_run(func, *args, **kwargs): - if func is onboarding._get_bot_info: + if func is onboarding.get_bot_info: return {"username": "my_bot"} - if func is onboarding._wait_for_chat: + if func is onboarding.wait_for_chat: return onboarding.ChatInfo( chat_id=123, username="alice", @@ -202,9 +210,9 @@ def test_interactive_setup_recovers_from_malformed_toml(monkeypatch, tmp_path) - monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"])) def _fake_run(func, *args, **kwargs): - if func is onboarding._get_bot_info: + if func is onboarding.get_bot_info: return {"username": "my_bot"} - if func is onboarding._wait_for_chat: + if func is onboarding.wait_for_chat: return onboarding.ChatInfo( chat_id=123, username="alice", @@ -230,9 +238,9 @@ def test_interactive_setup_recovers_from_malformed_toml(monkeypatch, tmp_path) - def test_capture_chat_id_with_token(monkeypatch) -> None: def _fake_run(func, *args, **kwargs): - if func is onboarding._get_bot_info: + if func is onboarding.get_bot_info: return {"username": "my_bot"} - if func is onboarding._wait_for_chat: + if func is onboarding.wait_for_chat: return onboarding.ChatInfo( chat_id=456, username=None, @@ -257,7 +265,7 @@ def test_capture_chat_id_prompts_for_token(monkeypatch) -> None: ) def _fake_run(func, *args, **kwargs): - if func is onboarding._wait_for_chat: + if func is onboarding.wait_for_chat: return onboarding.ChatInfo( chat_id=789, username="alice", diff --git a/tests/test_projects_config.py b/tests/test_projects_config.py index 6a3bb54..be52428 100644 --- a/tests/test_projects_config.py +++ b/tests/test_projects_config.py @@ -4,13 +4,16 @@ import pytest from typer.testing import CliRunner from takopi import cli -from takopi.config import ConfigError -from takopi.config_store import read_raw_toml +from takopi.config import ConfigError, read_config from takopi.settings import TakopiSettings +def _base_config() -> dict: + return {"transports": {"telegram": {"bot_token": "token", "chat_id": 123}}} + + def test_parse_projects_rejects_engine_alias() -> None: - config = {"projects": {"codex": {"path": "/tmp/repo"}}} + config = {**_base_config(), "projects": {"codex": {"path": "/tmp/repo"}}} with pytest.raises(ConfigError, match="aliases must not match engine ids"): settings = TakopiSettings.model_validate(config) settings.to_projects_config( @@ -21,7 +24,7 @@ def test_parse_projects_rejects_engine_alias() -> None: def test_parse_projects_default_project_must_exist() -> None: - config = {"default_project": "z80", "projects": {}} + config = {**_base_config(), "default_project": "z80", "projects": {}} with pytest.raises(ConfigError, match="default_project"): settings = TakopiSettings.model_validate(config) settings.to_projects_config( @@ -33,6 +36,11 @@ def test_parse_projects_default_project_must_exist() -> None: def test_init_writes_project(monkeypatch, tmp_path) -> None: config_path = tmp_path / "takopi.toml" + config_path.write_text( + 'transport = "telegram"\n\n[transports.telegram]\n' + 'bot_token = "token"\nchat_id = 123\n', + encoding="utf-8", + ) monkeypatch.setattr("takopi.config.HOME_CONFIG_PATH", config_path) monkeypatch.setattr(cli, "resolve_default_base", lambda _: "main") monkeypatch.setattr(cli, "_load_settings_optional", lambda: (None, None)) @@ -67,7 +75,7 @@ def test_init_migrates_legacy_config(monkeypatch, tmp_path) -> None: result = runner.invoke(cli.create_app(), ["init", "z80"]) assert result.exit_code == 0 - raw = read_raw_toml(config_path) + raw = read_config(config_path) assert "bot_token" not in raw assert "chat_id" not in raw assert raw["transport"] == "telegram" @@ -77,7 +85,10 @@ def test_init_migrates_legacy_config(monkeypatch, tmp_path) -> None: def test_projects_default_engine_unknown() -> None: - config = {"projects": {"z80": {"path": "/tmp/repo", "default_engine": "nope"}}} + config = { + **_base_config(), + "projects": {"z80": {"path": "/tmp/repo", "default_engine": "nope"}}, + } settings = TakopiSettings.model_validate(config) with pytest.raises(ConfigError, match="projects.z80.default_engine"): settings.to_projects_config( @@ -120,7 +131,9 @@ def test_projects_chat_id_must_be_unique() -> None: def test_projects_relative_path_resolves(tmp_path: Path) -> None: config_path = tmp_path / "takopi.toml" - settings = TakopiSettings.model_validate({"projects": {"z80": {"path": "repo"}}}) + settings = TakopiSettings.model_validate( + {**_base_config(), "projects": {"z80": {"path": "repo"}}} + ) projects = settings.to_projects_config( config_path=config_path, engine_ids=["codex"], diff --git a/tests/test_runtime_loader.py b/tests/test_runtime_loader.py index 8722c03..4796ac6 100644 --- a/tests/test_runtime_loader.py +++ b/tests/test_runtime_loader.py @@ -11,9 +11,19 @@ def test_build_runtime_spec_minimal( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: monkeypatch.setattr(runtime_loader.shutil, "which", lambda _cmd: "/bin/echo") - settings = TakopiSettings.model_validate({"transport": "telegram"}) + settings = TakopiSettings.model_validate( + { + "transport": "telegram", + "watch_config": True, + "transports": {"telegram": {"bot_token": "token", "chat_id": 123}}, + } + ) config_path = tmp_path / "takopi.toml" - config_path.write_text('transport = "telegram"\n', encoding="utf-8") + config_path.write_text( + 'transport = "telegram"\n\n[transports.telegram]\n' + 'bot_token = "token"\nchat_id = 123\n', + encoding="utf-8", + ) spec = runtime_loader.build_runtime_spec( settings=settings, @@ -23,10 +33,16 @@ def test_build_runtime_spec_minimal( assert spec.router.default_engine == settings.default_engine runtime = spec.to_runtime(config_path=config_path) assert runtime.default_engine == settings.default_engine + assert runtime.watch_config is True def test_resolve_default_engine_unknown(tmp_path: Path) -> None: - settings = TakopiSettings.model_validate({"transport": "telegram"}) + settings = TakopiSettings.model_validate( + { + "transport": "telegram", + "transports": {"telegram": {"bot_token": "token", "chat_id": 123}}, + } + ) with pytest.raises(ConfigError, match="Unknown default engine"): runtime_loader.resolve_default_engine( override="unknown", diff --git a/tests/test_settings.py b/tests/test_settings.py index d2680f0..c04a50f 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -4,8 +4,7 @@ from pathlib import Path import pytest -from takopi.config import ConfigError -from takopi.config_store import read_raw_toml +from takopi.config import ConfigError, read_config from takopi.settings import ( TakopiSettings, load_settings, @@ -38,8 +37,7 @@ def test_load_settings_from_toml(tmp_path: Path) -> None: assert token == "token" assert chat_id == 123 - dumped = settings.model_dump() - assert dumped["transports"]["telegram"]["bot_token"] == "token" + assert settings.transports.telegram.bot_token == "token" def test_env_overrides_toml(tmp_path: Path, monkeypatch) -> None: @@ -67,7 +65,7 @@ def test_legacy_keys_migrated(tmp_path: Path) -> None: assert loaded_path == config_path assert settings.transports.telegram.chat_id == 123 - raw = read_raw_toml(config_path) + raw = read_config(config_path) assert "bot_token" not in raw assert "chat_id" not in raw assert raw["transports"]["telegram"]["bot_token"] == "token" @@ -100,7 +98,10 @@ def test_validate_settings_data_rejects_empty_default_engine(tmp_path: Path) -> def test_validate_settings_data_rejects_empty_default_project(tmp_path: Path) -> None: config_path = tmp_path / "takopi.toml" - data = {"default_project": " "} + data = { + "default_project": " ", + "transports": {"telegram": {"bot_token": "token", "chat_id": 123}}, + } with pytest.raises(ConfigError, match="default_project"): validate_settings_data(data, config_path=config_path) @@ -108,7 +109,10 @@ def test_validate_settings_data_rejects_empty_default_project(tmp_path: Path) -> def test_validate_settings_data_rejects_empty_project_path(tmp_path: Path) -> None: config_path = tmp_path / "takopi.toml" - data = {"projects": {"z80": {"path": " "}}} + data = { + "projects": {"z80": {"path": " "}}, + "transports": {"telegram": {"bot_token": "token", "chat_id": 123}}, + } with pytest.raises(ConfigError, match="path"): validate_settings_data(data, config_path=config_path) @@ -172,14 +176,14 @@ def test_transport_config_telegram_and_extra(tmp_path: Path) -> None: settings.transport_config("discord", config_path=config_path) -def test_bot_token_none_allowed() -> None: - settings = TakopiSettings.model_validate( - { - "transport": "telegram", - "transports": {"telegram": {"bot_token": None, "chat_id": 123}}, - } - ) - assert settings.transports.telegram.bot_token is None +def test_bot_token_none_rejected(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + data = { + "transport": "telegram", + "transports": {"telegram": {"bot_token": None, "chat_id": 123}}, + } + with pytest.raises(ConfigError, match="bot_token"): + validate_settings_data(data, config_path=config_path) def test_require_telegram_rejects_non_telegram_transport(tmp_path: Path) -> None: diff --git a/tests/test_settings_contract.py b/tests/test_settings_contract.py new file mode 100644 index 0000000..c303ade --- /dev/null +++ b/tests/test_settings_contract.py @@ -0,0 +1,30 @@ +from pathlib import Path + +import pytest + +from takopi.config import ConfigError +from takopi.settings import TakopiSettings, validate_settings_data + + +def test_settings_strips_and_expands_transport_config(tmp_path: Path) -> None: + settings = TakopiSettings.model_validate( + { + "transport": " telegram ", + "plugins": {"enabled": [" foo "]}, + "transports": {"telegram": {"bot_token": " token ", "chat_id": 123}}, + } + ) + + assert settings.transport == "telegram" + assert settings.plugins.enabled == ["foo"] + assert settings.transports.telegram.bot_token == "token" + + +def test_settings_rejects_bool_chat_id(tmp_path: Path) -> None: + data = { + "transport": "telegram", + "transports": {"telegram": {"bot_token": "token", "chat_id": True}}, + } + + with pytest.raises(ConfigError, match="chat_id"): + validate_settings_data(data, config_path=tmp_path / "takopi.toml") diff --git a/tests/test_telegram_backend.py b/tests/test_telegram_backend.py index 54ecb16..36d3746 100644 --- a/tests/test_telegram_backend.py +++ b/tests/test_telegram_backend.py @@ -5,7 +5,7 @@ from typing import Any import pytest -from takopi.config import ConfigError, empty_projects_config +from takopi.config import ProjectsConfig from takopi.model import EngineId from takopi.router import AutoRouter, RunnerEntry from takopi.runners.mock import Return, ScriptRunner @@ -25,7 +25,11 @@ def test_build_startup_message_includes_missing_engines(tmp_path: Path) -> None: ], default_engine=codex, ) - runtime = TransportRuntime(router=router, projects=empty_projects_config()) + runtime = TransportRuntime( + router=router, + projects=ProjectsConfig(projects={}, default_project=None), + watch_config=True, + ) message = telegram_backend._build_startup_message( runtime, startup_pwd=str(tmp_path) @@ -54,7 +58,11 @@ def test_telegram_backend_build_and_run_wires_config( entries=[RunnerEntry(engine=codex, runner=runner, available=True)], default_engine=codex, ) - runtime = TransportRuntime(router=router, projects=empty_projects_config()) + runtime = TransportRuntime( + router=router, + projects=ProjectsConfig(projects={}, default_project=None), + watch_config=True, + ) captured: dict[str, Any] = {} @@ -91,8 +99,7 @@ def test_telegram_backend_build_and_run_wires_config( cfg = captured["cfg"] kwargs = captured["kwargs"] assert cfg.chat_id == 321 - assert cfg.voice_transcription is not None - assert cfg.voice_transcription.enabled is True + assert cfg.voice_transcription is True assert cfg.files.enabled is True assert cfg.files.allowed_user_ids == frozenset({1, 2}) assert cfg.topics.enabled is True @@ -101,12 +108,10 @@ def test_telegram_backend_build_and_run_wires_config( assert kwargs["transport_id"] == "telegram" -def test_build_files_config_rejects_non_dict(tmp_path: Path) -> None: - config_path = tmp_path / "takopi.toml" - transport_config: dict[str, object] = {"files": ["nope"]} +def test_build_files_config_defaults() -> None: + cfg = telegram_backend._build_files_config({}) - with pytest.raises(ConfigError, match="transports.telegram.files"): - telegram_backend._build_files_config( - transport_config, - config_path=config_path, - ) + assert cfg.enabled is False + assert cfg.auto_put is True + assert cfg.uploads_dir == "incoming" + assert cfg.allowed_user_ids == frozenset() diff --git a/tests/test_telegram_bridge.py b/tests/test_telegram_bridge.py index 2da0846..bff838f 100644 --- a/tests/test_telegram_bridge.py +++ b/tests/test_telegram_bridge.py @@ -7,23 +7,26 @@ import pytest from takopi import commands, plugins import takopi.telegram.bridge as bridge +import takopi.telegram.loop as telegram_loop +import takopi.telegram.commands as telegram_commands +import takopi.telegram.topics as telegram_topics from takopi.directives import parse_directives from takopi.telegram.bridge import ( TelegramBridgeConfig, TelegramFilesConfig, TelegramPresenter, TelegramTransport, - _build_bot_commands, - _handle_callback_cancel, - _handle_cancel, - _is_cancel_command, - _send_with_resume, + build_bot_commands, + handle_callback_cancel, + handle_cancel, + is_cancel_command, + send_with_resume, run_main_loop, ) from takopi.telegram.client import BotClient from takopi.telegram.topic_state import TopicStateStore, resolve_state_path from takopi.context import RunContext -from takopi.config import ProjectConfig, ProjectsConfig, empty_projects_config +from takopi.config import ProjectConfig, ProjectsConfig from takopi.runner_bridge import ExecBridgeConfig, RunningTask from takopi.markdown import MarkdownPresenter from takopi.model import EngineId, ResumeToken @@ -42,6 +45,10 @@ from tests.plugin_fixtures import FakeEntryPoint, install_entrypoints CODEX_ENGINE = EngineId("codex") +def _empty_projects() -> ProjectsConfig: + return ProjectsConfig(projects={}, default_project=None) + + def _make_router(runner) -> AutoRouter: return AutoRouter( entries=[RunnerEntry(engine=runner.engine, runner=runner)], @@ -288,7 +295,7 @@ def _make_cfg( ) runtime = TransportRuntime( router=_make_router(runner), - projects=empty_projects_config(), + projects=_empty_projects(), ) return TelegramBridgeConfig( bot=_FakeBot(), @@ -303,7 +310,7 @@ def test_parse_directives_inline_engine() -> None: directives = parse_directives( "/claude do it", engine_ids=("codex", "claude"), - projects=empty_projects_config(), + projects=_empty_projects(), ) assert directives.engine == "claude" assert directives.prompt == "do it" @@ -313,7 +320,7 @@ def test_parse_directives_newline() -> None: directives = parse_directives( "/codex\nhello", engine_ids=("codex", "claude"), - projects=empty_projects_config(), + projects=_empty_projects(), ) assert directives.engine == "codex" assert directives.prompt == "hello" @@ -323,7 +330,7 @@ def test_parse_directives_ignores_unknown() -> None: directives = parse_directives( "/unknown hi", engine_ids=("codex", "claude"), - projects=empty_projects_config(), + projects=_empty_projects(), ) assert directives.engine is None assert directives.prompt == "/unknown hi" @@ -333,7 +340,7 @@ def test_parse_directives_bot_suffix() -> None: directives = parse_directives( "/claude@bunny_agent_bot hi", engine_ids=("claude",), - projects=empty_projects_config(), + projects=_empty_projects(), ) assert directives.engine == "claude" assert directives.prompt == "hi" @@ -343,7 +350,7 @@ def test_parse_directives_only_first_non_empty_line() -> None: directives = parse_directives( "hello\n/claude hi", engine_ids=("codex", "claude"), - projects=empty_projects_config(), + projects=_empty_projects(), ) assert directives.engine is None assert directives.prompt == "hello\n/claude hi" @@ -355,9 +362,9 @@ def test_build_bot_commands_includes_cancel_and_engine() -> None: ) runtime = TransportRuntime( router=_make_router(runner), - projects=empty_projects_config(), + projects=_empty_projects(), ) - commands = _build_bot_commands(runtime) + commands = build_bot_commands(runtime) assert {"command": "cancel", "description": "cancel run"} in commands assert {"command": "file", "description": "upload or fetch files"} in commands @@ -386,7 +393,7 @@ def test_build_bot_commands_includes_projects() -> None: ) runtime = TransportRuntime(router=router, projects=projects) - commands = _build_bot_commands(runtime) + commands = build_bot_commands(runtime) assert any(cmd["command"] == "good" for cmd in commands) assert not any(cmd["command"] == "bad-name" for cmd in commands) @@ -413,10 +420,10 @@ def test_build_bot_commands_includes_command_plugins(monkeypatch) -> None: runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) runtime = TransportRuntime( router=_make_router(runner), - projects=empty_projects_config(), + projects=_empty_projects(), ) - commands_list = _build_bot_commands(runtime) + commands_list = build_bot_commands(runtime) assert {"command": "pingcmd", "description": "ping command"} in commands_list @@ -439,7 +446,7 @@ def test_build_bot_commands_caps_total() -> None: ) runtime = TransportRuntime(router=router, projects=projects) - commands = _build_bot_commands(runtime) + commands = build_bot_commands(runtime) assert len(commands) == 100 assert any(cmd["command"] == "codex" for cmd in commands) @@ -667,7 +674,7 @@ async def test_handle_cancel_without_reply_prompts_user() -> None: ) running_tasks: dict = {} - await _handle_cancel(cfg, msg, running_tasks) + await handle_cancel(cfg, msg, running_tasks) assert len(transport.send_calls) == 1 assert "reply to the progress message" in transport.send_calls[0]["message"].text @@ -688,7 +695,7 @@ async def test_handle_cancel_with_no_progress_message_says_nothing_running() -> ) running_tasks: dict = {} - await _handle_cancel(cfg, msg, running_tasks) + await handle_cancel(cfg, msg, running_tasks) assert len(transport.send_calls) == 1 assert "nothing is currently running" in transport.send_calls[0]["message"].text @@ -710,7 +717,7 @@ async def test_handle_cancel_with_finished_task_says_nothing_running() -> None: ) running_tasks: dict = {} - await _handle_cancel(cfg, msg, running_tasks) + await handle_cancel(cfg, msg, running_tasks) assert len(transport.send_calls) == 1 assert "nothing is currently running" in transport.send_calls[0]["message"].text @@ -733,7 +740,7 @@ async def test_handle_cancel_cancels_running_task() -> None: running_task = RunningTask() running_tasks = {MessageRef(channel_id=123, message_id=progress_id): running_task} - await _handle_cancel(cfg, msg, running_tasks) + await handle_cancel(cfg, msg, running_tasks) assert running_task.cancel_requested.is_set() is True assert len(transport.send_calls) == 0 # No error message sent @@ -759,7 +766,7 @@ async def test_handle_cancel_only_cancels_matching_progress_message() -> None: MessageRef(channel_id=123, message_id=2): task_second, } - await _handle_cancel(cfg, msg, running_tasks) + await handle_cancel(cfg, msg, running_tasks) assert task_first.cancel_requested.is_set() is True assert task_second.cancel_requested.is_set() is False @@ -824,7 +831,9 @@ async def test_handle_file_put_writes_file(tmp_path: Path) -> None: ), ) - await bridge._handle_file_put(cfg, msg, "/proj uploads/hello.txt", None, None) + await telegram_commands._handle_file_put( + cfg, msg, "/proj uploads/hello.txt", None, None + ) target = tmp_path / "uploads" / "hello.txt" assert target.read_bytes() == payload @@ -883,7 +892,7 @@ async def test_handle_file_get_sends_document_for_allowed_user( chat_type="supergroup", ) - await bridge._handle_file_get(cfg, msg, "/proj hello.txt", None, None) + await telegram_commands._handle_file_get(cfg, msg, "/proj hello.txt", None, None) assert bot.document_calls assert bot.document_calls[0]["filename"] == "hello.txt" @@ -906,7 +915,7 @@ async def test_handle_callback_cancel_cancels_running_task() -> None: sender_id=123, ) - await _handle_callback_cancel(cfg, query, running_tasks) + await handle_callback_cancel(cfg, query, running_tasks) assert running_task.cancel_requested.is_set() is True assert len(transport.send_calls) == 0 @@ -928,7 +937,7 @@ async def test_handle_callback_cancel_without_task_acknowledges() -> None: sender_id=123, ) - await _handle_callback_cancel(cfg, query, {}) + await handle_callback_cancel(cfg, query, {}) assert len(transport.send_calls) == 0 bot = cast(_FakeBot, cfg.bot) @@ -937,9 +946,9 @@ async def test_handle_callback_cancel_without_task_acknowledges() -> None: def test_cancel_command_accepts_extra_text() -> None: - assert _is_cancel_command("/cancel now") is True - assert _is_cancel_command("/cancel@takopi please") is True - assert _is_cancel_command("/cancelled") is False + assert is_cancel_command("/cancel now") is True + assert is_cancel_command("/cancel@takopi please") is True + assert is_cancel_command("/cancelled") is False def test_resolve_message_accepts_backticked_ctx_line() -> None: @@ -971,24 +980,21 @@ def test_topic_title_matches_command_syntax() -> None: transport = _FakeTransport() cfg = _make_cfg(transport) - title = bridge._topic_title( - cfg=cfg, + title = telegram_topics._topic_title( runtime=cfg.runtime, context=RunContext(project="takopi", branch="master"), ) assert title == "takopi @master" - title = bridge._topic_title( - cfg=cfg, + title = telegram_topics._topic_title( runtime=cfg.runtime, context=RunContext(project="takopi", branch=None), ) assert title == "takopi" - title = bridge._topic_title( - cfg=cfg, + title = telegram_topics._topic_title( runtime=cfg.runtime, context=RunContext(project=None, branch="main"), ) @@ -1006,8 +1012,7 @@ def test_topic_title_projects_scope_includes_project() -> None: ), ) - title = bridge._topic_title( - cfg=cfg, + title = telegram_topics._topic_title( runtime=cfg.runtime, context=RunContext(project="takopi", branch="master"), ) @@ -1028,7 +1033,7 @@ async def test_maybe_rename_topic_updates_title(tmp_path: Path) -> None: topic_title="takopi @old", ) - await bridge._maybe_rename_topic( + await telegram_topics._maybe_rename_topic( cfg, store, chat_id=123, @@ -1058,7 +1063,7 @@ async def test_maybe_rename_topic_skips_when_title_matches(tmp_path: Path) -> No ) snapshot = await store.get_thread(123, 77) - await bridge._maybe_rename_topic( + await telegram_topics._maybe_rename_topic( cfg, store, chat_id=123, @@ -1096,7 +1101,7 @@ async def test_send_with_resume_waits_for_token() -> None: async with anyio.create_task_group() as tg: tg.start_soon(trigger_resume) - await _send_with_resume( + await send_with_resume( cfg, enqueue, running_task, @@ -1138,7 +1143,7 @@ async def test_send_with_resume_reports_when_missing() -> None: running_task = RunningTask() running_task.done.set() - await _send_with_resume( + await send_with_resume( cfg, enqueue, running_task, @@ -1175,7 +1180,7 @@ async def test_run_main_loop_routes_reply_to_running_resume() -> None: ) runtime = TransportRuntime( router=_make_router(runner), - projects=empty_projects_config(), + projects=_empty_projects(), ) cfg = TelegramBridgeConfig( bot=bot, @@ -1311,7 +1316,7 @@ async def test_run_main_loop_replies_in_same_thread() -> None: ) runtime = TransportRuntime( router=_make_router(runner), - projects=empty_projects_config(), + projects=_empty_projects(), ) cfg = TelegramBridgeConfig( bot=bot, @@ -1489,7 +1494,7 @@ async def test_run_main_loop_handles_command_plugins(monkeypatch) -> None: ) runtime = TransportRuntime( router=_make_router(runner), - projects=empty_projects_config(), + projects=_empty_projects(), ) cfg = TelegramBridgeConfig( bot=bot, @@ -1714,7 +1719,7 @@ async def test_run_main_loop_refreshes_command_ids(monkeypatch) -> None: return [] return ["late_cmd"] - monkeypatch.setattr(bridge, "list_command_ids", _list_command_ids) + monkeypatch.setattr(telegram_loop, "list_command_ids", _list_command_ids) transport = _FakeTransport() bot = _FakeBot() @@ -1726,7 +1731,7 @@ async def test_run_main_loop_refreshes_command_ids(monkeypatch) -> None: ) runtime = TransportRuntime( router=_make_router(runner), - projects=empty_projects_config(), + projects=_empty_projects(), ) cfg = TelegramBridgeConfig( bot=bot, diff --git a/uv.lock b/uv.lock index de72b35..507514e 100644 --- a/uv.lock +++ b/uv.lock @@ -88,6 +88,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -156,6 +165,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jiter" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294, upload-time = "2025-11-09T20:49:23.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/99/45c9f0dbe4a1416b2b9a8a6d1236459540f43d7fb8883cff769a8db0612d/jiter-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c46d927acd09c67a9fb1416df45c5a04c27e83aae969267e98fba35b74e99525", size = 312478, upload-time = "2025-11-09T20:48:10.898Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a7/54ae75613ba9e0f55fcb0bc5d1f807823b5167cc944e9333ff322e9f07dd/jiter-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:774ff60b27a84a85b27b88cd5583899c59940bcc126caca97eb2a9df6aa00c49", size = 318706, upload-time = "2025-11-09T20:48:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/2aa241ad2c10774baf6c37f8b8e1f39c07db358f1329f4eb40eba179c2a2/jiter-0.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5433fab222fb072237df3f637d01b81f040a07dcac1cb4a5c75c7aa9ed0bef1", size = 351894, upload-time = "2025-11-09T20:48:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/54/4f/0f2759522719133a9042781b18cc94e335b6d290f5e2d3e6899d6af933e3/jiter-0.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8c593c6e71c07866ec6bfb790e202a833eeec885022296aff6b9e0b92d6a70e", size = 365714, upload-time = "2025-11-09T20:48:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6f/806b895f476582c62a2f52c453151edd8a0fde5411b0497baaa41018e878/jiter-0.12.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90d32894d4c6877a87ae00c6b915b609406819dce8bc0d4e962e4de2784e567e", size = 478989, upload-time = "2025-11-09T20:48:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/86/6c/012d894dc6e1033acd8db2b8346add33e413ec1c7c002598915278a37f79/jiter-0.12.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:798e46eed9eb10c3adbbacbd3bdb5ecd4cf7064e453d00dbef08802dae6937ff", size = 378615, upload-time = "2025-11-09T20:48:18.614Z" }, + { url = "https://files.pythonhosted.org/packages/87/30/d718d599f6700163e28e2c71c0bbaf6dace692e7df2592fd793ac9276717/jiter-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3f1368f0a6719ea80013a4eb90ba72e75d7ea67cfc7846db2ca504f3df0169a", size = 364745, upload-time = "2025-11-09T20:48:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/8f/85/315b45ce4b6ddc7d7fceca24068543b02bdc8782942f4ee49d652e2cc89f/jiter-0.12.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65f04a9d0b4406f7e51279710b27484af411896246200e461d80d3ba0caa901a", size = 386502, upload-time = "2025-11-09T20:48:21.543Z" }, + { url = "https://files.pythonhosted.org/packages/74/0b/ce0434fb40c5b24b368fe81b17074d2840748b4952256bab451b72290a49/jiter-0.12.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:fd990541982a24281d12b67a335e44f117e4c6cbad3c3b75c7dea68bf4ce3a67", size = 519845, upload-time = "2025-11-09T20:48:22.964Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a3/7a7a4488ba052767846b9c916d208b3ed114e3eb670ee984e4c565b9cf0d/jiter-0.12.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b111b0e9152fa7df870ecaebb0bd30240d9f7fff1f2003bcb4ed0f519941820b", size = 510701, upload-time = "2025-11-09T20:48:24.483Z" }, + { url = "https://files.pythonhosted.org/packages/c3/16/052ffbf9d0467b70af24e30f91e0579e13ded0c17bb4a8eb2aed3cb60131/jiter-0.12.0-cp314-cp314-win32.whl", hash = "sha256:a78befb9cc0a45b5a5a0d537b06f8544c2ebb60d19d02c41ff15da28a9e22d42", size = 205029, upload-time = "2025-11-09T20:48:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/e4/18/3cf1f3f0ccc789f76b9a754bdb7a6977e5d1d671ee97a9e14f7eb728d80e/jiter-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:e1fe01c082f6aafbe5c8faf0ff074f38dfb911d53f07ec333ca03f8f6226debf", size = 204960, upload-time = "2025-11-09T20:48:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/736821e52ecfdeeb0f024b8ab01b5a229f6b9293bbdb444c27efade50b0f/jiter-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:d72f3b5a432a4c546ea4bedc84cce0c3404874f1d1676260b9c7f048a9855451", size = 185529, upload-time = "2025-11-09T20:48:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/30/61/12ed8ee7a643cce29ac97c2281f9ce3956eb76b037e88d290f4ed0d41480/jiter-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e6ded41aeba3603f9728ed2b6196e4df875348ab97b28fc8afff115ed42ba7a7", size = 318974, upload-time = "2025-11-09T20:48:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c6/f3041ede6d0ed5e0e79ff0de4c8f14f401bbf196f2ef3971cdbe5fd08d1d/jiter-0.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a947920902420a6ada6ad51892082521978e9dd44a802663b001436e4b771684", size = 345932, upload-time = "2025-11-09T20:48:32.658Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/4d94835889edd01ad0e2dbfc05f7bdfaed46292e7b504a6ac7839aa00edb/jiter-0.12.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:add5e227e0554d3a52cf390a7635edaffdf4f8fce4fdbcef3cc2055bb396a30c", size = 367243, upload-time = "2025-11-09T20:48:34.093Z" }, + { url = "https://files.pythonhosted.org/packages/fd/76/0051b0ac2816253a99d27baf3dda198663aff882fa6ea7deeb94046da24e/jiter-0.12.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9b1cda8fcb736250d7e8711d4580ebf004a46771432be0ae4796944b5dfa5d", size = 479315, upload-time = "2025-11-09T20:48:35.507Z" }, + { url = "https://files.pythonhosted.org/packages/70/ae/83f793acd68e5cb24e483f44f482a1a15601848b9b6f199dacb970098f77/jiter-0.12.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deeb12a2223fe0135c7ff1356a143d57f95bbf1f4a66584f1fc74df21d86b993", size = 380714, upload-time = "2025-11-09T20:48:40.014Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/4808a88338ad2c228b1126b93fcd8ba145e919e886fe910d578230dabe3b/jiter-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c596cc0f4cb574877550ce4ecd51f8037469146addd676d7c1a30ebe6391923f", size = 365168, upload-time = "2025-11-09T20:48:41.462Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d4/04619a9e8095b42aef436b5aeb4c0282b4ff1b27d1db1508df9f5dc82750/jiter-0.12.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ab4c823b216a4aeab3fdbf579c5843165756bd9ad87cc6b1c65919c4715f783", size = 387893, upload-time = "2025-11-09T20:48:42.921Z" }, + { url = "https://files.pythonhosted.org/packages/17/ea/d3c7e62e4546fdc39197fa4a4315a563a89b95b6d54c0d25373842a59cbe/jiter-0.12.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e427eee51149edf962203ff8db75a7514ab89be5cb623fb9cea1f20b54f1107b", size = 520828, upload-time = "2025-11-09T20:48:44.278Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0b/c6d3562a03fd767e31cb119d9041ea7958c3c80cb3d753eafb19b3b18349/jiter-0.12.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:edb868841f84c111255ba5e80339d386d937ec1fdce419518ce1bd9370fac5b6", size = 511009, upload-time = "2025-11-09T20:48:45.726Z" }, + { url = "https://files.pythonhosted.org/packages/aa/51/2cb4468b3448a8385ebcd15059d325c9ce67df4e2758d133ab9442b19834/jiter-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8bbcfe2791dfdb7c5e48baf646d37a6a3dcb5a97a032017741dea9f817dca183", size = 205110, upload-time = "2025-11-09T20:48:47.033Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c5/ae5ec83dec9c2d1af805fd5fe8f74ebded9c8670c5210ec7820ce0dbeb1e/jiter-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2fa940963bf02e1d8226027ef461e36af472dea85d36054ff835aeed944dd873", size = 205223, upload-time = "2025-11-09T20:48:49.076Z" }, + { url = "https://files.pythonhosted.org/packages/97/9a/3c5391907277f0e55195550cf3fa8e293ae9ee0c00fb402fec1e38c0c82f/jiter-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506c9708dd29b27288f9f8f1140c3cb0e3d8ddb045956d7757b1fa0e0f39a473", size = 185564, upload-time = "2025-11-09T20:48:50.376Z" }, +] + [[package]] name = "lxml" version = "6.0.2" @@ -245,6 +287,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/3e/c5187de84bb2c2ca334ab163fcacf19a23ebb1d876c837f81a1b324a15bf/msgspec-0.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:93f23528edc51d9f686808a361728e903d6f2be55c901d6f5c92e44c6d546bfc", size = 183011, upload-time = "2025-11-24T03:56:16.442Z" }, ] +[[package]] +name = "openai" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/f4/4690ecb5d70023ce6bfcfeabfe717020f654bde59a775058ec6ac4692463/openai-2.15.0.tar.gz", hash = "sha256:42eb8cbb407d84770633f31bf727d4ffb4138711c670565a41663d9439174fba", size = 627383, upload-time = "2026-01-09T22:10:08.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/df/c306f7375d42bafb379934c2df4c2fa3964656c8c782bac75ee10c102818/openai-2.15.0-py3-none-any.whl", hash = "sha256:6ae23b932cd7230f7244e52954daa6602716d6b9bf235401a107af731baea6c3", size = 1067879, upload-time = "2026-01-09T22:10:06.446Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -473,6 +534,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "structlog" version = "25.5.0" @@ -504,6 +574,7 @@ dependencies = [ { name = "httpx" }, { name = "markdown-it-py" }, { name = "msgspec" }, + { name = "openai" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "questionary" }, @@ -529,6 +600,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "markdown-it-py" }, { name = "msgspec", specifier = ">=0.20.0" }, + { name = "openai", specifier = ">=2.15.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "questionary", specifier = ">=2.1.1" }, @@ -548,6 +620,18 @@ dev = [ { name = "ty", specifier = ">=0.0.8" }, ] +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + [[package]] name = "ty" version = "0.0.8"