refactor: simplify runtime, config, and telegram (#85)

This commit is contained in:
banteg
2026-01-11 14:48:39 +04:00
committed by GitHub
parent 2380b3e5e9
commit 194cc02bba
42 changed files with 3204 additions and 3717 deletions
+6 -6
View File
@@ -138,7 +138,7 @@ classDiagram
ActionEvent --> Action ActionEvent --> Action
CompletedEvent --> ResumeToken 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?} C --> D{Engine?}
D -->|Claude| D1["claude --print --output-format stream-json<br/>[--resume id] prompt"] 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 -->|Codex| D2["codex exec --json<br/>[resume &lt;token&gt;] -"]
D -->|Pi| D3["pi --output jsonl<br/>[--session id] prompt"] D -->|Pi| D3["pi --print --mode json<br/>--session &lt;id&gt; &lt;prompt&gt;"]
D -->|OpenCode| D4["opencode --output jsonl<br/>[--session id] prompt"] D -->|OpenCode| D4["opencode run --format json<br/>[--session id] -- &lt;prompt&gt;"]
D1 --> E[Spawn Subprocess<br/>anyio.open_process] D1 --> E[Spawn Subprocess<br/>anyio.open_process]
D2 --> E D2 --> E
@@ -320,14 +320,14 @@ flowchart TD
flowchart LR flowchart LR
subgraph Config["~/.takopi/"] subgraph Config["~/.takopi/"]
toml[takopi.toml] toml[takopi.toml]
lock[.takopi.lock] lock[takopi.lock]
end end
subgraph toml_contents["takopi.toml"] subgraph toml_contents["takopi.toml"]
direction TB direction TB
global["transport<br/>default_engine<br/>default_project"] global["transport<br/>default_engine<br/>default_project"]
telegram_cfg["[transports.telegram]<br/>bot_token = ...<br/>chat_id = ..."] 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 = ..."] plugins_extra["[plugins.mycommand]<br/>setting = ..."]
claude_cfg["[claude]<br/>model = ..."] claude_cfg["[claude]<br/>model = ..."]
codex_cfg["[codex]<br/>model = ..."] codex_cfg["[codex]<br/>model = ..."]
+3 -5
View File
@@ -17,7 +17,7 @@ See `public-api.md` for the stable API surface you should depend on.
## Entrypoint groups ## Entrypoint groups
Takopi uses two Python entrypoint groups: Takopi uses three Python entrypoint groups:
```toml ```toml
[project.entry-points."takopi.engine_backends"] [project.entry-points."takopi.engine_backends"]
@@ -36,6 +36,7 @@ mycommand = "mycommand.backend:BACKEND"
- The entrypoint value must resolve to a **backend object**: - The entrypoint value must resolve to a **backend object**:
- Engine backend -> `EngineBackend` - Engine backend -> `EngineBackend`
- Transport backend -> `TransportBackend` - Transport backend -> `TransportBackend`
- Command backend -> `CommandBackend`
- The backend object **must** have `id == entrypoint name`. - The backend object **must** have `id == entrypoint name`.
Takopi validates this at load time and will report errors via `takopi plugins --load`. 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 ```toml
[plugins] [plugins]
enabled = ["takopi-transport-slack", "takopi-engine-acme"] enabled = ["takopi-transport-slack", "takopi-engine-acme"]
auto_install = false
``` ```
- `enabled = []` (default) -> load all installed plugins. - `enabled = []` (default) -> load all installed plugins.
- If `enabled` is non-empty, **only distributions with matching names** are visible. - If `enabled` is non-empty, **only distributions with matching names** are visible.
- Distribution names are taken from package metadata (case-insensitive). - 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. - 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: This enabled list affects:
- Engine subcommands registered in the CLI - 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: - Depend on a compatible Takopi version range, for example:
```toml ```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 When the plugin API changes, Takopi will bump the API version and document
+1 -1
View File
@@ -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.: - Plugins should pin to a compatible Takopi range, e.g.:
```toml ```toml
dependencies = ["takopi>=0.11,<0.12"] dependencies = ["takopi>=0.14,<0.15"]
``` ```
--- ---
+1 -2
View File
@@ -10,7 +10,7 @@ This document captures current behavior so transport changes stay intentional.
## Flow ## 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. 2. We render progress on every step and diff against the last output.
3. Only deltas enqueue a Telegram edit. 3. Only deltas enqueue a Telegram edit.
4. High-value messages enqueue a send. 4. High-value messages enqueue a send.
@@ -91,7 +91,6 @@ Scheduling:
- Ordered by `(priority, queued_at)`. - Ordered by `(priority, queued_at)`.
- Priorities: send=0, delete=1, edit=2. - Priorities: send=0, delete=1, edit=2.
- Within a priority tier, the oldest pending op runs first. - Within a priority tier, the oldest pending op runs first.
- `updated_at` is kept for debugging only.
## Rate limiting + backoff ## Rate limiting + backoff
+1
View File
@@ -11,6 +11,7 @@ dependencies = [
"httpx>=0.28.1", "httpx>=0.28.1",
"markdown-it-py", "markdown-it-py",
"msgspec>=0.20.0", "msgspec>=0.20.0",
"openai>=2.15.0",
"pydantic>=2.12.5", "pydantic>=2.12.5",
"pydantic-settings>=2.12.0", "pydantic-settings>=2.12.0",
"questionary>=2.1.1", "questionary>=2.1.1",
+12 -162
View File
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import os import os
import shutil
import sys import sys
from collections.abc import Callable from collections.abc import Callable
from importlib.metadata import EntryPoint from importlib.metadata import EntryPoint
@@ -10,7 +9,6 @@ from pathlib import Path
import typer import typer
from . import __version__ from . import __version__
from .backends import EngineBackend
from .config import ConfigError, load_or_init_config, write_config from .config import ConfigError, load_or_init_config, write_config
from .config_migrations import migrate_config from .config_migrations import migrate_config
from .commands import get_command 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 .ids import RESERVED_COMMAND_IDS, RESERVED_ENGINE_IDS
from .lockfile import LockError, LockHandle, acquire_lock, token_fingerprint from .lockfile import LockError, LockHandle, acquire_lock, token_fingerprint
from .logging import get_logger, setup_logging from .logging import get_logger, setup_logging
from .router import AutoRouter, RunnerEntry from .runtime_loader import build_runtime_spec, resolve_plugins_allowlist
from .settings import ( from .settings import (
TakopiSettings, TakopiSettings,
load_settings, load_settings,
@@ -36,7 +34,6 @@ from .plugins import (
normalize_allowlist, normalize_allowlist,
) )
from .transports import SetupResult, get_transport from .transports import SetupResult, get_transport
from .transport_runtime import TransportRuntime
from .utils.git import resolve_default_base, resolve_main_worktree_root from .utils.git import resolve_default_base, resolve_main_worktree_root
from .telegram import onboarding from .telegram import onboarding
@@ -53,19 +50,6 @@ def _load_settings_optional() -> tuple[TakopiSettings | None, Path | None]:
return loaded 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: def _print_version_and_exit() -> None:
typer.echo(__version__) typer.echo(__version__)
raise typer.Exit() raise typer.Exit()
@@ -128,115 +112,6 @@ def _default_engine_for_setup(
return value.strip() 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: def _config_path_display(path: Path) -> str:
home = Path.home() home = Path.home()
try: try:
@@ -278,7 +153,7 @@ def _run_auto_router(
lock_handle: LockHandle | None = None lock_handle: LockHandle | None = None
try: try:
settings_hint, config_hint = _load_settings_optional() 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 = _default_engine_for_setup(
default_engine_override, default_engine_override,
settings=settings_hint, settings=settings_hint,
@@ -297,7 +172,7 @@ def _run_auto_router(
if not transport_backend.interactive_setup(force=True): if not transport_backend.interactive_setup(force=True):
raise typer.Exit(code=1) raise typer.Exit(code=1)
settings_hint, config_hint = _load_settings_optional() 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 = _default_engine_for_setup(
default_engine_override, default_engine_override,
settings=settings_hint, settings=settings_hint,
@@ -319,7 +194,7 @@ def _run_auto_router(
) )
if run_onboard and transport_backend.interactive_setup(force=True): if run_onboard and transport_backend.interactive_setup(force=True):
settings_hint, config_hint = _load_settings_optional() 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 = _default_engine_for_setup(
default_engine_override, default_engine_override,
settings=settings_hint, settings=settings_hint,
@@ -332,7 +207,7 @@ def _run_auto_router(
) )
elif transport_backend.interactive_setup(force=False): elif transport_backend.interactive_setup(force=False):
settings_hint, config_hint = _load_settings_optional() 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 = _default_engine_for_setup(
default_engine_override, default_engine_override,
settings=settings_hint, settings=settings_hint,
@@ -354,30 +229,12 @@ def _run_auto_router(
settings, config_path = load_settings() settings, config_path = load_settings()
if transport_override and transport_override != settings.transport: if transport_override and transport_override != settings.transport:
settings = settings.model_copy(update={"transport": transport_override}) settings = settings.model_copy(update={"transport": transport_override})
allowlist = _resolve_plugins_allowlist(settings) spec = build_runtime_spec(
engine_ids = list_backend_ids(allowlist=allowlist) settings=settings,
projects = settings.to_projects_config(
config_path=config_path, config_path=config_path,
engine_ids=engine_ids, default_engine_override=default_engine_override,
reserved=("cancel",), 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( transport_config = settings.transport_config(
settings.transport, config_path=config_path settings.transport, config_path=config_path
) )
@@ -386,13 +243,7 @@ def _run_auto_router(
config_path=config_path, config_path=config_path,
) )
lock_handle = acquire_config_lock(config_path, lock_token) lock_handle = acquire_config_lock(config_path, lock_token)
runtime = TransportRuntime( runtime = spec.to_runtime(config_path=config_path)
router=router,
projects=projects,
allowlist=allowlist,
config_path=config_path,
plugin_configs=settings.plugins.model_extra,
)
transport_backend.build_and_run( transport_backend.build_and_run(
final_notify=final_notify, final_notify=final_notify,
default_engine_override=default_engine_override, default_engine_override=default_engine_override,
@@ -467,7 +318,7 @@ def init(
alias = _prompt_alias(alias, default_alias=default_alias) alias = _prompt_alias(alias, default_alias=default_alias)
settings = validate_settings_data(config, config_path=config_path) 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) engine_ids = list_backend_ids(allowlist=allowlist)
projects_cfg = settings.to_projects_config( projects_cfg = settings.to_projects_config(
config_path=config_path, config_path=config_path,
@@ -535,8 +386,7 @@ def chat_id(
settings, _ = _load_settings_optional() settings, _ = _load_settings_optional()
if settings is not None: if settings is not None:
tg = settings.transports.telegram tg = settings.transports.telegram
if tg.bot_token is not None: token = tg.bot_token or None
token = tg.bot_token.get_secret_value().strip() or None
chat = onboarding.capture_chat_id(token=token) chat = onboarding.capture_chat_id(token=token)
if chat is None: if chat is None:
raise typer.Exit(code=1) raise typer.Exit(code=1)
@@ -601,7 +451,7 @@ def plugins_cmd(
) -> None: ) -> None:
"""List discovered plugins and optionally validate them.""" """List discovered plugins and optionally validate them."""
settings_hint, _ = _load_settings_optional() settings_hint, _ = _load_settings_optional()
allowlist = _resolve_plugins_allowlist(settings_hint) allowlist = resolve_plugins_allowlist(settings_hint)
allowlist_set = normalize_allowlist(allowlist) allowlist_set = normalize_allowlist(allowlist)
engine_eps = list_entrypoints( engine_eps = list_entrypoints(
+23 -163
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import tomllib import tomllib
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any
HOME_CONFIG_PATH = Path.home() / ".takopi" / "takopi.toml" HOME_CONFIG_PATH = Path.home() / ".takopi" / "takopi.toml"
@@ -12,7 +12,27 @@ class ConfigError(RuntimeError):
pass 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: try:
raw = cfg_path.read_text(encoding="utf-8") raw = cfg_path.read_text(encoding="utf-8")
except FileNotFoundError: 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 raise ConfigError(f"Config path {cfg_path} exists but is not a file.") from None
if not cfg_path.exists(): if not cfg_path.exists():
return {}, cfg_path return {}, cfg_path
return _read_config(cfg_path), cfg_path return read_config(cfg_path), cfg_path
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -72,166 +92,6 @@ class ProjectsConfig:
return tuple(self.chat_map.keys()) 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: def _toml_escape(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"') return value.replace("\\", "\\\\").replace('"', '\\"')
+35 -36
View File
@@ -3,28 +3,24 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from .config import ConfigError from .config import ConfigError, ensure_table, read_config, write_config
from .config_store import read_raw_toml, write_raw_toml
from .logging import get_logger from .logging import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
def _ensure_table( def _ensure_subtable(
config: dict[str, Any], parent: dict[str, Any],
key: str, key: str,
*, *,
config_path: Path, config_path: Path,
label: str | None = None, label: str,
) -> dict[str, Any]: ) -> dict[str, Any] | None:
value = config.get(key) value = parent.get(key)
if value is None: if value is None:
table: dict[str, Any] = {} return None
config[key] = table
return table
if not isinstance(value, dict): if not isinstance(value, dict):
name = label or key raise ConfigError(f"Invalid `{label}` in {config_path}; expected a table.")
raise ConfigError(f"Invalid `{name}` in {config_path}; expected a table.")
return value return value
@@ -33,15 +29,13 @@ def _migrate_legacy_telegram(config: dict[str, Any], *, config_path: Path) -> bo
if not has_legacy: if not has_legacy:
return False return False
transports = _ensure_table(config, "transports", config_path=config_path) transports = ensure_table(config, "transports", config_path=config_path)
telegram = transports.get("telegram") telegram = ensure_table(
if telegram is None: transports,
telegram = {} "telegram",
transports["telegram"] = telegram config_path=config_path,
if not isinstance(telegram, dict): label="transports.telegram",
raise ConfigError( )
f"Invalid `transports.telegram` in {config_path}; expected a table."
)
if "bot_token" in config and "bot_token" not in telegram: if "bot_token" in config and "bot_token" not in telegram:
telegram["bot_token"] = config["bot_token"] 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: 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: if transports is None:
return False 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: if telegram is None:
return False 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: if topics is None:
return False 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: if "mode" not in topics:
return False 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]: def migrate_config_file(path: Path) -> list[str]:
config = read_raw_toml(path) config = read_config(path)
applied = migrate_config(config, config_path=path) applied = migrate_config(config, config_path=path)
if applied: if applied:
write_raw_toml(config, path) write_config(config, path)
for migration in applied: for migration in applied:
logger.info( logger.info(
"config.migrated", "config.migrated",
-27
View File
@@ -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")
+10 -4
View File
@@ -14,6 +14,12 @@ from .transport_runtime import TransportRuntime
logger = get_logger(__name__) logger = get_logger(__name__)
__all__ = [
"ConfigReload",
"config_status",
"watch_config",
]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ConfigReload: class ConfigReload:
@@ -22,7 +28,7 @@ class ConfigReload:
config_path: Path 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: try:
stat = path.stat() stat = path.stat()
except FileNotFoundError: except FileNotFoundError:
@@ -64,7 +70,7 @@ async def watch_config(
reserved_tuple = tuple(reserved) reserved_tuple = tuple(reserved)
config_path = config_path.expanduser().resolve() config_path = config_path.expanduser().resolve()
watch_root = config_path.parent watch_root = config_path.parent
status, signature = _config_status(config_path) status, signature = config_status(config_path)
last_status = status last_status = status
if status != "ok": if status != "ok":
logger.warning("config.watch.unavailable", path=str(config_path), status=status) 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): if not any(Path(path) == config_path for _, path in changes):
continue continue
status, current = _config_status(config_path) status, current = config_status(config_path)
if status != "ok": if status != "ok":
if status != last_status: if status != last_status:
logger.warning( logger.warning(
@@ -123,6 +129,6 @@ async def watch_config(
error_type=exc.__class__.__name__, error_type=exc.__class__.__name__,
) )
_, signature = _config_status(config_path) _, signature = config_status(config_path)
if signature is None: if signature is None:
signature = current signature = current
+9 -16
View File
@@ -41,8 +41,7 @@ class PluginNotFound(LookupError):
super().__init__(message) super().__init__(message)
_LOAD_ERRORS: list[PluginLoadError] = [] _LOAD_ERRORS: dict[tuple[str, str, str, str | None, str], PluginLoadError] = {}
_LOAD_ERROR_KEYS: set[tuple[str, str, str, str | None, str]] = set()
_LOADED: dict[tuple[str, str], Any] = {} _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: def _record_error(error: PluginLoadError) -> None:
key = _error_key(error) key = _error_key(error)
if key in _LOAD_ERROR_KEYS: _LOAD_ERRORS.setdefault(key, error)
return
_LOAD_ERROR_KEYS.add(key)
_LOAD_ERRORS.append(error)
def get_load_errors() -> tuple[PluginLoadError, ...]: 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: def clear_load_errors(*, group: str | None = None, name: str | None = None) -> None:
if group is None and name is None: if group is None and name is None:
_LOAD_ERRORS.clear() _LOAD_ERRORS.clear()
_LOAD_ERROR_KEYS.clear()
return return
remaining: list[PluginLoadError] = [] remaining: dict[tuple[str, str, str, str | None, str], PluginLoadError] = {}
_LOAD_ERROR_KEYS.clear() for key, error in _LOAD_ERRORS.items():
for error in _LOAD_ERRORS:
if group is not None and error.group != group: if group is not None and error.group != group:
remaining.append(error) remaining[key] = error
_LOAD_ERROR_KEYS.add(_error_key(error))
continue continue
if name is not None and error.name != name: if name is not None and error.name != name:
remaining.append(error) remaining[key] = error
_LOAD_ERROR_KEYS.add(_error_key(error))
continue continue
_LOAD_ERRORS[:] = remaining _LOAD_ERRORS.clear()
_LOAD_ERRORS.update(remaining)
def reset_plugin_state() -> None: def reset_plugin_state() -> None:
+9 -2
View File
@@ -20,6 +20,13 @@ logger = get_logger(__name__)
ENGINE: EngineId = EngineId("codex") 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*$") _RESUME_RE = re.compile(r"(?im)^\s*`?codex\s+resume\s+(?P<token>[^`\s]+)`?\s*$")
_RECONNECTING_RE = re.compile( _RECONNECTING_RE = re.compile(
r"^Reconnecting\.{3}\s*(?P<attempt>\d+)/(?P<max>\d+)\s*$", 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: for arg in extra_args:
if arg in _EXEC_ONLY_FLAGS: if arg in _EXEC_ONLY_FLAGS:
return arg 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." 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: if exec_only_flag:
raise ConfigError( raise ConfigError(
f"Invalid `codex.extra_args` in {config_path}; exec-only flag " f"Invalid `codex.extra_args` in {config_path}; exec-only flag "
+5 -10
View File
@@ -22,6 +22,7 @@ class RuntimeSpec:
projects: ProjectsConfig projects: ProjectsConfig
allowlist: list[str] | None allowlist: list[str] | None
plugin_configs: Mapping[str, Any] | None plugin_configs: Mapping[str, Any] | None
watch_config: bool = False
def to_runtime(self, *, config_path: Path | None) -> TransportRuntime: def to_runtime(self, *, config_path: Path | None) -> TransportRuntime:
return TransportRuntime( return TransportRuntime(
@@ -30,6 +31,7 @@ class RuntimeSpec:
allowlist=self.allowlist, allowlist=self.allowlist,
config_path=config_path, config_path=config_path,
plugin_configs=self.plugin_configs, plugin_configs=self.plugin_configs,
watch_config=self.watch_config,
) )
def apply(self, runtime: TransportRuntime, *, config_path: Path | None) -> None: def apply(self, runtime: TransportRuntime, *, config_path: Path | None) -> None:
@@ -39,6 +41,7 @@ class RuntimeSpec:
allowlist=self.allowlist, allowlist=self.allowlist,
config_path=config_path, config_path=config_path,
plugin_configs=self.plugin_configs, plugin_configs=self.plugin_configs,
watch_config=self.watch_config,
) )
@@ -47,11 +50,7 @@ def resolve_plugins_allowlist(
) -> list[str] | None: ) -> list[str] | None:
if settings is None: if settings is None:
return None return None
enabled = [ enabled = list(settings.plugins.enabled)
value.strip()
for value in settings.plugins.enabled
if isinstance(value, str) and value.strip()
]
return enabled or None return enabled or None
@@ -63,11 +62,6 @@ def resolve_default_engine(
engine_ids: list[str], engine_ids: list[str],
) -> str: ) -> str:
default_engine = override or settings.default_engine or "codex" 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: if default_engine not in engine_ids:
available = ", ".join(sorted(engine_ids)) available = ", ".join(sorted(engine_ids))
raise ConfigError( raise ConfigError(
@@ -200,4 +194,5 @@ def build_runtime_spec(
projects=projects, projects=projects,
allowlist=allowlist, allowlist=allowlist,
plugin_configs=settings.plugins.model_extra, plugin_configs=settings.plugins.model_extra,
watch_config=settings.watch_config,
) )
+63 -213
View File
@@ -1,18 +1,18 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Annotated, Any, Iterable, Literal
from pydantic import ( from pydantic import (
BaseModel, BaseModel,
ConfigDict, ConfigDict,
Field, Field,
SecretStr,
ValidationError, ValidationError,
field_serializer, StringConstraints,
field_validator, field_validator,
model_validator, model_validator,
) )
from pydantic.types import StrictInt
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic_settings.sources import TomlConfigSettingsSource from pydantic_settings.sources import TomlConfigSettingsSource
@@ -21,39 +21,52 @@ from .config import (
HOME_CONFIG_PATH, HOME_CONFIG_PATH,
ProjectConfig, ProjectConfig,
ProjectsConfig, ProjectsConfig,
_normalize_engine_id,
_normalize_project_path,
) )
from .config_migrations import migrate_config_file 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): class TelegramTopicsSettings(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
enabled: bool = False enabled: bool = False
scope: str = "auto" scope: Literal["auto", "main", "projects", "all"] = "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
class TelegramFilesSettings(BaseModel): class TelegramFilesSettings(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
enabled: bool = False enabled: bool = False
auto_put: bool = True auto_put: bool = True
uploads_dir: str = "incoming" uploads_dir: NonEmptyStr = "incoming"
allowed_user_ids: list[int] = Field(default_factory=list) allowed_user_ids: list[StrictInt] = Field(default_factory=list)
deny_globs: list[str] = Field( deny_globs: list[NonEmptyStr] = Field(
default_factory=lambda: [ default_factory=lambda: [
".git/**", ".git/**",
".env", ".env",
@@ -63,81 +76,23 @@ class TelegramFilesSettings(BaseModel):
] ]
) )
@field_validator("uploads_dir", mode="before") @field_validator("uploads_dir")
@classmethod @classmethod
def _validate_uploads_dir(cls, value: Any) -> Any: def _validate_uploads_dir(cls, value: str) -> str:
if value is None: if Path(value).is_absolute():
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():
raise ValueError("files.uploads_dir must be a relative path") 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 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): class TelegramTransportSettings(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
bot_token: SecretStr | None = None bot_token: NonEmptyStr
chat_id: int | None = None chat_id: StrictInt
voice_transcription: bool = False voice_transcription: bool = False
topics: TelegramTopicsSettings = Field(default_factory=TelegramTopicsSettings) topics: TelegramTopicsSettings = Field(default_factory=TelegramTopicsSettings)
files: TelegramFilesSettings = Field(default_factory=TelegramFilesSettings) 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): class TransportsSettings(BaseModel):
telegram: TelegramTransportSettings = Field( telegram: TelegramTransportSettings = Field(
@@ -148,47 +103,19 @@ class TransportsSettings(BaseModel):
class PluginsSettings(BaseModel): class PluginsSettings(BaseModel):
enabled: list[str] = Field(default_factory=list) enabled: list[NonEmptyStr] = Field(default_factory=list)
auto_install: bool = False
model_config = ConfigDict(extra="allow") model_config = ConfigDict(extra="allow", str_strip_whitespace=True)
class ProjectSettings(BaseModel): class ProjectSettings(BaseModel):
path: str model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
worktrees_dir: str = ".worktrees"
default_engine: str | None = None
worktree_base: str | None = None
chat_id: int | None = None
model_config = ConfigDict(extra="allow") path: NonEmptyStr
worktrees_dir: NonEmptyStr = ".worktrees"
@field_validator( default_engine: NonEmptyStr | None = None
"path", worktree_base: NonEmptyStr | None = None
"worktrees_dir", chat_id: StrictInt | None = None
"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
class TakopiSettings(BaseSettings): class TakopiSettings(BaseSettings):
@@ -196,14 +123,15 @@ class TakopiSettings(BaseSettings):
extra="allow", extra="allow",
env_prefix="TAKOPI__", env_prefix="TAKOPI__",
env_nested_delimiter="__", env_nested_delimiter="__",
str_strip_whitespace=True,
) )
watch_config: bool = False watch_config: bool = False
default_engine: str = "codex" default_engine: NonEmptyStr = "codex"
default_project: str | None = None default_project: NonEmptyStr | None = None
projects: dict[str, ProjectSettings] = Field(default_factory=dict) projects: dict[str, ProjectSettings] = Field(default_factory=dict)
transport: str = "telegram" transport: NonEmptyStr = "telegram"
transports: TransportsSettings = Field(default_factory=TransportsSettings) transports: TransportsSettings = Field(default_factory=TransportsSettings)
plugins: PluginsSettings = Field(default_factory=PluginsSettings) plugins: PluginsSettings = Field(default_factory=PluginsSettings)
@@ -218,30 +146,6 @@ class TakopiSettings(BaseSettings):
) )
return data 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 @classmethod
def settings_customise_sources( def settings_customise_sources(
cls, cls,
@@ -302,11 +206,7 @@ class TakopiSettings(BaseSettings):
chat_map: dict[int, str] = {} chat_map: dict[int, str] = {}
for raw_alias, entry in self.projects.items(): for raw_alias, entry in self.projects.items():
if not isinstance(raw_alias, str) or not raw_alias.strip(): alias = raw_alias
raise ConfigError(
f"Invalid project alias in {config_path}; expected a non-empty string."
)
alias = raw_alias.strip()
alias_key = alias.lower() alias_key = alias.lower()
if alias_key in engine_map or alias_key in reserved_lower: if alias_key in engine_map or alias_key in reserved_lower:
raise ConfigError( raise ConfigError(
@@ -318,56 +218,24 @@ class TakopiSettings(BaseSettings):
f"Duplicate project alias {alias!r} in {config_path}." f"Duplicate project alias {alias!r} in {config_path}."
) )
path_value = entry.path path = _normalize_project_path(entry.path, config_path=config_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 = entry.worktrees_dir worktrees_dir = Path(entry.worktrees_dir).expanduser()
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 = entry.default_engine
default_engine = None default_engine = None
if default_engine_raw is not None: if entry.default_engine 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 = _normalize_engine_id(
default_engine_raw, entry.default_engine,
engine_ids=engine_ids, engine_ids=engine_ids,
config_path=config_path, config_path=config_path,
label=f"projects.{alias}.default_engine", label=f"projects.{alias}.default_engine",
) )
worktree_base_raw = entry.worktree_base worktree_base = 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()
chat_id = entry.chat_id chat_id = entry.chat_id
if chat_id is not None: if chat_id is not None:
if isinstance(chat_id, bool) or not isinstance(chat_id, int): if chat_id == default_chat_id:
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:
raise ConfigError( raise ConfigError(
f"Invalid `projects.{alias}.chat_id` in {config_path}; " f"Invalid `projects.{alias}.chat_id` in {config_path}; "
"must not match transports.telegram.chat_id." "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)." "(telegram only for now)."
) )
tg = settings.transports.telegram 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}.") raise ConfigError(f"Missing bot token in {config_path}.")
if tg.chat_id is None: return tg.bot_token, tg.chat_id
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
def _resolve_config_path(path: str | Path | None) -> Path: def _resolve_config_path(path: str | Path | None) -> Path:
+31 -71
View File
@@ -2,21 +2,14 @@ from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
from typing import cast
import anyio import anyio
from ..backends import EngineBackend from ..backends import EngineBackend
from ..runner_bridge import ExecBridgeConfig from ..runner_bridge import ExecBridgeConfig
from ..config import ConfigError
from ..logging import get_logger 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 ..transports import SetupResult, TransportBackend
from ..transport_runtime import TransportRuntime from ..transport_runtime import TransportRuntime
from .bridge import ( from .bridge import (
@@ -25,7 +18,6 @@ from .bridge import (
TelegramTransport, TelegramTransport,
TelegramFilesConfig, TelegramFilesConfig,
TelegramTopicsConfig, TelegramTopicsConfig,
TelegramVoiceTranscriptionConfig,
run_main_loop, run_main_loop,
) )
from .client import TelegramClient from .client import TelegramClient
@@ -57,54 +49,31 @@ def _build_startup_message(
) )
def _build_voice_transcription_config( def _build_topics_config(transport_config: dict[str, object]) -> TelegramTopicsConfig:
transport_config: dict[str, object], raw = cast(dict[str, object], transport_config.get("topics", {}))
) -> 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
return TelegramTopicsConfig( return TelegramTopicsConfig(
enabled=settings.enabled, enabled=cast(bool, raw.get("enabled", False)),
scope=settings.scope, scope=cast(str, raw.get("scope", "auto")),
) )
def _build_files_config( def _build_files_config(transport_config: dict[str, object]) -> TelegramFilesConfig:
transport_config: dict[str, object], defaults = TelegramFilesConfig()
*, raw = cast(dict[str, object], transport_config.get("files", {}))
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
return TelegramFilesConfig( return TelegramFilesConfig(
enabled=settings.enabled, enabled=cast(bool, raw.get("enabled", defaults.enabled)),
auto_put=settings.auto_put, auto_put=cast(bool, raw.get("auto_put", defaults.auto_put)),
uploads_dir=settings.uploads_dir, uploads_dir=cast(str, raw.get("uploads_dir", defaults.uploads_dir)),
allowed_user_ids=frozenset(settings.allowed_user_ids), max_upload_bytes=defaults.max_upload_bytes,
deny_globs=tuple(settings.deny_globs), 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( def lock_token(
self, *, transport_config: dict[str, object], config_path: Path self, *, transport_config: dict[str, object], config_path: Path
) -> str | None: ) -> str | None:
token, _ = require_telegram_config(transport_config, config_path) _ = config_path
return token return cast(str, transport_config.get("bot_token"))
def build_and_run( def build_and_run(
self, self,
@@ -138,18 +107,8 @@ class TelegramBackend(TransportBackend):
final_notify: bool, final_notify: bool,
default_engine_override: str | None, default_engine_override: str | None,
) -> None: ) -> None:
watch_enabled = False token = cast(str, transport_config.get("bot_token"))
try: chat_id = cast(int, transport_config.get("chat_id"))
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)
startup_msg = _build_startup_message( startup_msg = _build_startup_message(
runtime, runtime,
startup_pwd=os.getcwd(), startup_pwd=os.getcwd(),
@@ -162,16 +121,17 @@ class TelegramBackend(TransportBackend):
presenter=presenter, presenter=presenter,
final_notify=final_notify, final_notify=final_notify,
) )
voice_transcription = _build_voice_transcription_config(transport_config) topics = _build_topics_config(transport_config)
topics = _build_topics_config(transport_config, config_path=config_path) files = _build_files_config(transport_config)
files = _build_files_config(transport_config, config_path=config_path)
cfg = TelegramBridgeConfig( cfg = TelegramBridgeConfig(
bot=bot, bot=bot,
runtime=runtime, runtime=runtime,
chat_id=chat_id, chat_id=chat_id,
startup_msg=startup_msg, startup_msg=startup_msg,
exec_cfg=exec_cfg, exec_cfg=exec_cfg,
voice_transcription=voice_transcription, voice_transcription=cast(
bool, transport_config.get("voice_transcription", False)
),
topics=topics, topics=topics,
files=files, files=files,
) )
@@ -179,7 +139,7 @@ class TelegramBackend(TransportBackend):
async def run_loop() -> None: async def run_loop() -> None:
await run_main_loop( await run_main_loop(
cfg, cfg,
watch_config=watch_enabled, watch_config=runtime.watch_config,
default_engine_override=default_engine_override, default_engine_override=default_engine_override,
transport_id=self.id, transport_id=self.id,
transport_config=transport_config, transport_config=transport_config,
File diff suppressed because it is too large Load Diff
+1 -5
View File
@@ -412,7 +412,6 @@ class OutboxOp:
execute: Callable[[], Awaitable[Any]] execute: Callable[[], Awaitable[Any]]
priority: int priority: int
queued_at: float queued_at: float
updated_at: float
chat_id: int | None chat_id: int | None
label: str | None = None label: str | None = None
done: anyio.Event = field(default_factory=anyio.Event) done: anyio.Event = field(default_factory=anyio.Event)
@@ -465,8 +464,6 @@ class TelegramOutbox:
if previous is not None: if previous is not None:
op.queued_at = previous.queued_at op.queued_at = previous.queued_at
previous.set_result(None) previous.set_result(None)
else:
op.queued_at = op.updated_at
self._pending[key] = op self._pending[key] = op
self._cond.notify() self._cond.notify()
if not wait: if not wait:
@@ -661,8 +658,7 @@ class TelegramClient:
request = OutboxOp( request = OutboxOp(
execute=execute, execute=execute,
priority=priority, priority=priority,
queued_at=0.0, queued_at=self._clock(),
updated_at=self._clock(),
chat_id=chat_id, chat_id=chat_id,
label=label, label=label,
) )
File diff suppressed because it is too large Load Diff
+140
View File
@@ -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
+16 -8
View File
@@ -8,6 +8,22 @@ import zipfile
from collections.abc import Sequence from collections.abc import Sequence
from pathlib import Path, PurePosixPath 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, ...]: def split_command_args(text: str) -> tuple[str, ...]:
if not text.strip(): if not text.strip():
@@ -22,14 +38,6 @@ def file_usage() -> str:
return "usage: `/file put <path>` or `/file get <path>`" 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]: def parse_file_command(args_text: str) -> tuple[str | None, str, str | None]:
tokens = split_command_args(args_text) tokens = split_command_args(args_text)
if not tokens: if not tokens:
+660
View File
@@ -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()
+38 -53
View File
@@ -22,14 +22,28 @@ from rich.table import Table
from ..backends import EngineBackend, SetupIssue from ..backends import EngineBackend, SetupIssue
from ..backends_helpers import install_issue from ..backends_helpers import install_issue
from ..config import ConfigError from ..config import (
from ..config_store import read_raw_toml, write_raw_toml ConfigError,
dump_toml,
ensure_table,
read_config,
write_config,
)
from ..engines import list_backends from ..engines import list_backends
from ..logging import suppress_logs from ..logging import suppress_logs
from ..settings import HOME_CONFIG_PATH, load_settings, require_telegram from ..settings import HOME_CONFIG_PATH, load_settings, require_telegram
from ..transports import SetupResult from ..transports import SetupResult
from .client import TelegramClient, TelegramRetryAfter from .client import TelegramClient, TelegramRetryAfter
__all__ = [
"ChatInfo",
"check_setup",
"interactive_setup",
"mask_token",
"get_bot_info",
"wait_for_chat",
]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ChatInfo: class ChatInfo:
@@ -110,49 +124,14 @@ def check_setup(
return SetupResult(issues=issues, config_path=config_path) return SetupResult(issues=issues, config_path=config_path)
def _mask_token(token: str) -> str: def mask_token(token: str) -> str:
token = token.strip() token = token.strip()
if len(token) <= 12: if len(token) <= 12:
return "*" * len(token) return "*" * len(token)
return f"{token[:9]}...{token[-5:]}" return f"{token[:9]}...{token[-5:]}"
def _toml_escape(value: str) -> str: async def get_bot_info(token: str) -> dict[str, Any] | None:
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:
bot = TelegramClient(token) bot = TelegramClient(token)
try: try:
for _ in range(3): for _ in range(3):
@@ -165,7 +144,7 @@ async def _get_bot_info(token: str) -> dict[str, Any] | None:
await bot.close() await bot.close()
async def _wait_for_chat(token: str) -> ChatInfo: async def wait_for_chat(token: str) -> ChatInfo:
bot = TelegramClient(token) bot = TelegramClient(token)
try: try:
offset: int | None = None 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") console.print(" token cannot be empty")
continue continue
console.print(" validating...") console.print(" validating...")
info = anyio.run(_get_bot_info, token) info = anyio.run(get_bot_info, token)
if info: if info:
username = info.get("username") username = info.get("username")
if isinstance(username, str) and 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") console.print(" token cannot be empty")
return None return None
console.print(" validating...") console.print(" validating...")
info = anyio.run(_get_bot_info, token) info = anyio.run(get_bot_info, token)
if not info: if not info:
console.print(" failed to connect, check the token and try again") console.print(" failed to connect, check the token and try again")
return None 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(f" send /start to {bot_ref} (works in groups too)")
console.print(" waiting...") console.print(" waiting...")
try: try:
chat = anyio.run(_wait_for_chat, token) chat = anyio.run(wait_for_chat, token)
except KeyboardInterrupt: except KeyboardInterrupt:
console.print(" cancelled") console.print(" cancelled")
return None 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(f" send /start to {bot_ref} (works in groups too)")
console.print(" waiting...") console.print(" waiting...")
try: try:
chat = anyio.run(_wait_for_chat, token) chat = anyio.run(wait_for_chat, token)
except KeyboardInterrupt: except KeyboardInterrupt:
console.print(" cancelled") console.print(" cancelled")
return False return False
@@ -461,11 +440,17 @@ def interactive_setup(*, force: bool) -> bool:
if not save_anyway: if not save_anyway:
return False return False
config_preview = _render_config( preview_config: dict[str, Any] = {}
_mask_token(token), if default_engine is not None:
chat.chat_id, preview_config["default_engine"] = default_engine
default_engine, preview_config["transport"] = "telegram"
).rstrip() 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("\nstep 3: save configuration\n")
console.print(f" {_display_path(config_path)}\n") console.print(f" {_display_path(config_path)}\n")
for line in config_preview.splitlines(): for line in config_preview.splitlines():
@@ -482,7 +467,7 @@ def interactive_setup(*, force: bool) -> bool:
raw_config: dict[str, Any] = {} raw_config: dict[str, Any] = {}
if config_path.exists(): if config_path.exists():
try: try:
raw_config = read_raw_toml(config_path) raw_config = read_config(config_path)
except ConfigError as exc: except ConfigError as exc:
console.print(f"[yellow]warning:[/] config is malformed: {exc}") console.print(f"[yellow]warning:[/] config is malformed: {exc}")
backup = config_path.with_suffix(".toml.bak") backup = config_path.with_suffix(".toml.bak")
@@ -499,8 +484,8 @@ def interactive_setup(*, force: bool) -> bool:
if default_engine is not None: if default_engine is not None:
merged["default_engine"] = default_engine merged["default_engine"] = default_engine
merged["transport"] = "telegram" merged["transport"] = "telegram"
transports = _ensure_table(merged, "transports", config_path=config_path) transports = ensure_table(merged, "transports", config_path=config_path)
telegram = _ensure_table( telegram = ensure_table(
transports, transports,
"telegram", "telegram",
config_path=config_path, config_path=config_path,
@@ -510,7 +495,7 @@ def interactive_setup(*, force: bool) -> bool:
telegram["chat_id"] = chat.chat_id telegram["chat_id"] = chat.chat_id
merged.pop("bot_token", None) merged.pop("bot_token", None)
merged.pop("chat_id", 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)}") console.print(f" config saved to {_display_path(config_path)}")
done_panel = Panel( done_panel = Panel(
+1 -20
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import json import json
import os import os
import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
@@ -26,8 +25,6 @@ class TopicThreadSnapshot:
context: RunContext | None context: RunContext | None
sessions: dict[str, str] sessions: dict[str, str]
topic_title: str | None topic_title: str | None
created_by_bot: bool | None
updated_at: float | None
def resolve_state_path(config_path: Path) -> Path: def resolve_state_path(config_path: Path) -> Path:
@@ -104,7 +101,6 @@ class TopicStateStore:
context: RunContext, context: RunContext,
*, *,
topic_title: str | None = None, topic_title: str | None = None,
created_by_bot: bool | None = None,
) -> None: ) -> None:
async with self._lock: async with self._lock:
self._reload_locked_if_needed() self._reload_locked_if_needed()
@@ -112,9 +108,6 @@ class TopicStateStore:
thread["context"] = _dump_context(context) thread["context"] = _dump_context(context)
if topic_title is not None: if topic_title is not None:
thread["topic_title"] = topic_title 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() self._save_locked()
async def clear_context(self, chat_id: int, thread_id: int) -> None: async def clear_context(self, chat_id: int, thread_id: int) -> None:
@@ -124,7 +117,6 @@ class TopicStateStore:
if thread is None: if thread is None:
return return
thread.pop("context", None) thread.pop("context", None)
thread["updated_at"] = time.time()
self._save_locked() self._save_locked()
async def get_session_resume( async def get_session_resume(
@@ -158,9 +150,7 @@ class TopicStateStore:
thread["sessions"] = sessions thread["sessions"] = sessions
sessions[token.engine] = { sessions[token.engine] = {
"resume": token.value, "resume": token.value,
"updated_at": time.time(),
} }
thread["updated_at"] = time.time()
self._save_locked() self._save_locked()
async def clear_sessions(self, chat_id: int, thread_id: int) -> None: async def clear_sessions(self, chat_id: int, thread_id: int) -> None:
@@ -170,7 +160,6 @@ class TopicStateStore:
if thread is None: if thread is None:
return return
thread.pop("sessions", None) thread.pop("sessions", None)
thread["updated_at"] = time.time()
self._save_locked() self._save_locked()
async def find_thread_for_context( async def find_thread_for_context(
@@ -210,23 +199,15 @@ class TopicStateStore:
value = entry.get("resume") value = entry.get("resume")
if isinstance(value, str) and value: if isinstance(value, str) and value:
sessions[engine] = 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") topic_title = thread.get("topic_title")
if not isinstance(topic_title, str): if not isinstance(topic_title, str):
topic_title = None topic_title = None
created_by_bot = thread.get("created_by_bot")
if not isinstance(created_by_bot, bool):
created_by_bot = None
return TopicThreadSnapshot( return TopicThreadSnapshot(
chat_id=chat_id, chat_id=chat_id,
thread_id=thread_id, thread_id=thread_id,
context=_parse_context(thread.get("context")), context=_parse_context(thread.get("context")),
sessions=sessions, sessions=sessions,
topic_title=topic_title, topic_title=topic_title,
created_by_bot=created_by_bot,
updated_at=updated_at,
) )
def _stat_mtime_ns(self) -> int | None: def _stat_mtime_ns(self) -> int | None:
@@ -302,6 +283,6 @@ class TopicStateStore:
entry = threads.get(key) entry = threads.get(key)
if isinstance(entry, dict): if isinstance(entry, dict):
return entry return entry
entry = {"chat_id": chat_id, "thread_id": thread_id} entry = {}
threads[key] = entry threads[key] = entry
return entry return entry
+234
View File
@@ -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."
)
-100
View File
@@ -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
+60
View File
@@ -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
+9
View File
@@ -45,6 +45,7 @@ class TransportRuntime:
"_allowlist", "_allowlist",
"_config_path", "_config_path",
"_plugin_configs", "_plugin_configs",
"_watch_config",
) )
def __init__( def __init__(
@@ -55,12 +56,14 @@ class TransportRuntime:
allowlist: Iterable[str] | None = None, allowlist: Iterable[str] | None = None,
config_path: Path | None = None, config_path: Path | None = None,
plugin_configs: Mapping[str, Any] | None = None, plugin_configs: Mapping[str, Any] | None = None,
watch_config: bool = False,
) -> None: ) -> None:
self._router = router self._router = router
self._projects = projects self._projects = projects
self._allowlist = normalize_allowlist(allowlist) self._allowlist = normalize_allowlist(allowlist)
self._config_path = config_path self._config_path = config_path
self._plugin_configs = dict(plugin_configs or {}) self._plugin_configs = dict(plugin_configs or {})
self._watch_config = watch_config
def update( def update(
self, self,
@@ -70,12 +73,14 @@ class TransportRuntime:
allowlist: Iterable[str] | None = None, allowlist: Iterable[str] | None = None,
config_path: Path | None = None, config_path: Path | None = None,
plugin_configs: Mapping[str, Any] | None = None, plugin_configs: Mapping[str, Any] | None = None,
watch_config: bool = False,
) -> None: ) -> None:
self._router = router self._router = router
self._projects = projects self._projects = projects
self._allowlist = normalize_allowlist(allowlist) self._allowlist = normalize_allowlist(allowlist)
self._config_path = config_path self._config_path = config_path
self._plugin_configs = dict(plugin_configs or {}) self._plugin_configs = dict(plugin_configs or {})
self._watch_config = watch_config
@property @property
def default_engine(self) -> EngineId: def default_engine(self) -> EngineId:
@@ -119,6 +124,10 @@ class TransportRuntime:
def config_path(self) -> Path | None: def config_path(self) -> Path | None:
return self._config_path return self._config_path
@property
def watch_config(self) -> bool:
return self._watch_config
def plugin_config(self, plugin_id: str) -> dict[str, Any]: def plugin_config(self, plugin_id: str) -> dict[str, Any]:
if not self._plugin_configs: if not self._plugin_configs:
return {} return {}
+28 -26
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import os import os
import signal import signal
from collections.abc import AsyncIterator, Sequence from collections.abc import AsyncIterator, Callable, Sequence
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any from typing import Any
@@ -21,45 +21,47 @@ async def wait_for_process(proc: Process, timeout: float) -> bool:
def terminate_process(proc: Process) -> None: def terminate_process(proc: Process) -> None:
if proc.returncode is not None: _signal_process(
return proc,
if os.name == "posix" and proc.pid is not None: signal.SIGTERM,
try: fallback=proc.terminate,
os.killpg(proc.pid, signal.SIGTERM) log_event="subprocess.terminate.failed",
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
def kill_process(proc: Process) -> None: 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: if proc.returncode is not None:
return return
if os.name == "posix" and proc.pid is not None: if os.name == "posix" and proc.pid is not None:
try: try:
os.killpg(proc.pid, signal.SIGKILL) os.killpg(proc.pid, sig)
return return
except ProcessLookupError: except ProcessLookupError:
return return
except Exception as e: except Exception as exc:
logger.debug( logger.debug(
"subprocess.kill.failed", log_event,
error=str(e), error=str(exc),
error_type=e.__class__.__name__, error_type=exc.__class__.__name__,
pid=proc.pid, pid=proc.pid,
) )
try: try:
proc.kill() fallback()
except ProcessLookupError: except ProcessLookupError:
return return
+1 -1
View File
@@ -45,7 +45,7 @@ def test_chat_id_command_uses_config_token(monkeypatch) -> None:
settings = TakopiSettings.model_validate( settings = TakopiSettings.model_validate(
{ {
"transport": "telegram", "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"))) monkeypatch.setattr(cli, "_load_settings_optional", lambda: (settings, Path("x")))
+10 -11
View File
@@ -4,38 +4,37 @@ from pathlib import Path
import pytest import pytest
from takopi.config import ConfigError from takopi.config import ConfigError, read_config, write_config
from takopi.config_store import read_raw_toml, write_raw_toml
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" config_path = tmp_path / "takopi.toml"
payload = { payload = {
"default_engine": "codex", "default_engine": "codex",
"projects": {"z80": {"path": "/tmp/repo"}}, "projects": {"z80": {"path": "/tmp/repo"}},
} }
write_raw_toml(payload, config_path) write_config(payload, config_path)
loaded = read_raw_toml(config_path) loaded = read_config(config_path)
assert loaded == payload 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" config_path = tmp_path / "missing.toml"
with pytest.raises(ConfigError, match="Missing config file"): 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 = tmp_path / "takopi.toml"
config_path.write_text("nope = [", encoding="utf-8") config_path.write_text("nope = [", encoding="utf-8")
with pytest.raises(ConfigError, match="Malformed TOML"): 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 = tmp_path / "config_dir"
config_path.mkdir() config_path.mkdir()
with pytest.raises(ConfigError, match="exists but is not a file"): with pytest.raises(ConfigError, match="exists but is not a file"):
read_raw_toml(config_path) read_config(config_path)
+18 -9
View File
@@ -4,8 +4,8 @@ import anyio
import pytest import pytest
import takopi.config_watch as config_watch import takopi.config_watch as config_watch
from takopi.config_watch import ConfigReload, _config_status, watch_config from takopi.config_watch import ConfigReload, config_status, watch_config
from takopi.config import empty_projects_config from takopi.config import ProjectsConfig
from takopi.router import AutoRouter, RunnerEntry from takopi.router import AutoRouter, RunnerEntry
from takopi.runtime_loader import RuntimeSpec from takopi.runtime_loader import RuntimeSpec
from takopi.runners.mock import Return, ScriptRunner 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: def test_config_status_variants(tmp_path: Path) -> None:
missing = tmp_path / "missing.toml" missing = tmp_path / "missing.toml"
status, signature = _config_status(missing) status, signature = config_status(missing)
assert status == "missing" assert status == "missing"
assert signature is None assert signature is None
directory = tmp_path / "config.d" directory = tmp_path / "config.d"
directory.mkdir() directory.mkdir()
status, signature = _config_status(directory) status, signature = config_status(directory)
assert status == "invalid" assert status == "invalid"
assert signature is None assert signature is None
config_file = tmp_path / "takopi.toml" config_file = tmp_path / "takopi.toml"
config_file.write_text('transport = "telegram"\n', encoding="utf-8") config_file.write_text(
status, signature = _config_status(config_file) '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 status == "ok"
assert signature is not None assert signature is not None
@@ -47,7 +51,7 @@ async def test_watch_config_applies_runtime(
) )
runtime = TransportRuntime( runtime = TransportRuntime(
router=router, router=router,
projects=empty_projects_config(), projects=ProjectsConfig(projects={}, default_project=None),
config_path=resolved_path, config_path=resolved_path,
) )
@@ -58,12 +62,17 @@ async def test_watch_config_applies_runtime(
) )
new_spec = RuntimeSpec( new_spec = RuntimeSpec(
router=new_router, router=new_router,
projects=empty_projects_config(), projects=ProjectsConfig(projects={}, default_project=None),
allowlist=None, allowlist=None,
plugin_configs=None, plugin_configs=None,
) )
reload = ConfigReload( 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, runtime_spec=new_spec,
config_path=resolved_path, config_path=resolved_path,
) )
+1 -1
View File
@@ -87,7 +87,7 @@ def test_require_telegram_rejects_empty_token(tmp_path) -> None:
encoding="utf-8", encoding="utf-8",
) )
with pytest.raises(ConfigError, match="bot token"): with pytest.raises(ConfigError, match="bot_token"):
settings, _ = load_settings(config_path) settings, _ = load_settings(config_path)
require_telegram(settings, config_path) require_telegram(settings, config_path)
+2 -2
View File
@@ -12,7 +12,7 @@ from takopi.model import (
StartedEvent, StartedEvent,
TakopiEvent, 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") 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: 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 @pytest.mark.anyio
+7 -2
View File
@@ -49,9 +49,13 @@ def test_check_setup_marks_missing_config(monkeypatch, tmp_path: Path) -> None:
assert result.config_path == onboarding.HOME_CONFIG_PATH 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") backend = engines.get_backend("codex")
monkeypatch.setattr(onboarding.shutil, "which", lambda _name: "/usr/bin/codex") monkeypatch.setattr(onboarding.shutil, "which", lambda _name: "/usr/bin/codex")
def _fail_require(*_args, **_kwargs):
raise onboarding.ConfigError("Missing bot token")
monkeypatch.setattr( monkeypatch.setattr(
onboarding, onboarding,
"load_settings", "load_settings",
@@ -59,12 +63,13 @@ def test_check_setup_marks_invalid_chat_id(monkeypatch, tmp_path: Path) -> None:
TakopiSettings.model_validate( TakopiSettings.model_validate(
{ {
"transport": "telegram", "transport": "telegram",
"transports": {"telegram": {"bot_token": "token", "chat_id": None}}, "transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
} }
), ),
tmp_path / "takopi.toml", tmp_path / "takopi.toml",
), ),
) )
monkeypatch.setattr(onboarding, "require_telegram", _fail_require)
result = onboarding.check_setup(backend) result = onboarding.check_setup(backend)
+25 -17
View File
@@ -1,26 +1,34 @@
from __future__ import annotations from __future__ import annotations
from takopi.config import dump_toml
from takopi.telegram import onboarding from takopi.telegram import onboarding
from takopi.backends import EngineBackend from takopi.backends import EngineBackend
def test_mask_token_short() -> None: def test_mask_token_short() -> None:
assert onboarding._mask_token("short") == "*****" assert onboarding.mask_token("short") == "*****"
def test_mask_token_long() -> None: def test_mask_token_long() -> None:
token = "123456789:ABCdefGH" token = "123456789:ABCdefGH"
masked = onboarding._mask_token(token) masked = onboarding.mask_token(token)
assert masked.startswith("123456789") assert masked.startswith("123456789")
assert masked.endswith("defGH") assert masked.endswith("defGH")
assert "..." in masked assert "..." in masked
def test_render_config_escapes() -> None: def test_render_config_escapes() -> None:
config = onboarding._render_config( config = dump_toml(
'token"with\\quote', {
123, "default_engine": "codex",
"codex", "transport": "telegram",
"transports": {
"telegram": {
"bot_token": 'token"with\\quote',
"chat_id": 123,
}
},
}
) )
assert 'default_engine = "codex"' in config assert 'default_engine = "codex"' in config
assert 'transport = "telegram"' 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"])) monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"]))
def _fake_run(func, *args, **kwargs): def _fake_run(func, *args, **kwargs):
if func is onboarding._get_bot_info: if func is onboarding.get_bot_info:
return {"username": "my_bot"} return {"username": "my_bot"}
if func is onboarding._wait_for_chat: if func is onboarding.wait_for_chat:
return onboarding.ChatInfo( return onboarding.ChatInfo(
chat_id=123, chat_id=123,
username="alice", username="alice",
@@ -127,9 +135,9 @@ def test_interactive_setup_preserves_projects(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"])) monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"]))
def _fake_run(func, *args, **kwargs): def _fake_run(func, *args, **kwargs):
if func is onboarding._get_bot_info: if func is onboarding.get_bot_info:
return {"username": "my_bot"} return {"username": "my_bot"}
if func is onboarding._wait_for_chat: if func is onboarding.wait_for_chat:
return onboarding.ChatInfo( return onboarding.ChatInfo(
chat_id=123, chat_id=123,
username="alice", username="alice",
@@ -164,9 +172,9 @@ def test_interactive_setup_no_agents_aborts(monkeypatch, tmp_path) -> None:
) )
def _fake_run(func, *args, **kwargs): def _fake_run(func, *args, **kwargs):
if func is onboarding._get_bot_info: if func is onboarding.get_bot_info:
return {"username": "my_bot"} return {"username": "my_bot"}
if func is onboarding._wait_for_chat: if func is onboarding.wait_for_chat:
return onboarding.ChatInfo( return onboarding.ChatInfo(
chat_id=123, chat_id=123,
username="alice", username="alice",
@@ -202,9 +210,9 @@ def test_interactive_setup_recovers_from_malformed_toml(monkeypatch, tmp_path) -
monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"])) monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"]))
def _fake_run(func, *args, **kwargs): def _fake_run(func, *args, **kwargs):
if func is onboarding._get_bot_info: if func is onboarding.get_bot_info:
return {"username": "my_bot"} return {"username": "my_bot"}
if func is onboarding._wait_for_chat: if func is onboarding.wait_for_chat:
return onboarding.ChatInfo( return onboarding.ChatInfo(
chat_id=123, chat_id=123,
username="alice", 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 test_capture_chat_id_with_token(monkeypatch) -> None:
def _fake_run(func, *args, **kwargs): def _fake_run(func, *args, **kwargs):
if func is onboarding._get_bot_info: if func is onboarding.get_bot_info:
return {"username": "my_bot"} return {"username": "my_bot"}
if func is onboarding._wait_for_chat: if func is onboarding.wait_for_chat:
return onboarding.ChatInfo( return onboarding.ChatInfo(
chat_id=456, chat_id=456,
username=None, username=None,
@@ -257,7 +265,7 @@ def test_capture_chat_id_prompts_for_token(monkeypatch) -> None:
) )
def _fake_run(func, *args, **kwargs): def _fake_run(func, *args, **kwargs):
if func is onboarding._wait_for_chat: if func is onboarding.wait_for_chat:
return onboarding.ChatInfo( return onboarding.ChatInfo(
chat_id=789, chat_id=789,
username="alice", username="alice",
+20 -7
View File
@@ -4,13 +4,16 @@ import pytest
from typer.testing import CliRunner from typer.testing import CliRunner
from takopi import cli from takopi import cli
from takopi.config import ConfigError from takopi.config import ConfigError, read_config
from takopi.config_store import read_raw_toml
from takopi.settings import TakopiSettings 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: 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"): with pytest.raises(ConfigError, match="aliases must not match engine ids"):
settings = TakopiSettings.model_validate(config) settings = TakopiSettings.model_validate(config)
settings.to_projects_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: 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"): with pytest.raises(ConfigError, match="default_project"):
settings = TakopiSettings.model_validate(config) settings = TakopiSettings.model_validate(config)
settings.to_projects_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: def test_init_writes_project(monkeypatch, tmp_path) -> None:
config_path = tmp_path / "takopi.toml" 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("takopi.config.HOME_CONFIG_PATH", config_path)
monkeypatch.setattr(cli, "resolve_default_base", lambda _: "main") monkeypatch.setattr(cli, "resolve_default_base", lambda _: "main")
monkeypatch.setattr(cli, "_load_settings_optional", lambda: (None, None)) 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"]) result = runner.invoke(cli.create_app(), ["init", "z80"])
assert result.exit_code == 0 assert result.exit_code == 0
raw = read_raw_toml(config_path) raw = read_config(config_path)
assert "bot_token" not in raw assert "bot_token" not in raw
assert "chat_id" not in raw assert "chat_id" not in raw
assert raw["transport"] == "telegram" 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: 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) settings = TakopiSettings.model_validate(config)
with pytest.raises(ConfigError, match="projects.z80.default_engine"): with pytest.raises(ConfigError, match="projects.z80.default_engine"):
settings.to_projects_config( 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: def test_projects_relative_path_resolves(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml" 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( projects = settings.to_projects_config(
config_path=config_path, config_path=config_path,
engine_ids=["codex"], engine_ids=["codex"],
+19 -3
View File
@@ -11,9 +11,19 @@ def test_build_runtime_spec_minimal(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None: ) -> None:
monkeypatch.setattr(runtime_loader.shutil, "which", lambda _cmd: "/bin/echo") 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 = 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( spec = runtime_loader.build_runtime_spec(
settings=settings, settings=settings,
@@ -23,10 +33,16 @@ def test_build_runtime_spec_minimal(
assert spec.router.default_engine == settings.default_engine assert spec.router.default_engine == settings.default_engine
runtime = spec.to_runtime(config_path=config_path) runtime = spec.to_runtime(config_path=config_path)
assert runtime.default_engine == settings.default_engine assert runtime.default_engine == settings.default_engine
assert runtime.watch_config is True
def test_resolve_default_engine_unknown(tmp_path: Path) -> None: 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"): with pytest.raises(ConfigError, match="Unknown default engine"):
runtime_loader.resolve_default_engine( runtime_loader.resolve_default_engine(
override="unknown", override="unknown",
+19 -15
View File
@@ -4,8 +4,7 @@ from pathlib import Path
import pytest import pytest
from takopi.config import ConfigError from takopi.config import ConfigError, read_config
from takopi.config_store import read_raw_toml
from takopi.settings import ( from takopi.settings import (
TakopiSettings, TakopiSettings,
load_settings, load_settings,
@@ -38,8 +37,7 @@ def test_load_settings_from_toml(tmp_path: Path) -> None:
assert token == "token" assert token == "token"
assert chat_id == 123 assert chat_id == 123
dumped = settings.model_dump() assert settings.transports.telegram.bot_token == "token"
assert dumped["transports"]["telegram"]["bot_token"] == "token"
def test_env_overrides_toml(tmp_path: Path, monkeypatch) -> None: 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 loaded_path == config_path
assert settings.transports.telegram.chat_id == 123 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 "bot_token" not in raw
assert "chat_id" not in raw assert "chat_id" not in raw
assert raw["transports"]["telegram"]["bot_token"] == "token" 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: def test_validate_settings_data_rejects_empty_default_project(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml" 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"): with pytest.raises(ConfigError, match="default_project"):
validate_settings_data(data, config_path=config_path) 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: def test_validate_settings_data_rejects_empty_project_path(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml" 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"): with pytest.raises(ConfigError, match="path"):
validate_settings_data(data, config_path=config_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) settings.transport_config("discord", config_path=config_path)
def test_bot_token_none_allowed() -> None: def test_bot_token_none_rejected(tmp_path: Path) -> None:
settings = TakopiSettings.model_validate( config_path = tmp_path / "takopi.toml"
{ data = {
"transport": "telegram", "transport": "telegram",
"transports": {"telegram": {"bot_token": None, "chat_id": 123}}, "transports": {"telegram": {"bot_token": None, "chat_id": 123}},
} }
) with pytest.raises(ConfigError, match="bot_token"):
assert settings.transports.telegram.bot_token is None validate_settings_data(data, config_path=config_path)
def test_require_telegram_rejects_non_telegram_transport(tmp_path: Path) -> None: def test_require_telegram_rejects_non_telegram_transport(tmp_path: Path) -> None:
+30
View File
@@ -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")
+18 -13
View File
@@ -5,7 +5,7 @@ from typing import Any
import pytest import pytest
from takopi.config import ConfigError, empty_projects_config from takopi.config import ProjectsConfig
from takopi.model import EngineId from takopi.model import EngineId
from takopi.router import AutoRouter, RunnerEntry from takopi.router import AutoRouter, RunnerEntry
from takopi.runners.mock import Return, ScriptRunner 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, 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( message = telegram_backend._build_startup_message(
runtime, startup_pwd=str(tmp_path) 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)], entries=[RunnerEntry(engine=codex, runner=runner, available=True)],
default_engine=codex, 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] = {} captured: dict[str, Any] = {}
@@ -91,8 +99,7 @@ def test_telegram_backend_build_and_run_wires_config(
cfg = captured["cfg"] cfg = captured["cfg"]
kwargs = captured["kwargs"] kwargs = captured["kwargs"]
assert cfg.chat_id == 321 assert cfg.chat_id == 321
assert cfg.voice_transcription is not None assert cfg.voice_transcription is True
assert cfg.voice_transcription.enabled is True
assert cfg.files.enabled is True assert cfg.files.enabled is True
assert cfg.files.allowed_user_ids == frozenset({1, 2}) assert cfg.files.allowed_user_ids == frozenset({1, 2})
assert cfg.topics.enabled is True assert cfg.topics.enabled is True
@@ -101,12 +108,10 @@ def test_telegram_backend_build_and_run_wires_config(
assert kwargs["transport_id"] == "telegram" assert kwargs["transport_id"] == "telegram"
def test_build_files_config_rejects_non_dict(tmp_path: Path) -> None: def test_build_files_config_defaults() -> None:
config_path = tmp_path / "takopi.toml" cfg = telegram_backend._build_files_config({})
transport_config: dict[str, object] = {"files": ["nope"]}
with pytest.raises(ConfigError, match="transports.telegram.files"): assert cfg.enabled is False
telegram_backend._build_files_config( assert cfg.auto_put is True
transport_config, assert cfg.uploads_dir == "incoming"
config_path=config_path, assert cfg.allowed_user_ids == frozenset()
)
+52 -47
View File
@@ -7,23 +7,26 @@ import pytest
from takopi import commands, plugins from takopi import commands, plugins
import takopi.telegram.bridge as bridge 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.directives import parse_directives
from takopi.telegram.bridge import ( from takopi.telegram.bridge import (
TelegramBridgeConfig, TelegramBridgeConfig,
TelegramFilesConfig, TelegramFilesConfig,
TelegramPresenter, TelegramPresenter,
TelegramTransport, TelegramTransport,
_build_bot_commands, build_bot_commands,
_handle_callback_cancel, handle_callback_cancel,
_handle_cancel, handle_cancel,
_is_cancel_command, is_cancel_command,
_send_with_resume, send_with_resume,
run_main_loop, run_main_loop,
) )
from takopi.telegram.client import BotClient from takopi.telegram.client import BotClient
from takopi.telegram.topic_state import TopicStateStore, resolve_state_path from takopi.telegram.topic_state import TopicStateStore, resolve_state_path
from takopi.context import RunContext 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.runner_bridge import ExecBridgeConfig, RunningTask
from takopi.markdown import MarkdownPresenter from takopi.markdown import MarkdownPresenter
from takopi.model import EngineId, ResumeToken from takopi.model import EngineId, ResumeToken
@@ -42,6 +45,10 @@ from tests.plugin_fixtures import FakeEntryPoint, install_entrypoints
CODEX_ENGINE = EngineId("codex") CODEX_ENGINE = EngineId("codex")
def _empty_projects() -> ProjectsConfig:
return ProjectsConfig(projects={}, default_project=None)
def _make_router(runner) -> AutoRouter: def _make_router(runner) -> AutoRouter:
return AutoRouter( return AutoRouter(
entries=[RunnerEntry(engine=runner.engine, runner=runner)], entries=[RunnerEntry(engine=runner.engine, runner=runner)],
@@ -288,7 +295,7 @@ def _make_cfg(
) )
runtime = TransportRuntime( runtime = TransportRuntime(
router=_make_router(runner), router=_make_router(runner),
projects=empty_projects_config(), projects=_empty_projects(),
) )
return TelegramBridgeConfig( return TelegramBridgeConfig(
bot=_FakeBot(), bot=_FakeBot(),
@@ -303,7 +310,7 @@ def test_parse_directives_inline_engine() -> None:
directives = parse_directives( directives = parse_directives(
"/claude do it", "/claude do it",
engine_ids=("codex", "claude"), engine_ids=("codex", "claude"),
projects=empty_projects_config(), projects=_empty_projects(),
) )
assert directives.engine == "claude" assert directives.engine == "claude"
assert directives.prompt == "do it" assert directives.prompt == "do it"
@@ -313,7 +320,7 @@ def test_parse_directives_newline() -> None:
directives = parse_directives( directives = parse_directives(
"/codex\nhello", "/codex\nhello",
engine_ids=("codex", "claude"), engine_ids=("codex", "claude"),
projects=empty_projects_config(), projects=_empty_projects(),
) )
assert directives.engine == "codex" assert directives.engine == "codex"
assert directives.prompt == "hello" assert directives.prompt == "hello"
@@ -323,7 +330,7 @@ def test_parse_directives_ignores_unknown() -> None:
directives = parse_directives( directives = parse_directives(
"/unknown hi", "/unknown hi",
engine_ids=("codex", "claude"), engine_ids=("codex", "claude"),
projects=empty_projects_config(), projects=_empty_projects(),
) )
assert directives.engine is None assert directives.engine is None
assert directives.prompt == "/unknown hi" assert directives.prompt == "/unknown hi"
@@ -333,7 +340,7 @@ def test_parse_directives_bot_suffix() -> None:
directives = parse_directives( directives = parse_directives(
"/claude@bunny_agent_bot hi", "/claude@bunny_agent_bot hi",
engine_ids=("claude",), engine_ids=("claude",),
projects=empty_projects_config(), projects=_empty_projects(),
) )
assert directives.engine == "claude" assert directives.engine == "claude"
assert directives.prompt == "hi" assert directives.prompt == "hi"
@@ -343,7 +350,7 @@ def test_parse_directives_only_first_non_empty_line() -> None:
directives = parse_directives( directives = parse_directives(
"hello\n/claude hi", "hello\n/claude hi",
engine_ids=("codex", "claude"), engine_ids=("codex", "claude"),
projects=empty_projects_config(), projects=_empty_projects(),
) )
assert directives.engine is None assert directives.engine is None
assert directives.prompt == "hello\n/claude hi" assert directives.prompt == "hello\n/claude hi"
@@ -355,9 +362,9 @@ def test_build_bot_commands_includes_cancel_and_engine() -> None:
) )
runtime = TransportRuntime( runtime = TransportRuntime(
router=_make_router(runner), 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": "cancel", "description": "cancel run"} in commands
assert {"command": "file", "description": "upload or fetch files"} 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) 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 any(cmd["command"] == "good" for cmd in commands)
assert not any(cmd["command"] == "bad-name" 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) runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
runtime = TransportRuntime( runtime = TransportRuntime(
router=_make_router(runner), 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 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) runtime = TransportRuntime(router=router, projects=projects)
commands = _build_bot_commands(runtime) commands = build_bot_commands(runtime)
assert len(commands) == 100 assert len(commands) == 100
assert any(cmd["command"] == "codex" for cmd in commands) 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 = {} running_tasks: dict = {}
await _handle_cancel(cfg, msg, running_tasks) await handle_cancel(cfg, msg, running_tasks)
assert len(transport.send_calls) == 1 assert len(transport.send_calls) == 1
assert "reply to the progress message" in transport.send_calls[0]["message"].text 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 = {} running_tasks: dict = {}
await _handle_cancel(cfg, msg, running_tasks) await handle_cancel(cfg, msg, running_tasks)
assert len(transport.send_calls) == 1 assert len(transport.send_calls) == 1
assert "nothing is currently running" in transport.send_calls[0]["message"].text 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 = {} running_tasks: dict = {}
await _handle_cancel(cfg, msg, running_tasks) await handle_cancel(cfg, msg, running_tasks)
assert len(transport.send_calls) == 1 assert len(transport.send_calls) == 1
assert "nothing is currently running" in transport.send_calls[0]["message"].text 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_task = RunningTask()
running_tasks = {MessageRef(channel_id=123, message_id=progress_id): running_task} 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 running_task.cancel_requested.is_set() is True
assert len(transport.send_calls) == 0 # No error message sent 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, 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_first.cancel_requested.is_set() is True
assert task_second.cancel_requested.is_set() is False 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" target = tmp_path / "uploads" / "hello.txt"
assert target.read_bytes() == payload assert target.read_bytes() == payload
@@ -883,7 +892,7 @@ async def test_handle_file_get_sends_document_for_allowed_user(
chat_type="supergroup", 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
assert bot.document_calls[0]["filename"] == "hello.txt" 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, 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 running_task.cancel_requested.is_set() is True
assert len(transport.send_calls) == 0 assert len(transport.send_calls) == 0
@@ -928,7 +937,7 @@ async def test_handle_callback_cancel_without_task_acknowledges() -> None:
sender_id=123, sender_id=123,
) )
await _handle_callback_cancel(cfg, query, {}) await handle_callback_cancel(cfg, query, {})
assert len(transport.send_calls) == 0 assert len(transport.send_calls) == 0
bot = cast(_FakeBot, cfg.bot) 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: def test_cancel_command_accepts_extra_text() -> None:
assert _is_cancel_command("/cancel now") is True assert is_cancel_command("/cancel now") is True
assert _is_cancel_command("/cancel@takopi please") is True assert is_cancel_command("/cancel@takopi please") is True
assert _is_cancel_command("/cancelled") is False assert is_cancel_command("/cancelled") is False
def test_resolve_message_accepts_backticked_ctx_line() -> None: def test_resolve_message_accepts_backticked_ctx_line() -> None:
@@ -971,24 +980,21 @@ def test_topic_title_matches_command_syntax() -> None:
transport = _FakeTransport() transport = _FakeTransport()
cfg = _make_cfg(transport) cfg = _make_cfg(transport)
title = bridge._topic_title( title = telegram_topics._topic_title(
cfg=cfg,
runtime=cfg.runtime, runtime=cfg.runtime,
context=RunContext(project="takopi", branch="master"), context=RunContext(project="takopi", branch="master"),
) )
assert title == "takopi @master" assert title == "takopi @master"
title = bridge._topic_title( title = telegram_topics._topic_title(
cfg=cfg,
runtime=cfg.runtime, runtime=cfg.runtime,
context=RunContext(project="takopi", branch=None), context=RunContext(project="takopi", branch=None),
) )
assert title == "takopi" assert title == "takopi"
title = bridge._topic_title( title = telegram_topics._topic_title(
cfg=cfg,
runtime=cfg.runtime, runtime=cfg.runtime,
context=RunContext(project=None, branch="main"), context=RunContext(project=None, branch="main"),
) )
@@ -1006,8 +1012,7 @@ def test_topic_title_projects_scope_includes_project() -> None:
), ),
) )
title = bridge._topic_title( title = telegram_topics._topic_title(
cfg=cfg,
runtime=cfg.runtime, runtime=cfg.runtime,
context=RunContext(project="takopi", branch="master"), 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", topic_title="takopi @old",
) )
await bridge._maybe_rename_topic( await telegram_topics._maybe_rename_topic(
cfg, cfg,
store, store,
chat_id=123, 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) snapshot = await store.get_thread(123, 77)
await bridge._maybe_rename_topic( await telegram_topics._maybe_rename_topic(
cfg, cfg,
store, store,
chat_id=123, 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: async with anyio.create_task_group() as tg:
tg.start_soon(trigger_resume) tg.start_soon(trigger_resume)
await _send_with_resume( await send_with_resume(
cfg, cfg,
enqueue, enqueue,
running_task, running_task,
@@ -1138,7 +1143,7 @@ async def test_send_with_resume_reports_when_missing() -> None:
running_task = RunningTask() running_task = RunningTask()
running_task.done.set() running_task.done.set()
await _send_with_resume( await send_with_resume(
cfg, cfg,
enqueue, enqueue,
running_task, running_task,
@@ -1175,7 +1180,7 @@ async def test_run_main_loop_routes_reply_to_running_resume() -> None:
) )
runtime = TransportRuntime( runtime = TransportRuntime(
router=_make_router(runner), router=_make_router(runner),
projects=empty_projects_config(), projects=_empty_projects(),
) )
cfg = TelegramBridgeConfig( cfg = TelegramBridgeConfig(
bot=bot, bot=bot,
@@ -1311,7 +1316,7 @@ async def test_run_main_loop_replies_in_same_thread() -> None:
) )
runtime = TransportRuntime( runtime = TransportRuntime(
router=_make_router(runner), router=_make_router(runner),
projects=empty_projects_config(), projects=_empty_projects(),
) )
cfg = TelegramBridgeConfig( cfg = TelegramBridgeConfig(
bot=bot, bot=bot,
@@ -1489,7 +1494,7 @@ async def test_run_main_loop_handles_command_plugins(monkeypatch) -> None:
) )
runtime = TransportRuntime( runtime = TransportRuntime(
router=_make_router(runner), router=_make_router(runner),
projects=empty_projects_config(), projects=_empty_projects(),
) )
cfg = TelegramBridgeConfig( cfg = TelegramBridgeConfig(
bot=bot, bot=bot,
@@ -1714,7 +1719,7 @@ async def test_run_main_loop_refreshes_command_ids(monkeypatch) -> None:
return [] return []
return ["late_cmd"] return ["late_cmd"]
monkeypatch.setattr(bridge, "list_command_ids", _list_command_ids) monkeypatch.setattr(telegram_loop, "list_command_ids", _list_command_ids)
transport = _FakeTransport() transport = _FakeTransport()
bot = _FakeBot() bot = _FakeBot()
@@ -1726,7 +1731,7 @@ async def test_run_main_loop_refreshes_command_ids(monkeypatch) -> None:
) )
runtime = TransportRuntime( runtime = TransportRuntime(
router=_make_router(runner), router=_make_router(runner),
projects=empty_projects_config(), projects=_empty_projects(),
) )
cfg = TelegramBridgeConfig( cfg = TelegramBridgeConfig(
bot=bot, bot=bot,
Generated
+84
View File
@@ -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" }, { 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]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" 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" }, { 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]] [[package]]
name = "lxml" name = "lxml"
version = "6.0.2" 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" }, { 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]] [[package]]
name = "packaging" name = "packaging"
version = "25.0" 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" }, { 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]] [[package]]
name = "structlog" name = "structlog"
version = "25.5.0" version = "25.5.0"
@@ -504,6 +574,7 @@ dependencies = [
{ name = "httpx" }, { name = "httpx" },
{ name = "markdown-it-py" }, { name = "markdown-it-py" },
{ name = "msgspec" }, { name = "msgspec" },
{ name = "openai" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "questionary" }, { name = "questionary" },
@@ -529,6 +600,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "markdown-it-py" }, { name = "markdown-it-py" },
{ name = "msgspec", specifier = ">=0.20.0" }, { name = "msgspec", specifier = ">=0.20.0" },
{ name = "openai", specifier = ">=2.15.0" },
{ name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic", specifier = ">=2.12.5" },
{ name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" },
{ name = "questionary", specifier = ">=2.1.1" }, { name = "questionary", specifier = ">=2.1.1" },
@@ -548,6 +620,18 @@ dev = [
{ name = "ty", specifier = ">=0.0.8" }, { 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]] [[package]]
name = "ty" name = "ty"
version = "0.0.8" version = "0.0.8"