refactor: simplify runtime, config, and telegram (#85)
This commit is contained in:
@@ -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<br/>[--resume id] prompt"]
|
||||
D -->|Codex| D2["codex exec --output jsonl<br/>[--reconnect id] prompt"]
|
||||
D -->|Pi| D3["pi --output jsonl<br/>[--session id] prompt"]
|
||||
D -->|OpenCode| D4["opencode --output jsonl<br/>[--session id] prompt"]
|
||||
D -->|Codex| D2["codex exec --json<br/>[resume <token>] -"]
|
||||
D -->|Pi| D3["pi --print --mode json<br/>--session <id> <prompt>"]
|
||||
D -->|OpenCode| D4["opencode run --format json<br/>[--session id] -- <prompt>"]
|
||||
|
||||
D1 --> E[Spawn Subprocess<br/>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<br/>default_engine<br/>default_project"]
|
||||
telegram_cfg["[transports.telegram]<br/>bot_token = ...<br/>chat_id = ..."]
|
||||
plugins_cfg["[plugins]<br/>enabled = [\"...\"]"]
|
||||
plugins_cfg["[plugins]<br/>enabled = [...]"]
|
||||
plugins_extra["[plugins.mycommand]<br/>setting = ..."]
|
||||
claude_cfg["[claude]<br/>model = ..."]
|
||||
codex_cfg["[codex]<br/>model = ..."]
|
||||
|
||||
+3
-5
@@ -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
|
||||
|
||||
+1
-1
@@ -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"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+12
-162
@@ -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(
|
||||
|
||||
+23
-163
@@ -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('"', '\\"')
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
+9
-16
@@ -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:
|
||||
|
||||
@@ -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<token>[^`\s]+)`?\s*$")
|
||||
_RECONNECTING_RE = re.compile(
|
||||
r"^Reconnecting\.{3}\s*(?P<attempt>\d+)/(?P<max>\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 "
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+63
-213
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
+103
-2659
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 <project> [@branch]`"
|
||||
|
||||
|
||||
def _usage_topic(*, chat_project: str | None) -> str:
|
||||
if chat_project is not None:
|
||||
return "usage: `/topic @branch`"
|
||||
return "usage: `/topic <project> @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
|
||||
@@ -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 <path>` or `/file get <path>`"
|
||||
|
||||
|
||||
def file_put_usage() -> str:
|
||||
return "usage: `/file put <path>`"
|
||||
|
||||
|
||||
def file_get_usage() -> str:
|
||||
return "usage: `/file get <path>`"
|
||||
|
||||
|
||||
def parse_file_command(args_text: str) -> tuple[str | None, str, str | None]:
|
||||
tokens = split_command_args(args_text)
|
||||
if not tokens:
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.<alias>.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."
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")))
|
||||
|
||||
+10
-11
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
+19
-15
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user