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
CompletedEvent --> ResumeToken
note for Action "ActionKind: command | tool | file_change |\nweb_search | subagent | note | turn | warning"
note for Action "ActionKind: command | tool | file_change |\nweb_search | subagent | note | turn | warning | telemetry"
```
---
@@ -202,9 +202,9 @@ flowchart TD
C --> D{Engine?}
D -->|Claude| D1["claude --print --output-format stream-json<br/>[--resume id] prompt"]
D -->|Codex| D2["codex exec --output jsonl<br/>[--reconnect id] prompt"]
D -->|Pi| D3["pi --output jsonl<br/>[--session id] prompt"]
D -->|OpenCode| D4["opencode --output jsonl<br/>[--session id] prompt"]
D -->|Codex| D2["codex exec --json<br/>[resume &lt;token&gt;] -"]
D -->|Pi| D3["pi --print --mode json<br/>--session &lt;id&gt; &lt;prompt&gt;"]
D -->|OpenCode| D4["opencode run --format json<br/>[--session id] -- &lt;prompt&gt;"]
D1 --> E[Spawn Subprocess<br/>anyio.open_process]
D2 --> E
@@ -320,14 +320,14 @@ flowchart TD
flowchart LR
subgraph Config["~/.takopi/"]
toml[takopi.toml]
lock[.takopi.lock]
lock[takopi.lock]
end
subgraph toml_contents["takopi.toml"]
direction TB
global["transport<br/>default_engine<br/>default_project"]
telegram_cfg["[transports.telegram]<br/>bot_token = ...<br/>chat_id = ..."]
plugins_cfg["[plugins]<br/>enabled = [\"...\"]"]
plugins_cfg["[plugins]<br/>enabled = [...]"]
plugins_extra["[plugins.mycommand]<br/>setting = ..."]
claude_cfg["[claude]<br/>model = ..."]
codex_cfg["[codex]<br/>model = ..."]
+3 -5
View File
@@ -17,7 +17,7 @@ See `public-api.md` for the stable API surface you should depend on.
## Entrypoint groups
Takopi uses two Python entrypoint groups:
Takopi uses three Python entrypoint groups:
```toml
[project.entry-points."takopi.engine_backends"]
@@ -36,6 +36,7 @@ mycommand = "mycommand.backend:BACKEND"
- The entrypoint value must resolve to a **backend object**:
- Engine backend -> `EngineBackend`
- Transport backend -> `TransportBackend`
- Command backend -> `CommandBackend`
- The backend object **must** have `id == entrypoint name`.
Takopi validates this at load time and will report errors via `takopi plugins --load`.
@@ -76,15 +77,12 @@ Takopi supports a simple enabled list to control which plugins are visible.
```toml
[plugins]
enabled = ["takopi-transport-slack", "takopi-engine-acme"]
auto_install = false
```
- `enabled = []` (default) -> load all installed plugins.
- If `enabled` is non-empty, **only distributions with matching names** are visible.
- Distribution names are taken from package metadata (case-insensitive).
- If a plugin has no resolvable distribution name and an enabled list is set, it is hidden.
- `auto_install` is **reserved** and not implemented yet.
This enabled list affects:
- Engine subcommands registered in the CLI
@@ -289,7 +287,7 @@ Takopi exposes a **stable plugin API** via `takopi.api`.
- Depend on a compatible Takopi version range, for example:
```toml
dependencies = ["takopi>=0.11,<0.12"]
dependencies = ["takopi>=0.14,<0.15"]
```
When the plugin API changes, Takopi will bump the API version and document
+1 -1
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.:
```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
1. CLI emits JSON events.
1. Engine CLI emits JSONL events.
2. We render progress on every step and diff against the last output.
3. Only deltas enqueue a Telegram edit.
4. High-value messages enqueue a send.
@@ -91,7 +91,6 @@ Scheduling:
- Ordered by `(priority, queued_at)`.
- Priorities: send=0, delete=1, edit=2.
- Within a priority tier, the oldest pending op runs first.
- `updated_at` is kept for debugging only.
## Rate limiting + backoff
+1
View File
@@ -11,6 +11,7 @@ dependencies = [
"httpx>=0.28.1",
"markdown-it-py",
"msgspec>=0.20.0",
"openai>=2.15.0",
"pydantic>=2.12.5",
"pydantic-settings>=2.12.0",
"questionary>=2.1.1",
+12 -162
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import os
import shutil
import sys
from collections.abc import Callable
from importlib.metadata import EntryPoint
@@ -10,7 +9,6 @@ from pathlib import Path
import typer
from . import __version__
from .backends import EngineBackend
from .config import ConfigError, load_or_init_config, write_config
from .config_migrations import migrate_config
from .commands import get_command
@@ -18,7 +16,7 @@ from .engines import get_backend, list_backend_ids
from .ids import RESERVED_COMMAND_IDS, RESERVED_ENGINE_IDS
from .lockfile import LockError, LockHandle, acquire_lock, token_fingerprint
from .logging import get_logger, setup_logging
from .router import AutoRouter, RunnerEntry
from .runtime_loader import build_runtime_spec, resolve_plugins_allowlist
from .settings import (
TakopiSettings,
load_settings,
@@ -36,7 +34,6 @@ from .plugins import (
normalize_allowlist,
)
from .transports import SetupResult, get_transport
from .transport_runtime import TransportRuntime
from .utils.git import resolve_default_base, resolve_main_worktree_root
from .telegram import onboarding
@@ -53,19 +50,6 @@ def _load_settings_optional() -> tuple[TakopiSettings | None, Path | None]:
return loaded
def _resolve_plugins_allowlist(
settings: TakopiSettings | None,
) -> list[str] | None:
if settings is None:
return None
enabled = [
value.strip()
for value in settings.plugins.enabled
if isinstance(value, str) and value.strip()
]
return enabled or None
def _print_version_and_exit() -> None:
typer.echo(__version__)
raise typer.Exit()
@@ -128,115 +112,6 @@ def _default_engine_for_setup(
return value.strip()
def _resolve_default_engine(
*,
override: str | None,
settings: TakopiSettings,
config_path: Path,
engine_ids: list[str],
) -> str:
default_engine = override or settings.default_engine or "codex"
if not isinstance(default_engine, str) or not default_engine.strip():
raise ConfigError(
f"Invalid `default_engine` in {config_path}; expected a non-empty string."
)
default_engine = default_engine.strip()
if default_engine not in engine_ids:
available = ", ".join(sorted(engine_ids))
raise ConfigError(
f"Unknown default engine {default_engine!r}. Available: {available}."
)
return default_engine
def _build_router(
*,
settings: TakopiSettings,
config_path: Path,
backends: list[EngineBackend],
default_engine: str,
) -> AutoRouter:
entries: list[RunnerEntry] = []
warnings: list[str] = []
for backend in backends:
engine_id = backend.id
issue: str | None = None
engine_cfg: dict
try:
engine_cfg = settings.engine_config(engine_id, config_path=config_path)
except ConfigError as exc:
if engine_id == default_engine:
raise
issue = str(exc)
engine_cfg = {}
try:
runner = backend.build_runner(engine_cfg, config_path)
except Exception as exc:
if engine_id == default_engine:
raise
issue = issue or str(exc)
if engine_cfg:
try:
runner = backend.build_runner({}, config_path)
except Exception as fallback_exc:
warnings.append(f"{engine_id}: {issue or str(fallback_exc)}")
continue
else:
warnings.append(f"{engine_id}: {issue}")
continue
cmd = backend.cli_cmd or backend.id
if shutil.which(cmd) is None:
issue = issue or f"{cmd} not found on PATH"
if issue and engine_id == default_engine:
raise ConfigError(f"Default engine {engine_id!r} unavailable: {issue}")
available = issue is None
if issue and engine_id != default_engine:
warnings.append(f"{engine_id}: {issue}")
entries.append(
RunnerEntry(
engine=engine_id,
runner=runner,
available=available,
issue=issue,
)
)
for warning in warnings:
logger.warning("setup.warning", issue=warning)
return AutoRouter(entries=entries, default_engine=default_engine)
def _load_backends(
*,
engine_ids: list[str],
allowlist: list[str] | None,
default_engine: str,
) -> list[EngineBackend]:
backends: list[EngineBackend] = []
load_issues: list[str] = []
for engine_id in engine_ids:
try:
backend = get_backend(engine_id, allowlist=allowlist)
except ConfigError as exc:
if engine_id == default_engine:
raise
load_issues.append(f"{engine_id}: {exc}")
continue
backends.append(backend)
if not backends:
raise ConfigError("No engine backends are available.")
for issue in load_issues:
logger.warning("setup.warning", issue=issue)
return backends
def _config_path_display(path: Path) -> str:
home = Path.home()
try:
@@ -278,7 +153,7 @@ def _run_auto_router(
lock_handle: LockHandle | None = None
try:
settings_hint, config_hint = _load_settings_optional()
allowlist = _resolve_plugins_allowlist(settings_hint)
allowlist = resolve_plugins_allowlist(settings_hint)
default_engine = _default_engine_for_setup(
default_engine_override,
settings=settings_hint,
@@ -297,7 +172,7 @@ def _run_auto_router(
if not transport_backend.interactive_setup(force=True):
raise typer.Exit(code=1)
settings_hint, config_hint = _load_settings_optional()
allowlist = _resolve_plugins_allowlist(settings_hint)
allowlist = resolve_plugins_allowlist(settings_hint)
default_engine = _default_engine_for_setup(
default_engine_override,
settings=settings_hint,
@@ -319,7 +194,7 @@ def _run_auto_router(
)
if run_onboard and transport_backend.interactive_setup(force=True):
settings_hint, config_hint = _load_settings_optional()
allowlist = _resolve_plugins_allowlist(settings_hint)
allowlist = resolve_plugins_allowlist(settings_hint)
default_engine = _default_engine_for_setup(
default_engine_override,
settings=settings_hint,
@@ -332,7 +207,7 @@ def _run_auto_router(
)
elif transport_backend.interactive_setup(force=False):
settings_hint, config_hint = _load_settings_optional()
allowlist = _resolve_plugins_allowlist(settings_hint)
allowlist = resolve_plugins_allowlist(settings_hint)
default_engine = _default_engine_for_setup(
default_engine_override,
settings=settings_hint,
@@ -354,30 +229,12 @@ def _run_auto_router(
settings, config_path = load_settings()
if transport_override and transport_override != settings.transport:
settings = settings.model_copy(update={"transport": transport_override})
allowlist = _resolve_plugins_allowlist(settings)
engine_ids = list_backend_ids(allowlist=allowlist)
projects = settings.to_projects_config(
spec = build_runtime_spec(
settings=settings,
config_path=config_path,
engine_ids=engine_ids,
default_engine_override=default_engine_override,
reserved=("cancel",),
)
default_engine = _resolve_default_engine(
override=default_engine_override,
settings=settings,
config_path=config_path,
engine_ids=engine_ids,
)
backends = _load_backends(
engine_ids=engine_ids,
allowlist=allowlist,
default_engine=default_engine,
)
router = _build_router(
settings=settings,
config_path=config_path,
backends=backends,
default_engine=default_engine,
)
transport_config = settings.transport_config(
settings.transport, config_path=config_path
)
@@ -386,13 +243,7 @@ def _run_auto_router(
config_path=config_path,
)
lock_handle = acquire_config_lock(config_path, lock_token)
runtime = TransportRuntime(
router=router,
projects=projects,
allowlist=allowlist,
config_path=config_path,
plugin_configs=settings.plugins.model_extra,
)
runtime = spec.to_runtime(config_path=config_path)
transport_backend.build_and_run(
final_notify=final_notify,
default_engine_override=default_engine_override,
@@ -467,7 +318,7 @@ def init(
alias = _prompt_alias(alias, default_alias=default_alias)
settings = validate_settings_data(config, config_path=config_path)
allowlist = _resolve_plugins_allowlist(settings)
allowlist = resolve_plugins_allowlist(settings)
engine_ids = list_backend_ids(allowlist=allowlist)
projects_cfg = settings.to_projects_config(
config_path=config_path,
@@ -535,8 +386,7 @@ def chat_id(
settings, _ = _load_settings_optional()
if settings is not None:
tg = settings.transports.telegram
if tg.bot_token is not None:
token = tg.bot_token.get_secret_value().strip() or None
token = tg.bot_token or None
chat = onboarding.capture_chat_id(token=token)
if chat is None:
raise typer.Exit(code=1)
@@ -601,7 +451,7 @@ def plugins_cmd(
) -> None:
"""List discovered plugins and optionally validate them."""
settings_hint, _ = _load_settings_optional()
allowlist = _resolve_plugins_allowlist(settings_hint)
allowlist = resolve_plugins_allowlist(settings_hint)
allowlist_set = normalize_allowlist(allowlist)
engine_eps = list_entrypoints(
+23 -163
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
from typing import Any
HOME_CONFIG_PATH = Path.home() / ".takopi" / "takopi.toml"
@@ -12,7 +12,27 @@ class ConfigError(RuntimeError):
pass
def _read_config(cfg_path: Path) -> dict:
def ensure_table(
config: dict[str, Any],
key: str,
*,
config_path: Path,
label: str | None = None,
) -> dict[str, Any]:
value = config.get(key)
if value is None:
table: dict[str, Any] = {}
config[key] = table
return table
if not isinstance(value, dict):
name = label or key
raise ConfigError(f"Invalid `{name}` in {config_path}; expected a table.")
return value
def read_config(cfg_path: Path) -> dict:
if cfg_path.exists() and not cfg_path.is_file():
raise ConfigError(f"Config path {cfg_path} exists but is not a file.") from None
try:
raw = cfg_path.read_text(encoding="utf-8")
except FileNotFoundError:
@@ -31,7 +51,7 @@ def load_or_init_config(path: str | Path | None = None) -> tuple[dict, Path]:
raise ConfigError(f"Config path {cfg_path} exists but is not a file.") from None
if not cfg_path.exists():
return {}, cfg_path
return _read_config(cfg_path), cfg_path
return read_config(cfg_path), cfg_path
@dataclass(frozen=True, slots=True)
@@ -72,166 +92,6 @@ class ProjectsConfig:
return tuple(self.chat_map.keys())
def empty_projects_config() -> ProjectsConfig:
return ProjectsConfig(projects={}, default_project=None)
def _normalize_engine_id(
value: str,
*,
engine_ids: Iterable[str],
config_path: Path,
label: str,
) -> str:
engine_map = {engine.lower(): engine for engine in engine_ids}
cleaned = value.strip()
if not cleaned:
raise ConfigError(f"Invalid `{label}` in {config_path}; expected a string.")
engine = engine_map.get(cleaned.lower())
if engine is None:
available = ", ".join(sorted(engine_map.values()))
raise ConfigError(
f"Unknown `{label}` {cleaned!r} in {config_path}. Available: {available}."
)
return engine
def _normalize_project_path(value: str, *, config_path: Path) -> Path:
path = Path(value).expanduser()
if not path.is_absolute():
path = config_path.parent / path
return path
def parse_projects_config(
config: dict[str, Any],
*,
config_path: Path,
engine_ids: Iterable[str],
reserved: Iterable[str] = ("cancel",),
default_chat_id: int | None = None,
) -> ProjectsConfig:
default_project_raw = config.get("default_project")
default_project = None
if default_project_raw is not None:
if not isinstance(default_project_raw, str) or not default_project_raw.strip():
raise ConfigError(
f"Invalid `default_project` in {config_path}; expected a non-empty string."
)
default_project = default_project_raw.strip()
projects_raw = config.get("projects") or {}
if not isinstance(projects_raw, dict):
raise ConfigError(f"Invalid `projects` in {config_path}; expected a table.")
reserved_lower = {value.lower() for value in reserved}
engine_lower = {value.lower() for value in engine_ids}
projects: dict[str, ProjectConfig] = {}
chat_map: dict[int, str] = {}
for raw_alias, raw_entry in projects_raw.items():
if not isinstance(raw_alias, str) or not raw_alias.strip():
raise ConfigError(
f"Invalid project alias in {config_path}; expected a non-empty string."
)
alias = raw_alias.strip()
alias_key = alias.lower()
if alias_key in engine_lower or alias_key in reserved_lower:
raise ConfigError(
f"Invalid project alias {alias!r} in {config_path}; "
"aliases must not match engine ids or reserved commands."
)
if alias_key in projects:
raise ConfigError(f"Duplicate project alias {alias!r} in {config_path}.")
if not isinstance(raw_entry, dict):
raise ConfigError(
f"Invalid project entry for {alias!r} in {config_path}; expected a table."
)
path_value = raw_entry.get("path")
if not isinstance(path_value, str) or not path_value.strip():
raise ConfigError(f"Missing `path` for project {alias!r} in {config_path}.")
path = _normalize_project_path(path_value.strip(), config_path=config_path)
worktrees_dir_raw = raw_entry.get("worktrees_dir", ".worktrees")
if not isinstance(worktrees_dir_raw, str) or not worktrees_dir_raw.strip():
raise ConfigError(
f"Invalid `worktrees_dir` for project {alias!r} in {config_path}."
)
worktrees_dir = Path(worktrees_dir_raw.strip()).expanduser()
default_engine_raw = raw_entry.get("default_engine")
default_engine = None
if default_engine_raw is not None:
if not isinstance(default_engine_raw, str):
raise ConfigError(
f"Invalid `projects.{alias}.default_engine` in {config_path}; "
"expected a string."
)
default_engine = _normalize_engine_id(
default_engine_raw,
engine_ids=engine_ids,
config_path=config_path,
label=f"projects.{alias}.default_engine",
)
worktree_base_raw = raw_entry.get("worktree_base")
worktree_base = None
if worktree_base_raw is not None:
if not isinstance(worktree_base_raw, str) or not worktree_base_raw.strip():
raise ConfigError(
f"Invalid `projects.{alias}.worktree_base` in {config_path}; "
"expected a string."
)
worktree_base = worktree_base_raw.strip()
chat_id_raw = raw_entry.get("chat_id")
chat_id = None
if chat_id_raw is not None:
if isinstance(chat_id_raw, bool) or not isinstance(chat_id_raw, int):
raise ConfigError(
f"Invalid `projects.{alias}.chat_id` in {config_path}; "
"expected an integer."
)
chat_id = chat_id_raw
if default_chat_id is not None and chat_id == default_chat_id:
raise ConfigError(
f"Invalid `projects.{alias}.chat_id` in {config_path}; "
"must not match transports.telegram.chat_id."
)
if chat_id in chat_map:
existing = chat_map[chat_id]
raise ConfigError(
f"Duplicate `projects.*.chat_id` {chat_id} in {config_path}; "
f"already used by {existing!r}."
)
chat_map[chat_id] = alias_key
projects[alias_key] = ProjectConfig(
alias=alias,
path=path,
worktrees_dir=worktrees_dir,
default_engine=default_engine,
worktree_base=worktree_base,
chat_id=chat_id,
)
if default_project is not None:
default_key = default_project.lower()
if default_key not in projects:
raise ConfigError(
f"Invalid `default_project` {default_project!r} in {config_path}; "
"no matching project alias found."
)
default_project = default_key
return ProjectsConfig(
projects=projects,
default_project=default_project,
chat_map=chat_map,
)
def _toml_escape(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
+35 -36
View File
@@ -3,28 +3,24 @@ from __future__ import annotations
from pathlib import Path
from typing import Any
from .config import ConfigError
from .config_store import read_raw_toml, write_raw_toml
from .config import ConfigError, ensure_table, read_config, write_config
from .logging import get_logger
logger = get_logger(__name__)
def _ensure_table(
config: dict[str, Any],
def _ensure_subtable(
parent: dict[str, Any],
key: str,
*,
config_path: Path,
label: str | None = None,
) -> dict[str, Any]:
value = config.get(key)
label: str,
) -> dict[str, Any] | None:
value = parent.get(key)
if value is None:
table: dict[str, Any] = {}
config[key] = table
return table
return None
if not isinstance(value, dict):
name = label or key
raise ConfigError(f"Invalid `{name}` in {config_path}; expected a table.")
raise ConfigError(f"Invalid `{label}` in {config_path}; expected a table.")
return value
@@ -33,15 +29,13 @@ def _migrate_legacy_telegram(config: dict[str, Any], *, config_path: Path) -> bo
if not has_legacy:
return False
transports = _ensure_table(config, "transports", config_path=config_path)
telegram = transports.get("telegram")
if telegram is None:
telegram = {}
transports["telegram"] = telegram
if not isinstance(telegram, dict):
raise ConfigError(
f"Invalid `transports.telegram` in {config_path}; expected a table."
)
transports = ensure_table(config, "transports", config_path=config_path)
telegram = ensure_table(
transports,
"telegram",
config_path=config_path,
label="transports.telegram",
)
if "bot_token" in config and "bot_token" not in telegram:
telegram["bot_token"] = config["bot_token"]
@@ -55,27 +49,32 @@ def _migrate_legacy_telegram(config: dict[str, Any], *, config_path: Path) -> bo
def _migrate_topics_scope(config: dict[str, Any], *, config_path: Path) -> bool:
transports = config.get("transports")
transports = _ensure_subtable(
config,
"transports",
config_path=config_path,
label="transports",
)
if transports is None:
return False
if not isinstance(transports, dict):
raise ConfigError(f"Invalid `transports` in {config_path}; expected a table.")
telegram = transports.get("telegram")
telegram = _ensure_subtable(
transports,
"telegram",
config_path=config_path,
label="transports.telegram",
)
if telegram is None:
return False
if not isinstance(telegram, dict):
raise ConfigError(
f"Invalid `transports.telegram` in {config_path}; expected a table."
)
topics = telegram.get("topics")
topics = _ensure_subtable(
telegram,
"topics",
config_path=config_path,
label="transports.telegram.topics",
)
if topics is None:
return False
if not isinstance(topics, dict):
raise ConfigError(
f"Invalid `transports.telegram.topics` in {config_path}; expected a table."
)
if "mode" not in topics:
return False
@@ -112,10 +111,10 @@ def migrate_config(config: dict[str, Any], *, config_path: Path) -> list[str]:
def migrate_config_file(path: Path) -> list[str]:
config = read_raw_toml(path)
config = read_config(path)
applied = migrate_config(config, config_path=path)
if applied:
write_raw_toml(config, path)
write_config(config, path)
for migration in applied:
logger.info(
"config.migrated",
-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__)
__all__ = [
"ConfigReload",
"config_status",
"watch_config",
]
@dataclass(frozen=True, slots=True)
class ConfigReload:
@@ -22,7 +28,7 @@ class ConfigReload:
config_path: Path
def _config_status(path: Path) -> tuple[str, tuple[int, int] | None]:
def config_status(path: Path) -> tuple[str, tuple[int, int] | None]:
try:
stat = path.stat()
except FileNotFoundError:
@@ -64,7 +70,7 @@ async def watch_config(
reserved_tuple = tuple(reserved)
config_path = config_path.expanduser().resolve()
watch_root = config_path.parent
status, signature = _config_status(config_path)
status, signature = config_status(config_path)
last_status = status
if status != "ok":
logger.warning("config.watch.unavailable", path=str(config_path), status=status)
@@ -73,7 +79,7 @@ async def watch_config(
if not any(Path(path) == config_path for _, path in changes):
continue
status, current = _config_status(config_path)
status, current = config_status(config_path)
if status != "ok":
if status != last_status:
logger.warning(
@@ -123,6 +129,6 @@ async def watch_config(
error_type=exc.__class__.__name__,
)
_, signature = _config_status(config_path)
_, signature = config_status(config_path)
if signature is None:
signature = current
+9 -16
View File
@@ -41,8 +41,7 @@ class PluginNotFound(LookupError):
super().__init__(message)
_LOAD_ERRORS: list[PluginLoadError] = []
_LOAD_ERROR_KEYS: set[tuple[str, str, str, str | None, str]] = set()
_LOAD_ERRORS: dict[tuple[str, str, str, str | None, str], PluginLoadError] = {}
_LOADED: dict[tuple[str, str], Any] = {}
@@ -52,33 +51,27 @@ def _error_key(error: PluginLoadError) -> tuple[str, str, str, str | None, str]:
def _record_error(error: PluginLoadError) -> None:
key = _error_key(error)
if key in _LOAD_ERROR_KEYS:
return
_LOAD_ERROR_KEYS.add(key)
_LOAD_ERRORS.append(error)
_LOAD_ERRORS.setdefault(key, error)
def get_load_errors() -> tuple[PluginLoadError, ...]:
return tuple(_LOAD_ERRORS)
return tuple(_LOAD_ERRORS.values())
def clear_load_errors(*, group: str | None = None, name: str | None = None) -> None:
if group is None and name is None:
_LOAD_ERRORS.clear()
_LOAD_ERROR_KEYS.clear()
return
remaining: list[PluginLoadError] = []
_LOAD_ERROR_KEYS.clear()
for error in _LOAD_ERRORS:
remaining: dict[tuple[str, str, str, str | None, str], PluginLoadError] = {}
for key, error in _LOAD_ERRORS.items():
if group is not None and error.group != group:
remaining.append(error)
_LOAD_ERROR_KEYS.add(_error_key(error))
remaining[key] = error
continue
if name is not None and error.name != name:
remaining.append(error)
_LOAD_ERROR_KEYS.add(_error_key(error))
remaining[key] = error
continue
_LOAD_ERRORS[:] = remaining
_LOAD_ERRORS.clear()
_LOAD_ERRORS.update(remaining)
def reset_plugin_state() -> None:
+9 -2
View File
@@ -20,6 +20,13 @@ logger = get_logger(__name__)
ENGINE: EngineId = EngineId("codex")
__all__ = [
"ENGINE",
"CodexRunner",
"find_exec_only_flag",
"translate_codex_event",
]
_RESUME_RE = re.compile(r"(?im)^\s*`?codex\s+resume\s+(?P<token>[^`\s]+)`?\s*$")
_RECONNECTING_RE = re.compile(
r"^Reconnecting\.{3}\s*(?P<attempt>\d+)/(?P<max>\d+)\s*$",
@@ -40,7 +47,7 @@ _EXEC_ONLY_PREFIXES = (
)
def _find_exec_only_flag(extra_args: list[str]) -> str | None:
def find_exec_only_flag(extra_args: list[str]) -> str | None:
for arg in extra_args:
if arg in _EXEC_ONLY_FLAGS:
return arg
@@ -611,7 +618,7 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner:
f"Invalid `codex.extra_args` in {config_path}; expected a list of strings."
)
exec_only_flag = _find_exec_only_flag(extra_args)
exec_only_flag = find_exec_only_flag(extra_args)
if exec_only_flag:
raise ConfigError(
f"Invalid `codex.extra_args` in {config_path}; exec-only flag "
+5 -10
View File
@@ -22,6 +22,7 @@ class RuntimeSpec:
projects: ProjectsConfig
allowlist: list[str] | None
plugin_configs: Mapping[str, Any] | None
watch_config: bool = False
def to_runtime(self, *, config_path: Path | None) -> TransportRuntime:
return TransportRuntime(
@@ -30,6 +31,7 @@ class RuntimeSpec:
allowlist=self.allowlist,
config_path=config_path,
plugin_configs=self.plugin_configs,
watch_config=self.watch_config,
)
def apply(self, runtime: TransportRuntime, *, config_path: Path | None) -> None:
@@ -39,6 +41,7 @@ class RuntimeSpec:
allowlist=self.allowlist,
config_path=config_path,
plugin_configs=self.plugin_configs,
watch_config=self.watch_config,
)
@@ -47,11 +50,7 @@ def resolve_plugins_allowlist(
) -> list[str] | None:
if settings is None:
return None
enabled = [
value.strip()
for value in settings.plugins.enabled
if isinstance(value, str) and value.strip()
]
enabled = list(settings.plugins.enabled)
return enabled or None
@@ -63,11 +62,6 @@ def resolve_default_engine(
engine_ids: list[str],
) -> str:
default_engine = override or settings.default_engine or "codex"
if not isinstance(default_engine, str) or not default_engine.strip():
raise ConfigError(
f"Invalid `default_engine` in {config_path}; expected a non-empty string."
)
default_engine = default_engine.strip()
if default_engine not in engine_ids:
available = ", ".join(sorted(engine_ids))
raise ConfigError(
@@ -200,4 +194,5 @@ def build_runtime_spec(
projects=projects,
allowlist=allowlist,
plugin_configs=settings.plugins.model_extra,
watch_config=settings.watch_config,
)
+63 -213
View File
@@ -1,18 +1,18 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Iterable
from typing import Annotated, Any, Iterable, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
SecretStr,
ValidationError,
field_serializer,
StringConstraints,
field_validator,
model_validator,
)
from pydantic.types import StrictInt
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic_settings.sources import TomlConfigSettingsSource
@@ -21,39 +21,52 @@ from .config import (
HOME_CONFIG_PATH,
ProjectConfig,
ProjectsConfig,
_normalize_engine_id,
_normalize_project_path,
)
from .config_migrations import migrate_config_file
NonEmptyStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
def _normalize_engine_id(
value: str,
*,
engine_ids: Iterable[str],
config_path: Path,
label: str,
) -> str:
engine_map = {engine.lower(): engine for engine in engine_ids}
engine = engine_map.get(value.lower())
if engine is None:
available = ", ".join(sorted(engine_map.values()))
raise ConfigError(
f"Unknown `{label}` {value!r} in {config_path}. Available: {available}."
)
return engine
def _normalize_project_path(value: str, *, config_path: Path) -> Path:
path = Path(value).expanduser()
if not path.is_absolute():
path = config_path.parent / path
return path
class TelegramTopicsSettings(BaseModel):
model_config = ConfigDict(extra="forbid")
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
enabled: bool = False
scope: str = "auto"
@field_validator("scope", mode="before")
@classmethod
def _validate_scope(cls, value: Any) -> str:
if not isinstance(value, str):
raise ValueError("topics.scope must be a string")
cleaned = value.strip()
if cleaned not in {"auto", "main", "projects", "all"}:
raise ValueError(
"topics.scope must be 'auto', 'main', 'projects', or 'all'"
)
return cleaned
scope: Literal["auto", "main", "projects", "all"] = "auto"
class TelegramFilesSettings(BaseModel):
model_config = ConfigDict(extra="forbid")
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
enabled: bool = False
auto_put: bool = True
uploads_dir: str = "incoming"
allowed_user_ids: list[int] = Field(default_factory=list)
deny_globs: list[str] = Field(
uploads_dir: NonEmptyStr = "incoming"
allowed_user_ids: list[StrictInt] = Field(default_factory=list)
deny_globs: list[NonEmptyStr] = Field(
default_factory=lambda: [
".git/**",
".env",
@@ -63,81 +76,23 @@ class TelegramFilesSettings(BaseModel):
]
)
@field_validator("uploads_dir", mode="before")
@field_validator("uploads_dir")
@classmethod
def _validate_uploads_dir(cls, value: Any) -> Any:
if value is None:
raise ValueError("files.uploads_dir must be a string")
if not isinstance(value, str):
raise ValueError("files.uploads_dir must be a string")
cleaned = value.strip()
if not cleaned:
raise ValueError("files.uploads_dir must be a non-empty string")
if Path(cleaned).is_absolute():
def _validate_uploads_dir(cls, value: str) -> str:
if Path(value).is_absolute():
raise ValueError("files.uploads_dir must be a relative path")
return cleaned
@field_validator("allowed_user_ids", mode="before")
@classmethod
def _validate_allowed_users(cls, value: Any) -> Any:
if value is None:
return []
if not isinstance(value, list):
raise ValueError("files.allowed_user_ids must be a list of integers")
for item in value:
if isinstance(item, bool) or not isinstance(item, int):
raise ValueError("files.allowed_user_ids must be a list of integers")
return value
@field_validator("deny_globs", mode="before")
@classmethod
def _validate_deny_globs(cls, value: Any) -> Any:
if value is None:
return []
if not isinstance(value, list):
raise ValueError("files.deny_globs must be a list of strings")
cleaned: list[str] = []
for item in value:
if not isinstance(item, str):
raise ValueError("files.deny_globs must be a list of strings")
stripped = item.strip()
if not stripped:
raise ValueError("files.deny_globs entries must be non-empty strings")
cleaned.append(stripped)
return cleaned
class TelegramTransportSettings(BaseModel):
model_config = ConfigDict(extra="forbid")
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
bot_token: SecretStr | None = None
chat_id: int | None = None
bot_token: NonEmptyStr
chat_id: StrictInt
voice_transcription: bool = False
topics: TelegramTopicsSettings = Field(default_factory=TelegramTopicsSettings)
files: TelegramFilesSettings = Field(default_factory=TelegramFilesSettings)
@field_validator("bot_token", mode="before")
@classmethod
def _validate_bot_token(cls, value: Any) -> Any:
if value is None:
return None
if not isinstance(value, str):
raise ValueError("bot_token must be a string")
return value
@field_validator("chat_id", mode="before")
@classmethod
def _validate_chat_id(cls, value: Any) -> Any:
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError("chat_id must be an integer")
return value
@field_serializer("bot_token")
def _dump_token(self, value: SecretStr | None) -> str | None:
return value.get_secret_value() if value else None
class TransportsSettings(BaseModel):
telegram: TelegramTransportSettings = Field(
@@ -148,47 +103,19 @@ class TransportsSettings(BaseModel):
class PluginsSettings(BaseModel):
enabled: list[str] = Field(default_factory=list)
auto_install: bool = False
enabled: list[NonEmptyStr] = Field(default_factory=list)
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra="allow", str_strip_whitespace=True)
class ProjectSettings(BaseModel):
path: str
worktrees_dir: str = ".worktrees"
default_engine: str | None = None
worktree_base: str | None = None
chat_id: int | None = None
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
model_config = ConfigDict(extra="allow")
@field_validator(
"path",
"worktrees_dir",
"default_engine",
"worktree_base",
mode="before",
)
@classmethod
def _validate_strings(cls, value: Any, info) -> Any:
if value is None:
return None
if not isinstance(value, str):
raise ValueError(f"{info.field_name} must be a string")
cleaned = value.strip()
if not cleaned:
raise ValueError(f"{info.field_name} must be a non-empty string")
return cleaned
@field_validator("chat_id", mode="before")
@classmethod
def _validate_chat_id(cls, value: Any) -> Any:
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError("chat_id must be an integer")
return value
path: NonEmptyStr
worktrees_dir: NonEmptyStr = ".worktrees"
default_engine: NonEmptyStr | None = None
worktree_base: NonEmptyStr | None = None
chat_id: StrictInt | None = None
class TakopiSettings(BaseSettings):
@@ -196,14 +123,15 @@ class TakopiSettings(BaseSettings):
extra="allow",
env_prefix="TAKOPI__",
env_nested_delimiter="__",
str_strip_whitespace=True,
)
watch_config: bool = False
default_engine: str = "codex"
default_project: str | None = None
default_engine: NonEmptyStr = "codex"
default_project: NonEmptyStr | None = None
projects: dict[str, ProjectSettings] = Field(default_factory=dict)
transport: str = "telegram"
transport: NonEmptyStr = "telegram"
transports: TransportsSettings = Field(default_factory=TransportsSettings)
plugins: PluginsSettings = Field(default_factory=PluginsSettings)
@@ -218,30 +146,6 @@ class TakopiSettings(BaseSettings):
)
return data
@field_validator("default_engine", "transport", mode="before")
@classmethod
def _validate_required_strings(cls, value: Any, info) -> Any:
if value is None:
raise ValueError(f"{info.field_name} must be a non-empty string")
if not isinstance(value, str):
raise ValueError(f"{info.field_name} must be a string")
cleaned = value.strip()
if not cleaned:
raise ValueError(f"{info.field_name} must be a non-empty string")
return cleaned
@field_validator("default_project", mode="before")
@classmethod
def _validate_default_project(cls, value: Any) -> Any:
if value is None:
return None
if not isinstance(value, str):
raise ValueError("default_project must be a string")
cleaned = value.strip()
if not cleaned:
raise ValueError("default_project must be a non-empty string")
return cleaned
@classmethod
def settings_customise_sources(
cls,
@@ -302,11 +206,7 @@ class TakopiSettings(BaseSettings):
chat_map: dict[int, str] = {}
for raw_alias, entry in self.projects.items():
if not isinstance(raw_alias, str) or not raw_alias.strip():
raise ConfigError(
f"Invalid project alias in {config_path}; expected a non-empty string."
)
alias = raw_alias.strip()
alias = raw_alias
alias_key = alias.lower()
if alias_key in engine_map or alias_key in reserved_lower:
raise ConfigError(
@@ -318,56 +218,24 @@ class TakopiSettings(BaseSettings):
f"Duplicate project alias {alias!r} in {config_path}."
)
path_value = entry.path
if not isinstance(path_value, str) or not path_value.strip():
raise ConfigError(
f"Missing `path` for project {alias!r} in {config_path}."
)
path = _normalize_project_path(path_value.strip(), config_path=config_path)
path = _normalize_project_path(entry.path, config_path=config_path)
worktrees_dir_raw = entry.worktrees_dir
if not isinstance(worktrees_dir_raw, str) or not worktrees_dir_raw.strip():
raise ConfigError(
f"Invalid `worktrees_dir` for project {alias!r} in {config_path}."
)
worktrees_dir = Path(worktrees_dir_raw.strip()).expanduser()
worktrees_dir = Path(entry.worktrees_dir).expanduser()
default_engine_raw = entry.default_engine
default_engine = None
if default_engine_raw is not None:
if not isinstance(default_engine_raw, str):
raise ConfigError(
f"Invalid `projects.{alias}.default_engine` in {config_path}; "
"expected a string."
)
if entry.default_engine is not None:
default_engine = _normalize_engine_id(
default_engine_raw,
entry.default_engine,
engine_ids=engine_ids,
config_path=config_path,
label=f"projects.{alias}.default_engine",
)
worktree_base_raw = entry.worktree_base
worktree_base = None
if worktree_base_raw is not None:
if (
not isinstance(worktree_base_raw, str)
or not worktree_base_raw.strip()
):
raise ConfigError(
f"Invalid `projects.{alias}.worktree_base` in {config_path}; "
"expected a string."
)
worktree_base = worktree_base_raw.strip()
worktree_base = entry.worktree_base
chat_id = entry.chat_id
if chat_id is not None:
if isinstance(chat_id, bool) or not isinstance(chat_id, int):
raise ConfigError(
f"Invalid `projects.{alias}.chat_id` in {config_path}; "
"expected an integer."
)
if default_chat_id is not None and chat_id == default_chat_id:
if chat_id == default_chat_id:
raise ConfigError(
f"Invalid `projects.{alias}.chat_id` in {config_path}; "
"must not match transports.telegram.chat_id."
@@ -442,27 +310,9 @@ def require_telegram(settings: TakopiSettings, config_path: Path) -> tuple[str,
"(telegram only for now)."
)
tg = settings.transports.telegram
if tg.bot_token is None or not tg.bot_token.get_secret_value().strip():
if not tg.bot_token:
raise ConfigError(f"Missing bot token in {config_path}.")
if tg.chat_id is None:
raise ConfigError(f"Missing chat_id in {config_path}.")
if isinstance(tg.chat_id, bool) or not isinstance(tg.chat_id, int):
raise ConfigError(f"Invalid `chat_id` in {config_path}; expected an integer.")
return tg.bot_token.get_secret_value().strip(), tg.chat_id
def require_telegram_config(
config: dict[str, object], config_path: Path
) -> tuple[str, int]:
raw_token = config.get("bot_token")
if raw_token is None or not isinstance(raw_token, str) or not raw_token.strip():
raise ConfigError(f"Missing bot token in {config_path}.")
raw_chat_id = config.get("chat_id")
if raw_chat_id is None:
raise ConfigError(f"Missing chat_id in {config_path}.")
if isinstance(raw_chat_id, bool) or not isinstance(raw_chat_id, int):
raise ConfigError(f"Invalid `chat_id` in {config_path}; expected an integer.")
return raw_token.strip(), raw_chat_id
return tg.bot_token, tg.chat_id
def _resolve_config_path(path: str | Path | None) -> Path:
+31 -71
View File
@@ -2,21 +2,14 @@ from __future__ import annotations
import os
from pathlib import Path
from typing import cast
import anyio
from ..backends import EngineBackend
from ..runner_bridge import ExecBridgeConfig
from ..config import ConfigError
from ..logging import get_logger
from pydantic import ValidationError
from ..settings import (
TelegramFilesSettings,
TelegramTopicsSettings,
load_settings,
require_telegram_config,
)
from ..transports import SetupResult, TransportBackend
from ..transport_runtime import TransportRuntime
from .bridge import (
@@ -25,7 +18,6 @@ from .bridge import (
TelegramTransport,
TelegramFilesConfig,
TelegramTopicsConfig,
TelegramVoiceTranscriptionConfig,
run_main_loop,
)
from .client import TelegramClient
@@ -57,54 +49,31 @@ def _build_startup_message(
)
def _build_voice_transcription_config(
transport_config: dict[str, object],
) -> TelegramVoiceTranscriptionConfig:
return TelegramVoiceTranscriptionConfig(
enabled=bool(transport_config.get("voice_transcription", False)),
)
def _build_topics_config(
transport_config: dict[str, object],
*,
config_path: Path,
) -> TelegramTopicsConfig:
raw = transport_config.get("topics") or {}
if not isinstance(raw, dict):
raise ConfigError(
f"Invalid `transports.telegram.topics` in {config_path}; expected a table."
)
try:
settings = TelegramTopicsSettings.model_validate(raw)
except ValidationError as exc:
raise ConfigError(f"Invalid topics config in {config_path}: {exc}") from exc
def _build_topics_config(transport_config: dict[str, object]) -> TelegramTopicsConfig:
raw = cast(dict[str, object], transport_config.get("topics", {}))
return TelegramTopicsConfig(
enabled=settings.enabled,
scope=settings.scope,
enabled=cast(bool, raw.get("enabled", False)),
scope=cast(str, raw.get("scope", "auto")),
)
def _build_files_config(
transport_config: dict[str, object],
*,
config_path: Path,
) -> TelegramFilesConfig:
raw = transport_config.get("files") or {}
if not isinstance(raw, dict):
raise ConfigError(
f"Invalid `transports.telegram.files` in {config_path}; expected a table."
)
try:
settings = TelegramFilesSettings.model_validate(raw)
except ValidationError as exc:
raise ConfigError(f"Invalid files config in {config_path}: {exc}") from exc
def _build_files_config(transport_config: dict[str, object]) -> TelegramFilesConfig:
defaults = TelegramFilesConfig()
raw = cast(dict[str, object], transport_config.get("files", {}))
return TelegramFilesConfig(
enabled=settings.enabled,
auto_put=settings.auto_put,
uploads_dir=settings.uploads_dir,
allowed_user_ids=frozenset(settings.allowed_user_ids),
deny_globs=tuple(settings.deny_globs),
enabled=cast(bool, raw.get("enabled", defaults.enabled)),
auto_put=cast(bool, raw.get("auto_put", defaults.auto_put)),
uploads_dir=cast(str, raw.get("uploads_dir", defaults.uploads_dir)),
max_upload_bytes=defaults.max_upload_bytes,
max_download_bytes=defaults.max_download_bytes,
allowed_user_ids=frozenset(
cast(
list[int], raw.get("allowed_user_ids", list(defaults.allowed_user_ids))
)
),
deny_globs=tuple(
cast(list[str], raw.get("deny_globs", list(defaults.deny_globs)))
),
)
@@ -126,8 +95,8 @@ class TelegramBackend(TransportBackend):
def lock_token(
self, *, transport_config: dict[str, object], config_path: Path
) -> str | None:
token, _ = require_telegram_config(transport_config, config_path)
return token
_ = config_path
return cast(str, transport_config.get("bot_token"))
def build_and_run(
self,
@@ -138,18 +107,8 @@ class TelegramBackend(TransportBackend):
final_notify: bool,
default_engine_override: str | None,
) -> None:
watch_enabled = False
try:
settings, _ = load_settings(config_path)
except ConfigError as exc:
logger.warning(
"config.watch.disabled",
error=str(exc),
)
else:
watch_enabled = settings.watch_config
token, chat_id = require_telegram_config(transport_config, config_path)
token = cast(str, transport_config.get("bot_token"))
chat_id = cast(int, transport_config.get("chat_id"))
startup_msg = _build_startup_message(
runtime,
startup_pwd=os.getcwd(),
@@ -162,16 +121,17 @@ class TelegramBackend(TransportBackend):
presenter=presenter,
final_notify=final_notify,
)
voice_transcription = _build_voice_transcription_config(transport_config)
topics = _build_topics_config(transport_config, config_path=config_path)
files = _build_files_config(transport_config, config_path=config_path)
topics = _build_topics_config(transport_config)
files = _build_files_config(transport_config)
cfg = TelegramBridgeConfig(
bot=bot,
runtime=runtime,
chat_id=chat_id,
startup_msg=startup_msg,
exec_cfg=exec_cfg,
voice_transcription=voice_transcription,
voice_transcription=cast(
bool, transport_config.get("voice_transcription", False)
),
topics=topics,
files=files,
)
@@ -179,7 +139,7 @@ class TelegramBackend(TransportBackend):
async def run_loop() -> None:
await run_main_loop(
cfg,
watch_config=watch_enabled,
watch_config=runtime.watch_config,
default_engine_override=default_engine_override,
transport_id=self.id,
transport_config=transport_config,
File diff suppressed because it is too large Load Diff
+1 -5
View File
@@ -412,7 +412,6 @@ class OutboxOp:
execute: Callable[[], Awaitable[Any]]
priority: int
queued_at: float
updated_at: float
chat_id: int | None
label: str | None = None
done: anyio.Event = field(default_factory=anyio.Event)
@@ -465,8 +464,6 @@ class TelegramOutbox:
if previous is not None:
op.queued_at = previous.queued_at
previous.set_result(None)
else:
op.queued_at = op.updated_at
self._pending[key] = op
self._cond.notify()
if not wait:
@@ -661,8 +658,7 @@ class TelegramClient:
request = OutboxOp(
execute=execute,
priority=priority,
queued_at=0.0,
updated_at=self._clock(),
queued_at=self._clock(),
chat_id=chat_id,
label=label,
)
File diff suppressed because it is too large Load Diff
+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 pathlib import Path, PurePosixPath
__all__ = [
"ZipTooLargeError",
"default_upload_name",
"default_upload_path",
"deny_reason",
"file_usage",
"format_bytes",
"normalize_relative_path",
"parse_file_command",
"parse_file_prompt",
"resolve_path_within_root",
"split_command_args",
"write_bytes_atomic",
"zip_directory",
]
def split_command_args(text: str) -> tuple[str, ...]:
if not text.strip():
@@ -22,14 +38,6 @@ def file_usage() -> str:
return "usage: `/file put <path>` or `/file get <path>`"
def file_put_usage() -> str:
return "usage: `/file put <path>`"
def file_get_usage() -> str:
return "usage: `/file get <path>`"
def parse_file_command(args_text: str) -> tuple[str | None, str, str | None]:
tokens = split_command_args(args_text)
if not tokens:
+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_helpers import install_issue
from ..config import ConfigError
from ..config_store import read_raw_toml, write_raw_toml
from ..config import (
ConfigError,
dump_toml,
ensure_table,
read_config,
write_config,
)
from ..engines import list_backends
from ..logging import suppress_logs
from ..settings import HOME_CONFIG_PATH, load_settings, require_telegram
from ..transports import SetupResult
from .client import TelegramClient, TelegramRetryAfter
__all__ = [
"ChatInfo",
"check_setup",
"interactive_setup",
"mask_token",
"get_bot_info",
"wait_for_chat",
]
@dataclass(frozen=True, slots=True)
class ChatInfo:
@@ -110,49 +124,14 @@ def check_setup(
return SetupResult(issues=issues, config_path=config_path)
def _mask_token(token: str) -> str:
def mask_token(token: str) -> str:
token = token.strip()
if len(token) <= 12:
return "*" * len(token)
return f"{token[:9]}...{token[-5:]}"
def _toml_escape(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
def _render_config(token: str, chat_id: int, default_engine: str | None) -> str:
lines: list[str] = []
if default_engine:
lines.append(f'default_engine = "{_toml_escape(default_engine)}"')
lines.append("")
lines.append('transport = "telegram"')
lines.append("")
lines.append("[transports.telegram]")
lines.append(f'bot_token = "{_toml_escape(token)}"')
lines.append(f"chat_id = {chat_id}")
return "\n".join(lines) + "\n"
def _ensure_table(
config: dict[str, Any],
key: str,
*,
config_path: Path,
label: str | None = None,
) -> dict[str, Any]:
value = config.get(key)
if value is None:
table: dict[str, Any] = {}
config[key] = table
return table
if not isinstance(value, dict):
name = label or key
raise ConfigError(f"Invalid `{name}` in {config_path}; expected a table.")
return value
async def _get_bot_info(token: str) -> dict[str, Any] | None:
async def get_bot_info(token: str) -> dict[str, Any] | None:
bot = TelegramClient(token)
try:
for _ in range(3):
@@ -165,7 +144,7 @@ async def _get_bot_info(token: str) -> dict[str, Any] | None:
await bot.close()
async def _wait_for_chat(token: str) -> ChatInfo:
async def wait_for_chat(token: str) -> ChatInfo:
bot = TelegramClient(token)
try:
offset: int | None = None
@@ -329,7 +308,7 @@ def _prompt_token(console: Console) -> tuple[str, dict[str, Any]] | None:
console.print(" token cannot be empty")
continue
console.print(" validating...")
info = anyio.run(_get_bot_info, token)
info = anyio.run(get_bot_info, token)
if info:
username = info.get("username")
if isinstance(username, str) and username:
@@ -353,7 +332,7 @@ def capture_chat_id(*, token: str | None = None) -> ChatInfo | None:
console.print(" token cannot be empty")
return None
console.print(" validating...")
info = anyio.run(_get_bot_info, token)
info = anyio.run(get_bot_info, token)
if not info:
console.print(" failed to connect, check the token and try again")
return None
@@ -368,7 +347,7 @@ def capture_chat_id(*, token: str | None = None) -> ChatInfo | None:
console.print(f" send /start to {bot_ref} (works in groups too)")
console.print(" waiting...")
try:
chat = anyio.run(_wait_for_chat, token)
chat = anyio.run(wait_for_chat, token)
except KeyboardInterrupt:
console.print(" cancelled")
return None
@@ -428,7 +407,7 @@ def interactive_setup(*, force: bool) -> bool:
console.print(f" send /start to {bot_ref} (works in groups too)")
console.print(" waiting...")
try:
chat = anyio.run(_wait_for_chat, token)
chat = anyio.run(wait_for_chat, token)
except KeyboardInterrupt:
console.print(" cancelled")
return False
@@ -461,11 +440,17 @@ def interactive_setup(*, force: bool) -> bool:
if not save_anyway:
return False
config_preview = _render_config(
_mask_token(token),
chat.chat_id,
default_engine,
).rstrip()
preview_config: dict[str, Any] = {}
if default_engine is not None:
preview_config["default_engine"] = default_engine
preview_config["transport"] = "telegram"
preview_config["transports"] = {
"telegram": {
"bot_token": mask_token(token),
"chat_id": chat.chat_id,
}
}
config_preview = dump_toml(preview_config).rstrip()
console.print("\nstep 3: save configuration\n")
console.print(f" {_display_path(config_path)}\n")
for line in config_preview.splitlines():
@@ -482,7 +467,7 @@ def interactive_setup(*, force: bool) -> bool:
raw_config: dict[str, Any] = {}
if config_path.exists():
try:
raw_config = read_raw_toml(config_path)
raw_config = read_config(config_path)
except ConfigError as exc:
console.print(f"[yellow]warning:[/] config is malformed: {exc}")
backup = config_path.with_suffix(".toml.bak")
@@ -499,8 +484,8 @@ def interactive_setup(*, force: bool) -> bool:
if default_engine is not None:
merged["default_engine"] = default_engine
merged["transport"] = "telegram"
transports = _ensure_table(merged, "transports", config_path=config_path)
telegram = _ensure_table(
transports = ensure_table(merged, "transports", config_path=config_path)
telegram = ensure_table(
transports,
"telegram",
config_path=config_path,
@@ -510,7 +495,7 @@ def interactive_setup(*, force: bool) -> bool:
telegram["chat_id"] = chat.chat_id
merged.pop("bot_token", None)
merged.pop("chat_id", None)
write_raw_toml(merged, config_path)
write_config(merged, config_path)
console.print(f" config saved to {_display_path(config_path)}")
done_panel = Panel(
+1 -20
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import json
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
@@ -26,8 +25,6 @@ class TopicThreadSnapshot:
context: RunContext | None
sessions: dict[str, str]
topic_title: str | None
created_by_bot: bool | None
updated_at: float | None
def resolve_state_path(config_path: Path) -> Path:
@@ -104,7 +101,6 @@ class TopicStateStore:
context: RunContext,
*,
topic_title: str | None = None,
created_by_bot: bool | None = None,
) -> None:
async with self._lock:
self._reload_locked_if_needed()
@@ -112,9 +108,6 @@ class TopicStateStore:
thread["context"] = _dump_context(context)
if topic_title is not None:
thread["topic_title"] = topic_title
if created_by_bot is not None:
thread["created_by_bot"] = created_by_bot
thread["updated_at"] = time.time()
self._save_locked()
async def clear_context(self, chat_id: int, thread_id: int) -> None:
@@ -124,7 +117,6 @@ class TopicStateStore:
if thread is None:
return
thread.pop("context", None)
thread["updated_at"] = time.time()
self._save_locked()
async def get_session_resume(
@@ -158,9 +150,7 @@ class TopicStateStore:
thread["sessions"] = sessions
sessions[token.engine] = {
"resume": token.value,
"updated_at": time.time(),
}
thread["updated_at"] = time.time()
self._save_locked()
async def clear_sessions(self, chat_id: int, thread_id: int) -> None:
@@ -170,7 +160,6 @@ class TopicStateStore:
if thread is None:
return
thread.pop("sessions", None)
thread["updated_at"] = time.time()
self._save_locked()
async def find_thread_for_context(
@@ -210,23 +199,15 @@ class TopicStateStore:
value = entry.get("resume")
if isinstance(value, str) and value:
sessions[engine] = value
updated_at = thread.get("updated_at")
if not isinstance(updated_at, (int, float)):
updated_at = None
topic_title = thread.get("topic_title")
if not isinstance(topic_title, str):
topic_title = None
created_by_bot = thread.get("created_by_bot")
if not isinstance(created_by_bot, bool):
created_by_bot = None
return TopicThreadSnapshot(
chat_id=chat_id,
thread_id=thread_id,
context=_parse_context(thread.get("context")),
sessions=sessions,
topic_title=topic_title,
created_by_bot=created_by_bot,
updated_at=updated_at,
)
def _stat_mtime_ns(self) -> int | None:
@@ -302,6 +283,6 @@ class TopicStateStore:
entry = threads.get(key)
if isinstance(entry, dict):
return entry
entry = {"chat_id": chat_id, "thread_id": thread_id}
entry = {}
threads[key] = entry
return entry
+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",
"_config_path",
"_plugin_configs",
"_watch_config",
)
def __init__(
@@ -55,12 +56,14 @@ class TransportRuntime:
allowlist: Iterable[str] | None = None,
config_path: Path | None = None,
plugin_configs: Mapping[str, Any] | None = None,
watch_config: bool = False,
) -> None:
self._router = router
self._projects = projects
self._allowlist = normalize_allowlist(allowlist)
self._config_path = config_path
self._plugin_configs = dict(plugin_configs or {})
self._watch_config = watch_config
def update(
self,
@@ -70,12 +73,14 @@ class TransportRuntime:
allowlist: Iterable[str] | None = None,
config_path: Path | None = None,
plugin_configs: Mapping[str, Any] | None = None,
watch_config: bool = False,
) -> None:
self._router = router
self._projects = projects
self._allowlist = normalize_allowlist(allowlist)
self._config_path = config_path
self._plugin_configs = dict(plugin_configs or {})
self._watch_config = watch_config
@property
def default_engine(self) -> EngineId:
@@ -119,6 +124,10 @@ class TransportRuntime:
def config_path(self) -> Path | None:
return self._config_path
@property
def watch_config(self) -> bool:
return self._watch_config
def plugin_config(self, plugin_id: str) -> dict[str, Any]:
if not self._plugin_configs:
return {}
+28 -26
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import os
import signal
from collections.abc import AsyncIterator, Sequence
from collections.abc import AsyncIterator, Callable, Sequence
from contextlib import asynccontextmanager
from typing import Any
@@ -21,45 +21,47 @@ async def wait_for_process(proc: Process, timeout: float) -> bool:
def terminate_process(proc: Process) -> None:
if proc.returncode is not None:
return
if os.name == "posix" and proc.pid is not None:
try:
os.killpg(proc.pid, signal.SIGTERM)
return
except ProcessLookupError:
return
except Exception as e:
logger.debug(
"subprocess.terminate.failed",
error=str(e),
error_type=e.__class__.__name__,
pid=proc.pid,
)
try:
proc.terminate()
except ProcessLookupError:
return
_signal_process(
proc,
signal.SIGTERM,
fallback=proc.terminate,
log_event="subprocess.terminate.failed",
)
def kill_process(proc: Process) -> None:
_signal_process(
proc,
signal.SIGKILL,
fallback=proc.kill,
log_event="subprocess.kill.failed",
)
def _signal_process(
proc: Process,
sig: signal.Signals,
*,
fallback: Callable[[], None],
log_event: str,
) -> None:
if proc.returncode is not None:
return
if os.name == "posix" and proc.pid is not None:
try:
os.killpg(proc.pid, signal.SIGKILL)
os.killpg(proc.pid, sig)
return
except ProcessLookupError:
return
except Exception as e:
except Exception as exc:
logger.debug(
"subprocess.kill.failed",
error=str(e),
error_type=e.__class__.__name__,
log_event,
error=str(exc),
error_type=exc.__class__.__name__,
pid=proc.pid,
)
try:
proc.kill()
fallback()
except ProcessLookupError:
return
+1 -1
View File
@@ -45,7 +45,7 @@ def test_chat_id_command_uses_config_token(monkeypatch) -> None:
settings = TakopiSettings.model_validate(
{
"transport": "telegram",
"transports": {"telegram": {"bot_token": "config-token"}},
"transports": {"telegram": {"bot_token": "config-token", "chat_id": 123}},
}
)
monkeypatch.setattr(cli, "_load_settings_optional", lambda: (settings, Path("x")))
+10 -11
View File
@@ -4,38 +4,37 @@ from pathlib import Path
import pytest
from takopi.config import ConfigError
from takopi.config_store import read_raw_toml, write_raw_toml
from takopi.config import ConfigError, read_config, write_config
def test_read_write_raw_toml_round_trip(tmp_path: Path) -> None:
def test_read_write_config_round_trip(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
payload = {
"default_engine": "codex",
"projects": {"z80": {"path": "/tmp/repo"}},
}
write_raw_toml(payload, config_path)
loaded = read_raw_toml(config_path)
write_config(payload, config_path)
loaded = read_config(config_path)
assert loaded == payload
def test_read_raw_toml_missing_file(tmp_path: Path) -> None:
def test_read_config_missing_file(tmp_path: Path) -> None:
config_path = tmp_path / "missing.toml"
with pytest.raises(ConfigError, match="Missing config file"):
read_raw_toml(config_path)
read_config(config_path)
def test_read_raw_toml_invalid_toml(tmp_path: Path) -> None:
def test_read_config_invalid_toml(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
config_path.write_text("nope = [", encoding="utf-8")
with pytest.raises(ConfigError, match="Malformed TOML"):
read_raw_toml(config_path)
read_config(config_path)
def test_read_raw_toml_non_file(tmp_path: Path) -> None:
def test_read_config_non_file(tmp_path: Path) -> None:
config_path = tmp_path / "config_dir"
config_path.mkdir()
with pytest.raises(ConfigError, match="exists but is not a file"):
read_raw_toml(config_path)
read_config(config_path)
+18 -9
View File
@@ -4,8 +4,8 @@ import anyio
import pytest
import takopi.config_watch as config_watch
from takopi.config_watch import ConfigReload, _config_status, watch_config
from takopi.config import empty_projects_config
from takopi.config_watch import ConfigReload, config_status, watch_config
from takopi.config import ProjectsConfig
from takopi.router import AutoRouter, RunnerEntry
from takopi.runtime_loader import RuntimeSpec
from takopi.runners.mock import Return, ScriptRunner
@@ -15,19 +15,23 @@ from takopi.transport_runtime import TransportRuntime
def test_config_status_variants(tmp_path: Path) -> None:
missing = tmp_path / "missing.toml"
status, signature = _config_status(missing)
status, signature = config_status(missing)
assert status == "missing"
assert signature is None
directory = tmp_path / "config.d"
directory.mkdir()
status, signature = _config_status(directory)
status, signature = config_status(directory)
assert status == "invalid"
assert signature is None
config_file = tmp_path / "takopi.toml"
config_file.write_text('transport = "telegram"\n', encoding="utf-8")
status, signature = _config_status(config_file)
config_file.write_text(
'transport = "telegram"\n\n[transports.telegram]\n'
'bot_token = "token"\nchat_id = 123\n',
encoding="utf-8",
)
status, signature = config_status(config_file)
assert status == "ok"
assert signature is not None
@@ -47,7 +51,7 @@ async def test_watch_config_applies_runtime(
)
runtime = TransportRuntime(
router=router,
projects=empty_projects_config(),
projects=ProjectsConfig(projects={}, default_project=None),
config_path=resolved_path,
)
@@ -58,12 +62,17 @@ async def test_watch_config_applies_runtime(
)
new_spec = RuntimeSpec(
router=new_router,
projects=empty_projects_config(),
projects=ProjectsConfig(projects={}, default_project=None),
allowlist=None,
plugin_configs=None,
)
reload = ConfigReload(
settings=TakopiSettings.model_validate({"transport": "telegram"}),
settings=TakopiSettings.model_validate(
{
"transport": "telegram",
"transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
}
),
runtime_spec=new_spec,
config_path=resolved_path,
)
+1 -1
View File
@@ -87,7 +87,7 @@ def test_require_telegram_rejects_empty_token(tmp_path) -> None:
encoding="utf-8",
)
with pytest.raises(ConfigError, match="bot token"):
with pytest.raises(ConfigError, match="bot_token"):
settings, _ = load_settings(config_path)
require_telegram(settings, config_path)
+2 -2
View File
@@ -12,7 +12,7 @@ from takopi.model import (
StartedEvent,
TakopiEvent,
)
from takopi.runners.codex import CodexRunner, _find_exec_only_flag
from takopi.runners.codex import CodexRunner, find_exec_only_flag
CODEX_ENGINE = EngineId("codex")
@@ -159,7 +159,7 @@ def test_codex_exec_flags_after_exec() -> None:
],
)
def test_find_exec_only_flag(extra_args: list[str], expected: str | None) -> None:
assert _find_exec_only_flag(extra_args) == expected
assert find_exec_only_flag(extra_args) == expected
@pytest.mark.anyio
+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
def test_check_setup_marks_invalid_chat_id(monkeypatch, tmp_path: Path) -> None:
def test_check_setup_marks_invalid_bot_token(monkeypatch, tmp_path: Path) -> None:
backend = engines.get_backend("codex")
monkeypatch.setattr(onboarding.shutil, "which", lambda _name: "/usr/bin/codex")
def _fail_require(*_args, **_kwargs):
raise onboarding.ConfigError("Missing bot token")
monkeypatch.setattr(
onboarding,
"load_settings",
@@ -59,12 +63,13 @@ def test_check_setup_marks_invalid_chat_id(monkeypatch, tmp_path: Path) -> None:
TakopiSettings.model_validate(
{
"transport": "telegram",
"transports": {"telegram": {"bot_token": "token", "chat_id": None}},
"transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
}
),
tmp_path / "takopi.toml",
),
)
monkeypatch.setattr(onboarding, "require_telegram", _fail_require)
result = onboarding.check_setup(backend)
+25 -17
View File
@@ -1,26 +1,34 @@
from __future__ import annotations
from takopi.config import dump_toml
from takopi.telegram import onboarding
from takopi.backends import EngineBackend
def test_mask_token_short() -> None:
assert onboarding._mask_token("short") == "*****"
assert onboarding.mask_token("short") == "*****"
def test_mask_token_long() -> None:
token = "123456789:ABCdefGH"
masked = onboarding._mask_token(token)
masked = onboarding.mask_token(token)
assert masked.startswith("123456789")
assert masked.endswith("defGH")
assert "..." in masked
def test_render_config_escapes() -> None:
config = onboarding._render_config(
'token"with\\quote',
123,
"codex",
config = dump_toml(
{
"default_engine": "codex",
"transport": "telegram",
"transports": {
"telegram": {
"bot_token": 'token"with\\quote',
"chat_id": 123,
}
},
}
)
assert 'default_engine = "codex"' in config
assert 'transport = "telegram"' in config
@@ -82,9 +90,9 @@ def test_interactive_setup_writes_config(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"]))
def _fake_run(func, *args, **kwargs):
if func is onboarding._get_bot_info:
if func is onboarding.get_bot_info:
return {"username": "my_bot"}
if func is onboarding._wait_for_chat:
if func is onboarding.wait_for_chat:
return onboarding.ChatInfo(
chat_id=123,
username="alice",
@@ -127,9 +135,9 @@ def test_interactive_setup_preserves_projects(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"]))
def _fake_run(func, *args, **kwargs):
if func is onboarding._get_bot_info:
if func is onboarding.get_bot_info:
return {"username": "my_bot"}
if func is onboarding._wait_for_chat:
if func is onboarding.wait_for_chat:
return onboarding.ChatInfo(
chat_id=123,
username="alice",
@@ -164,9 +172,9 @@ def test_interactive_setup_no_agents_aborts(monkeypatch, tmp_path) -> None:
)
def _fake_run(func, *args, **kwargs):
if func is onboarding._get_bot_info:
if func is onboarding.get_bot_info:
return {"username": "my_bot"}
if func is onboarding._wait_for_chat:
if func is onboarding.wait_for_chat:
return onboarding.ChatInfo(
chat_id=123,
username="alice",
@@ -202,9 +210,9 @@ def test_interactive_setup_recovers_from_malformed_toml(monkeypatch, tmp_path) -
monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"]))
def _fake_run(func, *args, **kwargs):
if func is onboarding._get_bot_info:
if func is onboarding.get_bot_info:
return {"username": "my_bot"}
if func is onboarding._wait_for_chat:
if func is onboarding.wait_for_chat:
return onboarding.ChatInfo(
chat_id=123,
username="alice",
@@ -230,9 +238,9 @@ def test_interactive_setup_recovers_from_malformed_toml(monkeypatch, tmp_path) -
def test_capture_chat_id_with_token(monkeypatch) -> None:
def _fake_run(func, *args, **kwargs):
if func is onboarding._get_bot_info:
if func is onboarding.get_bot_info:
return {"username": "my_bot"}
if func is onboarding._wait_for_chat:
if func is onboarding.wait_for_chat:
return onboarding.ChatInfo(
chat_id=456,
username=None,
@@ -257,7 +265,7 @@ def test_capture_chat_id_prompts_for_token(monkeypatch) -> None:
)
def _fake_run(func, *args, **kwargs):
if func is onboarding._wait_for_chat:
if func is onboarding.wait_for_chat:
return onboarding.ChatInfo(
chat_id=789,
username="alice",
+20 -7
View File
@@ -4,13 +4,16 @@ import pytest
from typer.testing import CliRunner
from takopi import cli
from takopi.config import ConfigError
from takopi.config_store import read_raw_toml
from takopi.config import ConfigError, read_config
from takopi.settings import TakopiSettings
def _base_config() -> dict:
return {"transports": {"telegram": {"bot_token": "token", "chat_id": 123}}}
def test_parse_projects_rejects_engine_alias() -> None:
config = {"projects": {"codex": {"path": "/tmp/repo"}}}
config = {**_base_config(), "projects": {"codex": {"path": "/tmp/repo"}}}
with pytest.raises(ConfigError, match="aliases must not match engine ids"):
settings = TakopiSettings.model_validate(config)
settings.to_projects_config(
@@ -21,7 +24,7 @@ def test_parse_projects_rejects_engine_alias() -> None:
def test_parse_projects_default_project_must_exist() -> None:
config = {"default_project": "z80", "projects": {}}
config = {**_base_config(), "default_project": "z80", "projects": {}}
with pytest.raises(ConfigError, match="default_project"):
settings = TakopiSettings.model_validate(config)
settings.to_projects_config(
@@ -33,6 +36,11 @@ def test_parse_projects_default_project_must_exist() -> None:
def test_init_writes_project(monkeypatch, tmp_path) -> None:
config_path = tmp_path / "takopi.toml"
config_path.write_text(
'transport = "telegram"\n\n[transports.telegram]\n'
'bot_token = "token"\nchat_id = 123\n',
encoding="utf-8",
)
monkeypatch.setattr("takopi.config.HOME_CONFIG_PATH", config_path)
monkeypatch.setattr(cli, "resolve_default_base", lambda _: "main")
monkeypatch.setattr(cli, "_load_settings_optional", lambda: (None, None))
@@ -67,7 +75,7 @@ def test_init_migrates_legacy_config(monkeypatch, tmp_path) -> None:
result = runner.invoke(cli.create_app(), ["init", "z80"])
assert result.exit_code == 0
raw = read_raw_toml(config_path)
raw = read_config(config_path)
assert "bot_token" not in raw
assert "chat_id" not in raw
assert raw["transport"] == "telegram"
@@ -77,7 +85,10 @@ def test_init_migrates_legacy_config(monkeypatch, tmp_path) -> None:
def test_projects_default_engine_unknown() -> None:
config = {"projects": {"z80": {"path": "/tmp/repo", "default_engine": "nope"}}}
config = {
**_base_config(),
"projects": {"z80": {"path": "/tmp/repo", "default_engine": "nope"}},
}
settings = TakopiSettings.model_validate(config)
with pytest.raises(ConfigError, match="projects.z80.default_engine"):
settings.to_projects_config(
@@ -120,7 +131,9 @@ def test_projects_chat_id_must_be_unique() -> None:
def test_projects_relative_path_resolves(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
settings = TakopiSettings.model_validate({"projects": {"z80": {"path": "repo"}}})
settings = TakopiSettings.model_validate(
{**_base_config(), "projects": {"z80": {"path": "repo"}}}
)
projects = settings.to_projects_config(
config_path=config_path,
engine_ids=["codex"],
+19 -3
View File
@@ -11,9 +11,19 @@ def test_build_runtime_spec_minimal(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(runtime_loader.shutil, "which", lambda _cmd: "/bin/echo")
settings = TakopiSettings.model_validate({"transport": "telegram"})
settings = TakopiSettings.model_validate(
{
"transport": "telegram",
"watch_config": True,
"transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
}
)
config_path = tmp_path / "takopi.toml"
config_path.write_text('transport = "telegram"\n', encoding="utf-8")
config_path.write_text(
'transport = "telegram"\n\n[transports.telegram]\n'
'bot_token = "token"\nchat_id = 123\n',
encoding="utf-8",
)
spec = runtime_loader.build_runtime_spec(
settings=settings,
@@ -23,10 +33,16 @@ def test_build_runtime_spec_minimal(
assert spec.router.default_engine == settings.default_engine
runtime = spec.to_runtime(config_path=config_path)
assert runtime.default_engine == settings.default_engine
assert runtime.watch_config is True
def test_resolve_default_engine_unknown(tmp_path: Path) -> None:
settings = TakopiSettings.model_validate({"transport": "telegram"})
settings = TakopiSettings.model_validate(
{
"transport": "telegram",
"transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
}
)
with pytest.raises(ConfigError, match="Unknown default engine"):
runtime_loader.resolve_default_engine(
override="unknown",
+19 -15
View File
@@ -4,8 +4,7 @@ from pathlib import Path
import pytest
from takopi.config import ConfigError
from takopi.config_store import read_raw_toml
from takopi.config import ConfigError, read_config
from takopi.settings import (
TakopiSettings,
load_settings,
@@ -38,8 +37,7 @@ def test_load_settings_from_toml(tmp_path: Path) -> None:
assert token == "token"
assert chat_id == 123
dumped = settings.model_dump()
assert dumped["transports"]["telegram"]["bot_token"] == "token"
assert settings.transports.telegram.bot_token == "token"
def test_env_overrides_toml(tmp_path: Path, monkeypatch) -> None:
@@ -67,7 +65,7 @@ def test_legacy_keys_migrated(tmp_path: Path) -> None:
assert loaded_path == config_path
assert settings.transports.telegram.chat_id == 123
raw = read_raw_toml(config_path)
raw = read_config(config_path)
assert "bot_token" not in raw
assert "chat_id" not in raw
assert raw["transports"]["telegram"]["bot_token"] == "token"
@@ -100,7 +98,10 @@ def test_validate_settings_data_rejects_empty_default_engine(tmp_path: Path) ->
def test_validate_settings_data_rejects_empty_default_project(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
data = {"default_project": " "}
data = {
"default_project": " ",
"transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
}
with pytest.raises(ConfigError, match="default_project"):
validate_settings_data(data, config_path=config_path)
@@ -108,7 +109,10 @@ def test_validate_settings_data_rejects_empty_default_project(tmp_path: Path) ->
def test_validate_settings_data_rejects_empty_project_path(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
data = {"projects": {"z80": {"path": " "}}}
data = {
"projects": {"z80": {"path": " "}},
"transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
}
with pytest.raises(ConfigError, match="path"):
validate_settings_data(data, config_path=config_path)
@@ -172,14 +176,14 @@ def test_transport_config_telegram_and_extra(tmp_path: Path) -> None:
settings.transport_config("discord", config_path=config_path)
def test_bot_token_none_allowed() -> None:
settings = TakopiSettings.model_validate(
{
"transport": "telegram",
"transports": {"telegram": {"bot_token": None, "chat_id": 123}},
}
)
assert settings.transports.telegram.bot_token is None
def test_bot_token_none_rejected(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
data = {
"transport": "telegram",
"transports": {"telegram": {"bot_token": None, "chat_id": 123}},
}
with pytest.raises(ConfigError, match="bot_token"):
validate_settings_data(data, config_path=config_path)
def test_require_telegram_rejects_non_telegram_transport(tmp_path: Path) -> None:
+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
from takopi.config import ConfigError, empty_projects_config
from takopi.config import ProjectsConfig
from takopi.model import EngineId
from takopi.router import AutoRouter, RunnerEntry
from takopi.runners.mock import Return, ScriptRunner
@@ -25,7 +25,11 @@ def test_build_startup_message_includes_missing_engines(tmp_path: Path) -> None:
],
default_engine=codex,
)
runtime = TransportRuntime(router=router, projects=empty_projects_config())
runtime = TransportRuntime(
router=router,
projects=ProjectsConfig(projects={}, default_project=None),
watch_config=True,
)
message = telegram_backend._build_startup_message(
runtime, startup_pwd=str(tmp_path)
@@ -54,7 +58,11 @@ def test_telegram_backend_build_and_run_wires_config(
entries=[RunnerEntry(engine=codex, runner=runner, available=True)],
default_engine=codex,
)
runtime = TransportRuntime(router=router, projects=empty_projects_config())
runtime = TransportRuntime(
router=router,
projects=ProjectsConfig(projects={}, default_project=None),
watch_config=True,
)
captured: dict[str, Any] = {}
@@ -91,8 +99,7 @@ def test_telegram_backend_build_and_run_wires_config(
cfg = captured["cfg"]
kwargs = captured["kwargs"]
assert cfg.chat_id == 321
assert cfg.voice_transcription is not None
assert cfg.voice_transcription.enabled is True
assert cfg.voice_transcription is True
assert cfg.files.enabled is True
assert cfg.files.allowed_user_ids == frozenset({1, 2})
assert cfg.topics.enabled is True
@@ -101,12 +108,10 @@ def test_telegram_backend_build_and_run_wires_config(
assert kwargs["transport_id"] == "telegram"
def test_build_files_config_rejects_non_dict(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
transport_config: dict[str, object] = {"files": ["nope"]}
def test_build_files_config_defaults() -> None:
cfg = telegram_backend._build_files_config({})
with pytest.raises(ConfigError, match="transports.telegram.files"):
telegram_backend._build_files_config(
transport_config,
config_path=config_path,
)
assert cfg.enabled is False
assert cfg.auto_put is True
assert cfg.uploads_dir == "incoming"
assert cfg.allowed_user_ids == frozenset()
+52 -47
View File
@@ -7,23 +7,26 @@ import pytest
from takopi import commands, plugins
import takopi.telegram.bridge as bridge
import takopi.telegram.loop as telegram_loop
import takopi.telegram.commands as telegram_commands
import takopi.telegram.topics as telegram_topics
from takopi.directives import parse_directives
from takopi.telegram.bridge import (
TelegramBridgeConfig,
TelegramFilesConfig,
TelegramPresenter,
TelegramTransport,
_build_bot_commands,
_handle_callback_cancel,
_handle_cancel,
_is_cancel_command,
_send_with_resume,
build_bot_commands,
handle_callback_cancel,
handle_cancel,
is_cancel_command,
send_with_resume,
run_main_loop,
)
from takopi.telegram.client import BotClient
from takopi.telegram.topic_state import TopicStateStore, resolve_state_path
from takopi.context import RunContext
from takopi.config import ProjectConfig, ProjectsConfig, empty_projects_config
from takopi.config import ProjectConfig, ProjectsConfig
from takopi.runner_bridge import ExecBridgeConfig, RunningTask
from takopi.markdown import MarkdownPresenter
from takopi.model import EngineId, ResumeToken
@@ -42,6 +45,10 @@ from tests.plugin_fixtures import FakeEntryPoint, install_entrypoints
CODEX_ENGINE = EngineId("codex")
def _empty_projects() -> ProjectsConfig:
return ProjectsConfig(projects={}, default_project=None)
def _make_router(runner) -> AutoRouter:
return AutoRouter(
entries=[RunnerEntry(engine=runner.engine, runner=runner)],
@@ -288,7 +295,7 @@ def _make_cfg(
)
runtime = TransportRuntime(
router=_make_router(runner),
projects=empty_projects_config(),
projects=_empty_projects(),
)
return TelegramBridgeConfig(
bot=_FakeBot(),
@@ -303,7 +310,7 @@ def test_parse_directives_inline_engine() -> None:
directives = parse_directives(
"/claude do it",
engine_ids=("codex", "claude"),
projects=empty_projects_config(),
projects=_empty_projects(),
)
assert directives.engine == "claude"
assert directives.prompt == "do it"
@@ -313,7 +320,7 @@ def test_parse_directives_newline() -> None:
directives = parse_directives(
"/codex\nhello",
engine_ids=("codex", "claude"),
projects=empty_projects_config(),
projects=_empty_projects(),
)
assert directives.engine == "codex"
assert directives.prompt == "hello"
@@ -323,7 +330,7 @@ def test_parse_directives_ignores_unknown() -> None:
directives = parse_directives(
"/unknown hi",
engine_ids=("codex", "claude"),
projects=empty_projects_config(),
projects=_empty_projects(),
)
assert directives.engine is None
assert directives.prompt == "/unknown hi"
@@ -333,7 +340,7 @@ def test_parse_directives_bot_suffix() -> None:
directives = parse_directives(
"/claude@bunny_agent_bot hi",
engine_ids=("claude",),
projects=empty_projects_config(),
projects=_empty_projects(),
)
assert directives.engine == "claude"
assert directives.prompt == "hi"
@@ -343,7 +350,7 @@ def test_parse_directives_only_first_non_empty_line() -> None:
directives = parse_directives(
"hello\n/claude hi",
engine_ids=("codex", "claude"),
projects=empty_projects_config(),
projects=_empty_projects(),
)
assert directives.engine is None
assert directives.prompt == "hello\n/claude hi"
@@ -355,9 +362,9 @@ def test_build_bot_commands_includes_cancel_and_engine() -> None:
)
runtime = TransportRuntime(
router=_make_router(runner),
projects=empty_projects_config(),
projects=_empty_projects(),
)
commands = _build_bot_commands(runtime)
commands = build_bot_commands(runtime)
assert {"command": "cancel", "description": "cancel run"} in commands
assert {"command": "file", "description": "upload or fetch files"} in commands
@@ -386,7 +393,7 @@ def test_build_bot_commands_includes_projects() -> None:
)
runtime = TransportRuntime(router=router, projects=projects)
commands = _build_bot_commands(runtime)
commands = build_bot_commands(runtime)
assert any(cmd["command"] == "good" for cmd in commands)
assert not any(cmd["command"] == "bad-name" for cmd in commands)
@@ -413,10 +420,10 @@ def test_build_bot_commands_includes_command_plugins(monkeypatch) -> None:
runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
runtime = TransportRuntime(
router=_make_router(runner),
projects=empty_projects_config(),
projects=_empty_projects(),
)
commands_list = _build_bot_commands(runtime)
commands_list = build_bot_commands(runtime)
assert {"command": "pingcmd", "description": "ping command"} in commands_list
@@ -439,7 +446,7 @@ def test_build_bot_commands_caps_total() -> None:
)
runtime = TransportRuntime(router=router, projects=projects)
commands = _build_bot_commands(runtime)
commands = build_bot_commands(runtime)
assert len(commands) == 100
assert any(cmd["command"] == "codex" for cmd in commands)
@@ -667,7 +674,7 @@ async def test_handle_cancel_without_reply_prompts_user() -> None:
)
running_tasks: dict = {}
await _handle_cancel(cfg, msg, running_tasks)
await handle_cancel(cfg, msg, running_tasks)
assert len(transport.send_calls) == 1
assert "reply to the progress message" in transport.send_calls[0]["message"].text
@@ -688,7 +695,7 @@ async def test_handle_cancel_with_no_progress_message_says_nothing_running() ->
)
running_tasks: dict = {}
await _handle_cancel(cfg, msg, running_tasks)
await handle_cancel(cfg, msg, running_tasks)
assert len(transport.send_calls) == 1
assert "nothing is currently running" in transport.send_calls[0]["message"].text
@@ -710,7 +717,7 @@ async def test_handle_cancel_with_finished_task_says_nothing_running() -> None:
)
running_tasks: dict = {}
await _handle_cancel(cfg, msg, running_tasks)
await handle_cancel(cfg, msg, running_tasks)
assert len(transport.send_calls) == 1
assert "nothing is currently running" in transport.send_calls[0]["message"].text
@@ -733,7 +740,7 @@ async def test_handle_cancel_cancels_running_task() -> None:
running_task = RunningTask()
running_tasks = {MessageRef(channel_id=123, message_id=progress_id): running_task}
await _handle_cancel(cfg, msg, running_tasks)
await handle_cancel(cfg, msg, running_tasks)
assert running_task.cancel_requested.is_set() is True
assert len(transport.send_calls) == 0 # No error message sent
@@ -759,7 +766,7 @@ async def test_handle_cancel_only_cancels_matching_progress_message() -> None:
MessageRef(channel_id=123, message_id=2): task_second,
}
await _handle_cancel(cfg, msg, running_tasks)
await handle_cancel(cfg, msg, running_tasks)
assert task_first.cancel_requested.is_set() is True
assert task_second.cancel_requested.is_set() is False
@@ -824,7 +831,9 @@ async def test_handle_file_put_writes_file(tmp_path: Path) -> None:
),
)
await bridge._handle_file_put(cfg, msg, "/proj uploads/hello.txt", None, None)
await telegram_commands._handle_file_put(
cfg, msg, "/proj uploads/hello.txt", None, None
)
target = tmp_path / "uploads" / "hello.txt"
assert target.read_bytes() == payload
@@ -883,7 +892,7 @@ async def test_handle_file_get_sends_document_for_allowed_user(
chat_type="supergroup",
)
await bridge._handle_file_get(cfg, msg, "/proj hello.txt", None, None)
await telegram_commands._handle_file_get(cfg, msg, "/proj hello.txt", None, None)
assert bot.document_calls
assert bot.document_calls[0]["filename"] == "hello.txt"
@@ -906,7 +915,7 @@ async def test_handle_callback_cancel_cancels_running_task() -> None:
sender_id=123,
)
await _handle_callback_cancel(cfg, query, running_tasks)
await handle_callback_cancel(cfg, query, running_tasks)
assert running_task.cancel_requested.is_set() is True
assert len(transport.send_calls) == 0
@@ -928,7 +937,7 @@ async def test_handle_callback_cancel_without_task_acknowledges() -> None:
sender_id=123,
)
await _handle_callback_cancel(cfg, query, {})
await handle_callback_cancel(cfg, query, {})
assert len(transport.send_calls) == 0
bot = cast(_FakeBot, cfg.bot)
@@ -937,9 +946,9 @@ async def test_handle_callback_cancel_without_task_acknowledges() -> None:
def test_cancel_command_accepts_extra_text() -> None:
assert _is_cancel_command("/cancel now") is True
assert _is_cancel_command("/cancel@takopi please") is True
assert _is_cancel_command("/cancelled") is False
assert is_cancel_command("/cancel now") is True
assert is_cancel_command("/cancel@takopi please") is True
assert is_cancel_command("/cancelled") is False
def test_resolve_message_accepts_backticked_ctx_line() -> None:
@@ -971,24 +980,21 @@ def test_topic_title_matches_command_syntax() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
title = bridge._topic_title(
cfg=cfg,
title = telegram_topics._topic_title(
runtime=cfg.runtime,
context=RunContext(project="takopi", branch="master"),
)
assert title == "takopi @master"
title = bridge._topic_title(
cfg=cfg,
title = telegram_topics._topic_title(
runtime=cfg.runtime,
context=RunContext(project="takopi", branch=None),
)
assert title == "takopi"
title = bridge._topic_title(
cfg=cfg,
title = telegram_topics._topic_title(
runtime=cfg.runtime,
context=RunContext(project=None, branch="main"),
)
@@ -1006,8 +1012,7 @@ def test_topic_title_projects_scope_includes_project() -> None:
),
)
title = bridge._topic_title(
cfg=cfg,
title = telegram_topics._topic_title(
runtime=cfg.runtime,
context=RunContext(project="takopi", branch="master"),
)
@@ -1028,7 +1033,7 @@ async def test_maybe_rename_topic_updates_title(tmp_path: Path) -> None:
topic_title="takopi @old",
)
await bridge._maybe_rename_topic(
await telegram_topics._maybe_rename_topic(
cfg,
store,
chat_id=123,
@@ -1058,7 +1063,7 @@ async def test_maybe_rename_topic_skips_when_title_matches(tmp_path: Path) -> No
)
snapshot = await store.get_thread(123, 77)
await bridge._maybe_rename_topic(
await telegram_topics._maybe_rename_topic(
cfg,
store,
chat_id=123,
@@ -1096,7 +1101,7 @@ async def test_send_with_resume_waits_for_token() -> None:
async with anyio.create_task_group() as tg:
tg.start_soon(trigger_resume)
await _send_with_resume(
await send_with_resume(
cfg,
enqueue,
running_task,
@@ -1138,7 +1143,7 @@ async def test_send_with_resume_reports_when_missing() -> None:
running_task = RunningTask()
running_task.done.set()
await _send_with_resume(
await send_with_resume(
cfg,
enqueue,
running_task,
@@ -1175,7 +1180,7 @@ async def test_run_main_loop_routes_reply_to_running_resume() -> None:
)
runtime = TransportRuntime(
router=_make_router(runner),
projects=empty_projects_config(),
projects=_empty_projects(),
)
cfg = TelegramBridgeConfig(
bot=bot,
@@ -1311,7 +1316,7 @@ async def test_run_main_loop_replies_in_same_thread() -> None:
)
runtime = TransportRuntime(
router=_make_router(runner),
projects=empty_projects_config(),
projects=_empty_projects(),
)
cfg = TelegramBridgeConfig(
bot=bot,
@@ -1489,7 +1494,7 @@ async def test_run_main_loop_handles_command_plugins(monkeypatch) -> None:
)
runtime = TransportRuntime(
router=_make_router(runner),
projects=empty_projects_config(),
projects=_empty_projects(),
)
cfg = TelegramBridgeConfig(
bot=bot,
@@ -1714,7 +1719,7 @@ async def test_run_main_loop_refreshes_command_ids(monkeypatch) -> None:
return []
return ["late_cmd"]
monkeypatch.setattr(bridge, "list_command_ids", _list_command_ids)
monkeypatch.setattr(telegram_loop, "list_command_ids", _list_command_ids)
transport = _FakeTransport()
bot = _FakeBot()
@@ -1726,7 +1731,7 @@ async def test_run_main_loop_refreshes_command_ids(monkeypatch) -> None:
)
runtime = TransportRuntime(
router=_make_router(runner),
projects=empty_projects_config(),
projects=_empty_projects(),
)
cfg = TelegramBridgeConfig(
bot=bot,
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" },
]
[[package]]
name = "distro"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -156,6 +165,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jiter"
version = "0.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294, upload-time = "2025-11-09T20:49:23.302Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/99/45c9f0dbe4a1416b2b9a8a6d1236459540f43d7fb8883cff769a8db0612d/jiter-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c46d927acd09c67a9fb1416df45c5a04c27e83aae969267e98fba35b74e99525", size = 312478, upload-time = "2025-11-09T20:48:10.898Z" },
{ url = "https://files.pythonhosted.org/packages/4c/a7/54ae75613ba9e0f55fcb0bc5d1f807823b5167cc944e9333ff322e9f07dd/jiter-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:774ff60b27a84a85b27b88cd5583899c59940bcc126caca97eb2a9df6aa00c49", size = 318706, upload-time = "2025-11-09T20:48:12.266Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/2aa241ad2c10774baf6c37f8b8e1f39c07db358f1329f4eb40eba179c2a2/jiter-0.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5433fab222fb072237df3f637d01b81f040a07dcac1cb4a5c75c7aa9ed0bef1", size = 351894, upload-time = "2025-11-09T20:48:13.673Z" },
{ url = "https://files.pythonhosted.org/packages/54/4f/0f2759522719133a9042781b18cc94e335b6d290f5e2d3e6899d6af933e3/jiter-0.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8c593c6e71c07866ec6bfb790e202a833eeec885022296aff6b9e0b92d6a70e", size = 365714, upload-time = "2025-11-09T20:48:15.083Z" },
{ url = "https://files.pythonhosted.org/packages/dc/6f/806b895f476582c62a2f52c453151edd8a0fde5411b0497baaa41018e878/jiter-0.12.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90d32894d4c6877a87ae00c6b915b609406819dce8bc0d4e962e4de2784e567e", size = 478989, upload-time = "2025-11-09T20:48:16.706Z" },
{ url = "https://files.pythonhosted.org/packages/86/6c/012d894dc6e1033acd8db2b8346add33e413ec1c7c002598915278a37f79/jiter-0.12.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:798e46eed9eb10c3adbbacbd3bdb5ecd4cf7064e453d00dbef08802dae6937ff", size = 378615, upload-time = "2025-11-09T20:48:18.614Z" },
{ url = "https://files.pythonhosted.org/packages/87/30/d718d599f6700163e28e2c71c0bbaf6dace692e7df2592fd793ac9276717/jiter-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3f1368f0a6719ea80013a4eb90ba72e75d7ea67cfc7846db2ca504f3df0169a", size = 364745, upload-time = "2025-11-09T20:48:20.117Z" },
{ url = "https://files.pythonhosted.org/packages/8f/85/315b45ce4b6ddc7d7fceca24068543b02bdc8782942f4ee49d652e2cc89f/jiter-0.12.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65f04a9d0b4406f7e51279710b27484af411896246200e461d80d3ba0caa901a", size = 386502, upload-time = "2025-11-09T20:48:21.543Z" },
{ url = "https://files.pythonhosted.org/packages/74/0b/ce0434fb40c5b24b368fe81b17074d2840748b4952256bab451b72290a49/jiter-0.12.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:fd990541982a24281d12b67a335e44f117e4c6cbad3c3b75c7dea68bf4ce3a67", size = 519845, upload-time = "2025-11-09T20:48:22.964Z" },
{ url = "https://files.pythonhosted.org/packages/e8/a3/7a7a4488ba052767846b9c916d208b3ed114e3eb670ee984e4c565b9cf0d/jiter-0.12.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b111b0e9152fa7df870ecaebb0bd30240d9f7fff1f2003bcb4ed0f519941820b", size = 510701, upload-time = "2025-11-09T20:48:24.483Z" },
{ url = "https://files.pythonhosted.org/packages/c3/16/052ffbf9d0467b70af24e30f91e0579e13ded0c17bb4a8eb2aed3cb60131/jiter-0.12.0-cp314-cp314-win32.whl", hash = "sha256:a78befb9cc0a45b5a5a0d537b06f8544c2ebb60d19d02c41ff15da28a9e22d42", size = 205029, upload-time = "2025-11-09T20:48:25.749Z" },
{ url = "https://files.pythonhosted.org/packages/e4/18/3cf1f3f0ccc789f76b9a754bdb7a6977e5d1d671ee97a9e14f7eb728d80e/jiter-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:e1fe01c082f6aafbe5c8faf0ff074f38dfb911d53f07ec333ca03f8f6226debf", size = 204960, upload-time = "2025-11-09T20:48:27.415Z" },
{ url = "https://files.pythonhosted.org/packages/02/68/736821e52ecfdeeb0f024b8ab01b5a229f6b9293bbdb444c27efade50b0f/jiter-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:d72f3b5a432a4c546ea4bedc84cce0c3404874f1d1676260b9c7f048a9855451", size = 185529, upload-time = "2025-11-09T20:48:29.125Z" },
{ url = "https://files.pythonhosted.org/packages/30/61/12ed8ee7a643cce29ac97c2281f9ce3956eb76b037e88d290f4ed0d41480/jiter-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e6ded41aeba3603f9728ed2b6196e4df875348ab97b28fc8afff115ed42ba7a7", size = 318974, upload-time = "2025-11-09T20:48:30.87Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c6/f3041ede6d0ed5e0e79ff0de4c8f14f401bbf196f2ef3971cdbe5fd08d1d/jiter-0.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a947920902420a6ada6ad51892082521978e9dd44a802663b001436e4b771684", size = 345932, upload-time = "2025-11-09T20:48:32.658Z" },
{ url = "https://files.pythonhosted.org/packages/d5/5d/4d94835889edd01ad0e2dbfc05f7bdfaed46292e7b504a6ac7839aa00edb/jiter-0.12.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:add5e227e0554d3a52cf390a7635edaffdf4f8fce4fdbcef3cc2055bb396a30c", size = 367243, upload-time = "2025-11-09T20:48:34.093Z" },
{ url = "https://files.pythonhosted.org/packages/fd/76/0051b0ac2816253a99d27baf3dda198663aff882fa6ea7deeb94046da24e/jiter-0.12.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9b1cda8fcb736250d7e8711d4580ebf004a46771432be0ae4796944b5dfa5d", size = 479315, upload-time = "2025-11-09T20:48:35.507Z" },
{ url = "https://files.pythonhosted.org/packages/70/ae/83f793acd68e5cb24e483f44f482a1a15601848b9b6f199dacb970098f77/jiter-0.12.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deeb12a2223fe0135c7ff1356a143d57f95bbf1f4a66584f1fc74df21d86b993", size = 380714, upload-time = "2025-11-09T20:48:40.014Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/4808a88338ad2c228b1126b93fcd8ba145e919e886fe910d578230dabe3b/jiter-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c596cc0f4cb574877550ce4ecd51f8037469146addd676d7c1a30ebe6391923f", size = 365168, upload-time = "2025-11-09T20:48:41.462Z" },
{ url = "https://files.pythonhosted.org/packages/0c/d4/04619a9e8095b42aef436b5aeb4c0282b4ff1b27d1db1508df9f5dc82750/jiter-0.12.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ab4c823b216a4aeab3fdbf579c5843165756bd9ad87cc6b1c65919c4715f783", size = 387893, upload-time = "2025-11-09T20:48:42.921Z" },
{ url = "https://files.pythonhosted.org/packages/17/ea/d3c7e62e4546fdc39197fa4a4315a563a89b95b6d54c0d25373842a59cbe/jiter-0.12.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e427eee51149edf962203ff8db75a7514ab89be5cb623fb9cea1f20b54f1107b", size = 520828, upload-time = "2025-11-09T20:48:44.278Z" },
{ url = "https://files.pythonhosted.org/packages/cc/0b/c6d3562a03fd767e31cb119d9041ea7958c3c80cb3d753eafb19b3b18349/jiter-0.12.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:edb868841f84c111255ba5e80339d386d937ec1fdce419518ce1bd9370fac5b6", size = 511009, upload-time = "2025-11-09T20:48:45.726Z" },
{ url = "https://files.pythonhosted.org/packages/aa/51/2cb4468b3448a8385ebcd15059d325c9ce67df4e2758d133ab9442b19834/jiter-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8bbcfe2791dfdb7c5e48baf646d37a6a3dcb5a97a032017741dea9f817dca183", size = 205110, upload-time = "2025-11-09T20:48:47.033Z" },
{ url = "https://files.pythonhosted.org/packages/b2/c5/ae5ec83dec9c2d1af805fd5fe8f74ebded9c8670c5210ec7820ce0dbeb1e/jiter-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2fa940963bf02e1d8226027ef461e36af472dea85d36054ff835aeed944dd873", size = 205223, upload-time = "2025-11-09T20:48:49.076Z" },
{ url = "https://files.pythonhosted.org/packages/97/9a/3c5391907277f0e55195550cf3fa8e293ae9ee0c00fb402fec1e38c0c82f/jiter-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506c9708dd29b27288f9f8f1140c3cb0e3d8ddb045956d7757b1fa0e0f39a473", size = 185564, upload-time = "2025-11-09T20:48:50.376Z" },
]
[[package]]
name = "lxml"
version = "6.0.2"
@@ -245,6 +287,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/3e/c5187de84bb2c2ca334ab163fcacf19a23ebb1d876c837f81a1b324a15bf/msgspec-0.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:93f23528edc51d9f686808a361728e903d6f2be55c901d6f5c92e44c6d546bfc", size = 183011, upload-time = "2025-11-24T03:56:16.442Z" },
]
[[package]]
name = "openai"
version = "2.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "httpx" },
{ name = "jiter" },
{ name = "pydantic" },
{ name = "sniffio" },
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/f4/4690ecb5d70023ce6bfcfeabfe717020f654bde59a775058ec6ac4692463/openai-2.15.0.tar.gz", hash = "sha256:42eb8cbb407d84770633f31bf727d4ffb4138711c670565a41663d9439174fba", size = 627383, upload-time = "2026-01-09T22:10:08.603Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/df/c306f7375d42bafb379934c2df4c2fa3964656c8c782bac75ee10c102818/openai-2.15.0-py3-none-any.whl", hash = "sha256:6ae23b932cd7230f7244e52954daa6602716d6b9bf235401a107af731baea6c3", size = 1067879, upload-time = "2026-01-09T22:10:06.446Z" },
]
[[package]]
name = "packaging"
version = "25.0"
@@ -473,6 +534,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "structlog"
version = "25.5.0"
@@ -504,6 +574,7 @@ dependencies = [
{ name = "httpx" },
{ name = "markdown-it-py" },
{ name = "msgspec" },
{ name = "openai" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "questionary" },
@@ -529,6 +600,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "markdown-it-py" },
{ name = "msgspec", specifier = ">=0.20.0" },
{ name = "openai", specifier = ">=2.15.0" },
{ name = "pydantic", specifier = ">=2.12.5" },
{ name = "pydantic-settings", specifier = ">=2.12.0" },
{ name = "questionary", specifier = ">=2.1.1" },
@@ -548,6 +620,18 @@ dev = [
{ name = "ty", specifier = ">=0.0.8" },
]
[[package]]
name = "tqdm"
version = "4.67.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
]
[[package]]
name = "ty"
version = "0.0.8"