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"