diff --git a/pyproject.toml b/pyproject.toml index f4caf5d..03bc41f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ docs = [ ] [tool.pytest.ini_options] -addopts = ["--cov=takopi", "--cov-report=term-missing", "--cov-fail-under=75"] +addopts = ["--cov=takopi", "--cov-branch", "--cov-report=term-missing", "--cov-fail-under=80"] testpaths = ["tests"] [tool.ruff.lint] diff --git a/src/takopi/telegram/voice.py b/src/takopi/telegram/voice.py index 3439b3f..16d133e 100644 --- a/src/takopi/telegram/voice.py +++ b/src/takopi/telegram/voice.py @@ -2,6 +2,7 @@ from __future__ import annotations import io from collections.abc import Awaitable, Callable +from typing import Protocol from ..logging import get_logger from openai import AsyncOpenAI, OpenAIError @@ -22,6 +23,22 @@ VOICE_TRANSCRIPTION_DISABLED_HINT = ( ) +class VoiceTranscriber(Protocol): + async def transcribe(self, *, model: str, audio_bytes: bytes) -> str: ... + + +class OpenAIVoiceTranscriber: + async def transcribe(self, *, model: str, audio_bytes: bytes) -> str: + audio_file = io.BytesIO(audio_bytes) + audio_file.name = "voice.ogg" + async with AsyncOpenAI(timeout=120) as client: + response = await client.audio.transcriptions.create( + model=model, + file=audio_file, + ) + return response.text + + async def transcribe_voice( *, bot: BotClient, @@ -30,6 +47,7 @@ async def transcribe_voice( model: str, max_bytes: int | None = None, reply: Callable[..., Awaitable[None]], + transcriber: VoiceTranscriber | None = None, ) -> str | None: voice = msg.voice if voice is None: @@ -55,21 +73,23 @@ async def transcribe_voice( if max_bytes is not None and len(audio_bytes) > max_bytes: await reply(text="voice message is too large to transcribe.") return None - 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=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 + if transcriber is None: + transcriber = OpenAIVoiceTranscriber() + try: + return await transcriber.transcribe(model=model, audio_bytes=audio_bytes) + 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 + except (RuntimeError, OSError, ValueError) as exc: + logger.error( + "voice.transcribe.error", + error=str(exc), + error_type=exc.__class__.__name__, + ) + await reply(text=str(exc).strip() or "voice transcription failed") + return None diff --git a/tests/conftest.py b/tests/conftest.py index 34383ac..7259d50 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,18 +1,27 @@ -import sys -from pathlib import Path +from collections.abc import Callable import pytest -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) +from takopi.telegram.bridge import TelegramBridgeConfig +from takopi.runners.mock import ScriptRunner +from tests.telegram_fakes import FakeBot, FakeTransport, make_cfg as build_cfg @pytest.fixture -def anyio_backend() -> str: - return "asyncio" +def fake_transport() -> FakeTransport: + return FakeTransport() -@pytest.fixture(autouse=True) -def reset_plugins_state() -> None: - import takopi.plugins as plugins +@pytest.fixture +def fake_bot() -> FakeBot: + return FakeBot() - plugins.reset_plugin_state() + +@pytest.fixture +def make_cfg() -> Callable[..., TelegramBridgeConfig]: + def _factory( + transport: FakeTransport, runner: ScriptRunner | None = None + ) -> TelegramBridgeConfig: + return build_cfg(transport, runner) + + return _factory diff --git a/tests/telegram_fakes.py b/tests/telegram_fakes.py new file mode 100644 index 0000000..816b11b --- /dev/null +++ b/tests/telegram_fakes.py @@ -0,0 +1,288 @@ +from typing import Any + +import anyio + +from takopi.config import ProjectsConfig +from takopi.markdown import MarkdownPresenter +from takopi.router import AutoRouter, RunnerEntry +from takopi.runner_bridge import ExecBridgeConfig +from takopi.runners.mock import Return, ScriptRunner +from takopi.telegram.api_models import ( + Chat, + ChatMember, + File, + ForumTopic, + Message, + Update, + User, +) +from takopi.telegram.bridge import TelegramBridgeConfig +from takopi.telegram.client import BotClient +from takopi.transport import MessageRef, RenderedMessage, SendOptions +from takopi.transport_runtime import TransportRuntime + +DEFAULT_ENGINE_ID = "codex" + + +def _empty_projects() -> ProjectsConfig: + return ProjectsConfig(projects={}, default_project=None) + + +def _make_router(runner: Any) -> AutoRouter: + return AutoRouter( + entries=[RunnerEntry(engine=runner.engine, runner=runner)], + default_engine=runner.engine, + ) + + +class FakeTransport: + def __init__(self, progress_ready: anyio.Event | None = None) -> None: + self._next_id = 1 + self.send_calls: list[dict] = [] + self.edit_calls: list[dict] = [] + self.delete_calls: list[MessageRef] = [] + self.progress_ready = progress_ready + self.progress_ref: MessageRef | None = None + + async def send( + self, + *, + channel_id: int | str, + message: RenderedMessage, + options: SendOptions | None = None, + ) -> MessageRef: + ref = MessageRef(channel_id=channel_id, message_id=self._next_id) + self._next_id += 1 + self.send_calls.append( + { + "ref": ref, + "channel_id": channel_id, + "message": message, + "options": options, + } + ) + if ( + self.progress_ref is None + and options is not None + and options.reply_to is not None + and options.notify is False + ): + self.progress_ref = ref + if self.progress_ready is not None: + self.progress_ready.set() + return ref + + async def edit( + self, *, ref: MessageRef, message: RenderedMessage, wait: bool = True + ) -> MessageRef: + self.edit_calls.append({"ref": ref, "message": message, "wait": wait}) + return ref + + async def delete(self, *, ref: MessageRef) -> bool: + self.delete_calls.append(ref) + return True + + async def close(self) -> None: + return None + + +class FakeBot(BotClient): + def __init__(self) -> None: + self.command_calls: list[dict] = [] + self.callback_calls: list[dict] = [] + self.send_calls: list[dict] = [] + self.document_calls: list[dict] = [] + self.edit_calls: list[dict] = [] + self.edit_topic_calls: list[dict[str, Any]] = [] + self.delete_calls: list[dict] = [] + + async def get_updates( + self, + offset: int | None, + timeout_s: int = 50, + allowed_updates: list[str] | None = None, + ) -> list[Update] | None: + _ = offset + _ = timeout_s + _ = allowed_updates + return [] + + async def get_file(self, file_id: str) -> File | None: + _ = file_id + return None + + async def download_file(self, file_path: str) -> bytes | None: + _ = file_path + return None + + async def send_message( + self, + chat_id: int, + text: str, + reply_to_message_id: int | None = None, + disable_notification: bool | None = False, + message_thread_id: int | None = None, + entities: list[dict[str, Any]] | None = None, + parse_mode: str | None = None, + reply_markup: dict | None = None, + *, + replace_message_id: int | None = None, + ) -> Message: + self.send_calls.append( + { + "chat_id": chat_id, + "text": text, + "reply_to_message_id": reply_to_message_id, + "disable_notification": disable_notification, + "message_thread_id": message_thread_id, + "entities": entities, + "parse_mode": parse_mode, + "reply_markup": reply_markup, + "replace_message_id": replace_message_id, + } + ) + return Message(message_id=1) + + async def send_document( + self, + chat_id: int, + filename: str, + content: bytes, + reply_to_message_id: int | None = None, + message_thread_id: int | None = None, + disable_notification: bool | None = False, + caption: str | None = None, + ) -> Message: + self.document_calls.append( + { + "chat_id": chat_id, + "filename": filename, + "content": content, + "reply_to_message_id": reply_to_message_id, + "message_thread_id": message_thread_id, + "disable_notification": disable_notification, + "caption": caption, + } + ) + return Message(message_id=2) + + async def edit_message_text( + self, + chat_id: int, + message_id: int, + text: str, + entities: list[dict[str, Any]] | None = None, + parse_mode: str | None = None, + reply_markup: dict | None = None, + *, + wait: bool = True, + ) -> Message: + self.edit_calls.append( + { + "chat_id": chat_id, + "message_id": message_id, + "text": text, + "entities": entities, + "parse_mode": parse_mode, + "reply_markup": reply_markup, + "wait": wait, + } + ) + return Message(message_id=message_id) + + async def delete_message(self, chat_id: int, message_id: int) -> bool: + self.delete_calls.append({"chat_id": chat_id, "message_id": message_id}) + return True + + async def set_my_commands( + self, + commands: list[dict[str, Any]], + *, + scope: dict[str, Any] | None = None, + language_code: str | None = None, + ) -> bool: + self.command_calls.append( + { + "commands": commands, + "scope": scope, + "language_code": language_code, + } + ) + return True + + async def get_me(self) -> User | None: + return User(id=1, username="bot") + + async def get_chat(self, chat_id: int) -> Chat | None: + _ = chat_id + return Chat(id=chat_id, type="supergroup", is_forum=True) + + async def get_chat_member(self, chat_id: int, user_id: int) -> ChatMember | None: + _ = chat_id + _ = user_id + return ChatMember(status="administrator", can_manage_topics=True) + + async def create_forum_topic(self, chat_id: int, name: str) -> ForumTopic | None: + _ = chat_id + _ = name + return ForumTopic(message_thread_id=1) + + async def edit_forum_topic( + self, chat_id: int, message_thread_id: int, name: str + ) -> bool: + self.edit_topic_calls.append( + { + "chat_id": chat_id, + "message_thread_id": message_thread_id, + "name": name, + } + ) + return True + + async def close(self) -> None: + return None + + async def answer_callback_query( + self, + callback_query_id: str, + text: str | None = None, + show_alert: bool | None = None, + ) -> bool: + self.callback_calls.append( + { + "callback_query_id": callback_query_id, + "text": text, + "show_alert": show_alert, + } + ) + return True + + +def make_cfg( + transport: FakeTransport, + runner: ScriptRunner | None = None, + *, + engine_id: str = DEFAULT_ENGINE_ID, + forward_coalesce_s: float = 0.0, + media_group_debounce_s: float = 0.0, +) -> TelegramBridgeConfig: + if runner is None: + runner = ScriptRunner([Return(answer="ok")], engine=engine_id) + exec_cfg = ExecBridgeConfig( + transport=transport, + presenter=MarkdownPresenter(), + final_notify=True, + ) + runtime = TransportRuntime( + router=_make_router(runner), + projects=_empty_projects(), + ) + return TelegramBridgeConfig( + bot=FakeBot(), + runtime=runtime, + chat_id=123, + startup_msg="", + exec_cfg=exec_cfg, + forward_coalesce_s=forward_coalesce_s, + media_group_debounce_s=media_group_debounce_s, + ) diff --git a/tests/test_api_exports.py b/tests/test_api_exports.py new file mode 100644 index 0000000..c3cb453 --- /dev/null +++ b/tests/test_api_exports.py @@ -0,0 +1,7 @@ +from takopi import api + + +def test_api_exports() -> None: + assert api.TAKOPI_PLUGIN_API_VERSION == 1 + assert "TransportRuntime" in api.__all__ + assert api.TransportRuntime is not None diff --git a/tests/test_cli_auto_router.py b/tests/test_cli_auto_router.py new file mode 100644 index 0000000..9edcf64 --- /dev/null +++ b/tests/test_cli_auto_router.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from takopi import cli +from takopi.backends import EngineBackend, SetupIssue +from takopi.settings import TakopiSettings +from takopi.transports import SetupResult + + +@dataclass +class _DummyLock: + released: bool = False + + def release(self) -> None: + self.released = True + + +class _FakeTransport: + id = "fake" + description = "fake transport" + + def __init__(self, setup: SetupResult) -> None: + self._setup = setup + self.check_calls: list[tuple[object, str | None]] = [] + self.lock_calls: list[tuple[object, Path]] = [] + self.build_calls: list[dict[str, object]] = [] + + def check_setup(self, engine_backend, *, transport_override=None) -> SetupResult: + self.check_calls.append((engine_backend, transport_override)) + return self._setup + + def interactive_setup(self, *, force: bool) -> bool: + _ = force + return True + + def lock_token(self, *, transport_config: object, _config_path: Path) -> str | None: + self.lock_calls.append((transport_config, _config_path)) + return "lock" + + def build_and_run( + self, + *, + transport_config: object, + config_path: Path, + runtime, + final_notify: bool, + default_engine_override: str | None, + ) -> None: + self.build_calls.append( + { + "transport_config": transport_config, + "config_path": config_path, + "runtime": runtime, + "final_notify": final_notify, + "default_engine_override": default_engine_override, + } + ) + + +def _engine_backend() -> EngineBackend: + return EngineBackend(id="codex", build_runner=lambda _cfg, _path: None) + + +def _settings() -> TakopiSettings: + return TakopiSettings.model_validate( + { + "transport": "telegram", + "transports": {"telegram": {"bot_token": "token", "chat_id": 123}}, + } + ) + + +def test_run_auto_router_success_releases_lock(monkeypatch, tmp_path: Path) -> None: + setup = SetupResult(issues=[], config_path=tmp_path / "takopi.toml") + transport = _FakeTransport(setup) + engine_backend = _engine_backend() + config_path = tmp_path / "takopi.toml" + + monkeypatch.setattr( + cli, + "_resolve_setup_engine", + lambda _override: (None, None, None, "codex", engine_backend), + ) + monkeypatch.setattr(cli, "_resolve_transport_id", lambda _override: "wire") + monkeypatch.setattr(cli, "get_transport", lambda _id, allowlist=None: transport) + monkeypatch.setattr(cli, "load_settings", lambda: (_settings(), config_path)) + monkeypatch.setattr(cli, "setup_logging", lambda **_kwargs: None) + + spec_calls: dict[str, object] = {} + + class _Spec: + def to_runtime(self, *, config_path: Path): + spec_calls["runtime_config_path"] = config_path + return "runtime" + + def _build_runtime_spec(**kwargs): + spec_calls.update(kwargs) + return _Spec() + + monkeypatch.setattr(cli, "build_runtime_spec", _build_runtime_spec) + lock = _DummyLock() + monkeypatch.setattr(cli, "acquire_config_lock", lambda _path, _token: lock) + + cli._run_auto_router( + default_engine_override=None, + transport_override="wire", + final_notify=True, + debug=False, + onboard=False, + ) + + assert transport.build_calls + assert lock.released is True + assert spec_calls["reserved"] == cli.RESERVED_CHAT_COMMANDS + assert transport.lock_calls[0][0] == {} + + +def test_run_auto_router_requires_tty_for_onboard(monkeypatch, tmp_path: Path) -> None: + setup = SetupResult(issues=[], config_path=tmp_path / "takopi.toml") + transport = _FakeTransport(setup) + + monkeypatch.setattr( + cli, + "_resolve_setup_engine", + lambda _override: (None, None, None, "codex", _engine_backend()), + ) + monkeypatch.setattr(cli, "_resolve_transport_id", lambda _override: "fake") + monkeypatch.setattr(cli, "get_transport", lambda _id, allowlist=None: transport) + monkeypatch.setattr(cli, "_should_run_interactive", lambda: False) + monkeypatch.setattr(cli, "setup_logging", lambda **_kwargs: None) + + with pytest.raises(cli.typer.Exit) as exc: + cli._run_auto_router( + default_engine_override=None, + transport_override=None, + final_notify=True, + debug=False, + onboard=True, + ) + + assert exc.value.exit_code == 1 + assert not transport.build_calls + + +def test_run_auto_router_missing_config_noninteractive( + monkeypatch, tmp_path: Path +) -> None: + setup = SetupResult( + issues=[SetupIssue(title="create a config", lines=())], + config_path=tmp_path / "missing.toml", + ) + transport = _FakeTransport(setup) + + monkeypatch.setattr( + cli, + "_resolve_setup_engine", + lambda _override: (None, None, None, "codex", _engine_backend()), + ) + monkeypatch.setattr(cli, "_resolve_transport_id", lambda _override: "fake") + monkeypatch.setattr(cli, "get_transport", lambda _id, allowlist=None: transport) + monkeypatch.setattr(cli, "_should_run_interactive", lambda: False) + monkeypatch.setattr(cli, "setup_logging", lambda **_kwargs: None) + + with pytest.raises(cli.typer.Exit) as exc: + cli._run_auto_router( + default_engine_override=None, + transport_override=None, + final_notify=True, + debug=False, + onboard=False, + ) + + assert exc.value.exit_code == 1 + assert not transport.build_calls diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py new file mode 100644 index 0000000..edcae53 --- /dev/null +++ b/tests/test_cli_commands.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +from pathlib import Path +import tomllib + +from typer.testing import CliRunner + +from takopi import cli +from takopi.config import ConfigError +from takopi.plugins import ( + COMMAND_GROUP, + ENGINE_GROUP, + TRANSPORT_GROUP, + PluginLoadError, +) +from takopi.settings import TakopiSettings +from tests.plugin_fixtures import FakeEntryPoint + + +def _min_config() -> dict: + return { + "transport": "telegram", + "transports": {"telegram": {"bot_token": "token", "chat_id": 123}}, + } + + +def test_init_registers_project(monkeypatch, tmp_path: Path) -> None: + config = _min_config() + config_path = tmp_path / "takopi.toml" + repo_path = tmp_path / "repo" + repo_path.mkdir() + monkeypatch.chdir(repo_path) + + monkeypatch.setattr(cli, "load_or_init_config", lambda: (config, config_path)) + monkeypatch.setattr(cli, "resolve_main_worktree_root", lambda _path: None) + monkeypatch.setattr(cli, "resolve_default_base", lambda _path: "main") + monkeypatch.setattr(cli, "list_backend_ids", lambda allowlist=None: ["codex"]) + monkeypatch.setattr(cli, "resolve_plugins_allowlist", lambda _settings: None) + monkeypatch.setattr(cli.typer, "prompt", lambda *args, **kwargs: "demo") + + runner = CliRunner() + result = runner.invoke(cli.create_app(), ["init", "--default"]) + + assert result.exit_code == 0 + assert "saved project" in result.output + + data = tomllib.loads(config_path.read_text(encoding="utf-8")) + project = data["projects"]["demo"] + assert project["path"] == str(repo_path) + assert project["default_engine"] == "codex" + assert project["worktrees_dir"] == ".worktrees" + assert project["worktree_base"] == "main" + assert data["default_project"] == "demo" + + +def test_init_declines_overwrite(monkeypatch, tmp_path: Path) -> None: + config = _min_config() + config["projects"] = {"demo": {"path": "/tmp/repo"}} + config_path = tmp_path / "takopi.toml" + repo_path = tmp_path / "repo" + repo_path.mkdir() + monkeypatch.chdir(repo_path) + + monkeypatch.setattr(cli, "load_or_init_config", lambda: (config, config_path)) + monkeypatch.setattr(cli, "resolve_main_worktree_root", lambda _path: None) + monkeypatch.setattr(cli, "resolve_default_base", lambda _path: None) + monkeypatch.setattr(cli, "list_backend_ids", lambda allowlist=None: ["codex"]) + monkeypatch.setattr(cli, "resolve_plugins_allowlist", lambda _settings: None) + monkeypatch.setattr(cli.typer, "confirm", lambda *args, **kwargs: False) + + runner = CliRunner() + result = runner.invoke(cli.create_app(), ["init", "demo"]) + + assert result.exit_code == 1 + + +def test_plugins_cmd_loads_and_reports_errors(monkeypatch) -> None: + entrypoints = { + ENGINE_GROUP: [ + FakeEntryPoint( + "codex", + "takopi.runners.codex:BACKEND", + ENGINE_GROUP, + dist_name="takopi", + ), + FakeEntryPoint( + "broken", + "takopi.runners.broken:BACKEND", + ENGINE_GROUP, + dist_name="takopi", + ), + ], + TRANSPORT_GROUP: [ + FakeEntryPoint( + "telegram", + "takopi.transports.telegram:BACKEND", + TRANSPORT_GROUP, + dist_name="takopi", + ) + ], + COMMAND_GROUP: [ + FakeEntryPoint( + "hello", + "takopi.commands.hello:BACKEND", + COMMAND_GROUP, + dist_name="thirdparty", + ) + ], + } + + def _list_entrypoints(group: str, reserved_ids=None): + _ = reserved_ids + return entrypoints[group] + + calls: list[tuple[str, str]] = [] + + def _get_backend(name: str, allowlist=None): + _ = allowlist + calls.append(("engine", name)) + if name == "broken": + raise ConfigError("boom") + return object() + + def _get_transport(name: str, allowlist=None): + _ = allowlist + calls.append(("transport", name)) + return object() + + def _get_command(name: str, allowlist=None): + _ = allowlist + calls.append(("command", name)) + return object() + + monkeypatch.setattr(cli, "_load_settings_optional", lambda: (None, None)) + monkeypatch.setattr(cli, "resolve_plugins_allowlist", lambda _settings: ["takopi"]) + monkeypatch.setattr(cli, "list_entrypoints", _list_entrypoints) + monkeypatch.setattr(cli, "get_backend", _get_backend) + monkeypatch.setattr(cli, "get_transport", _get_transport) + monkeypatch.setattr(cli, "get_command", _get_command) + monkeypatch.setattr( + cli, + "get_load_errors", + lambda: [ + PluginLoadError( + ENGINE_GROUP, + "broken", + "takopi.runners.broken:BACKEND", + "takopi", + "boom", + ), + PluginLoadError( + TRANSPORT_GROUP, + "wire", + "takopi.transports.wire:BACKEND", + "takopi", + "missing", + ), + PluginLoadError( + COMMAND_GROUP, + "hello", + "takopi.commands.hello:BACKEND", + "thirdparty", + "oops", + ), + ], + ) + + runner = CliRunner() + result = runner.invoke(cli.create_app(), ["plugins", "--load"]) + + assert result.exit_code == 0 + assert "engine backends:" in result.output + assert "transport backends:" in result.output + assert "command backends:" in result.output + assert "codex (takopi) enabled" in result.output + assert "hello (thirdparty) disabled" in result.output + assert "errors:" in result.output + assert "engine broken (takopi): boom" in result.output + assert "transport wire (takopi): missing" in result.output + assert "command hello (thirdparty): oops" in result.output + + assert ("engine", "codex") in calls + assert ("engine", "broken") in calls + assert ("transport", "telegram") in calls + assert ("command", "hello") not in calls + + +def test_onboarding_paths_calls_debug(monkeypatch) -> None: + called = {"count": 0} + + def _debug() -> None: + called["count"] += 1 + + monkeypatch.setattr(cli.onboarding, "debug_onboarding_paths", _debug) + + runner = CliRunner() + result = runner.invoke(cli.create_app(), ["onboarding-paths"]) + + assert result.exit_code == 0 + assert called["count"] == 1 + + +def test_config_path_cmd_outputs_override(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + runner = CliRunner() + result = runner.invoke( + cli.create_app(), + ["config", "path", "--config-path", str(config_path)], + ) + + assert result.exit_code == 0 + assert result.output.strip() == str(config_path) + + +def test_config_path_cmd_defaults_to_home(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + config_path = tmp_path / ".takopi" / "takopi.toml" + monkeypatch.setattr(cli, "HOME_CONFIG_PATH", config_path) + + runner = CliRunner() + result = runner.invoke(cli.create_app(), ["config", "path"]) + + assert result.exit_code == 0 + assert result.output.strip() == "~/.takopi/takopi.toml" + + +def test_doctor_rejects_non_telegram_transport(monkeypatch) -> None: + settings = TakopiSettings.model_validate( + { + "transport": "local", + "transports": {"telegram": {"bot_token": "token", "chat_id": 123}}, + } + ) + monkeypatch.setattr(cli, "load_settings", lambda: (settings, Path("x"))) + + runner = CliRunner() + result = runner.invoke(cli.create_app(), ["doctor"]) + + assert result.exit_code == 1 + assert "telegram transport only" in result.output diff --git a/tests/test_cli_doctor.py b/tests/test_cli_doctor.py index 17a8677..8317f0a 100644 --- a/tests/test_cli_doctor.py +++ b/tests/test_cli_doctor.py @@ -1,9 +1,13 @@ from pathlib import Path +import pytest from typer.testing import CliRunner from takopi import cli +from takopi.config import ConfigError from takopi.settings import TakopiSettings +from takopi.settings import TelegramTopicsSettings +from takopi.telegram.api_models import Chat, User def _settings() -> TakopiSettings: @@ -50,3 +54,72 @@ def test_doctor_errors_exit_nonzero(monkeypatch) -> None: assert result.exit_code == 1 assert "telegram token: error" in result.output + + +class _FakeBot: + def __init__(self, me: User | None, chat: Chat | None) -> None: + self._me = me + self._chat = chat + self.closed = False + + async def get_me(self) -> User | None: + return self._me + + async def get_chat(self, chat_id: int) -> Chat | None: + _ = chat_id + return self._chat + + async def close(self) -> None: + self.closed = True + + +@pytest.mark.anyio +async def test_doctor_telegram_checks_invalid_token(monkeypatch) -> None: + bot = _FakeBot(me=None, chat=None) + monkeypatch.setattr(cli, "TelegramClient", lambda _token: bot) + topics = TelegramTopicsSettings(enabled=True) + + checks = await cli._doctor_telegram_checks( + "token", + 123, + topics, + (), + ) + + assert [check.label for check in checks] == [ + "telegram token", + "chat_id", + "topics", + ] + assert checks[0].status == "error" + assert checks[1].detail == "skipped (token invalid)" + assert checks[2].detail == "skipped (token invalid)" + assert bot.closed is True + + +@pytest.mark.anyio +async def test_doctor_telegram_checks_chat_and_topics_error(monkeypatch) -> None: + bot = _FakeBot( + me=User(id=1, username="bot", first_name=None, last_name=None), + chat=None, + ) + monkeypatch.setattr(cli, "TelegramClient", lambda _token: bot) + + async def _raise_topics(*_args, **_kwargs) -> None: + raise ConfigError("bad topics") + + monkeypatch.setattr(cli, "_validate_topics_setup_for", _raise_topics) + topics = TelegramTopicsSettings(enabled=True) + + checks = await cli._doctor_telegram_checks( + "token", + 321, + topics, + (), + ) + + assert checks[0].detail == "@bot" + assert checks[1].status == "error" + assert "unreachable" in (checks[1].detail or "") + assert checks[2].detail == "bad topics" + assert bot.closed is True diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py new file mode 100644 index 0000000..1f0ff65 --- /dev/null +++ b/tests/test_cli_helpers.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import pytest + +from takopi import cli +from takopi.config import ConfigError +from takopi.lockfile import LockError +from takopi.settings import TakopiSettings + + +def _settings(overrides: dict | None = None) -> TakopiSettings: + payload = { + "transport": "telegram", + "transports": {"telegram": {"bot_token": "token", "chat_id": 123}}, + } + if overrides: + payload.update(overrides) + return TakopiSettings.model_validate(payload) + + +def test_parse_key_path_valid() -> None: + assert cli._parse_key_path("transports.telegram.chat_id") == [ + "transports", + "telegram", + "chat_id", + ] + + +def test_parse_key_path_invalid_segment() -> None: + with pytest.raises(ConfigError): + cli._parse_key_path("transports..chat_id") + + +def test_parse_value_toml_and_fallback() -> None: + assert cli._parse_value("true") is True + assert cli._parse_value("123") == 123 + assert cli._parse_value("not-toml") == "not-toml" + + +def test_toml_literal_and_error() -> None: + assert cli._toml_literal("hello") == '"hello"' + with pytest.raises(ConfigError): + cli._toml_literal({"a": 1}) + + +def test_flatten_config() -> None: + flattened = cli._flatten_config( + {"transports": {"telegram": {"chat_id": 123}}, "watch_config": True} + ) + assert ("transports.telegram.chat_id", 123) in flattened + assert ("watch_config", True) in flattened + + +def test_normalized_value_from_settings() -> None: + settings = _settings() + assert cli._normalized_value_from_settings(settings, ["transport"]) == "telegram" + assert ( + cli._normalized_value_from_settings( + settings, ["transports", "telegram", "chat_id"] + ) + == 123 + ) + + +def test_should_run_interactive(monkeypatch) -> None: + class _Tty: + def isatty(self) -> bool: + return True + + class _NotTty: + def isatty(self) -> bool: + return False + + monkeypatch.setenv("TAKOPI_NO_INTERACTIVE", "1") + assert cli._should_run_interactive() is False + monkeypatch.delenv("TAKOPI_NO_INTERACTIVE") + + monkeypatch.setattr(cli.sys, "stdin", _Tty()) + monkeypatch.setattr(cli.sys, "stdout", _Tty()) + assert cli._should_run_interactive() is True + + monkeypatch.setattr(cli.sys, "stdin", _NotTty()) + monkeypatch.setattr(cli.sys, "stdout", _Tty()) + assert cli._should_run_interactive() is False + + +def test_resolve_transport_id_override(monkeypatch) -> None: + assert cli._resolve_transport_id(" telegram ") == "telegram" + with pytest.raises(ConfigError): + cli._resolve_transport_id(" ") + + def _raise() -> None: + raise ConfigError("boom") + + monkeypatch.setattr(cli, "load_or_init_config", _raise) + assert cli._resolve_transport_id(None) == "telegram" + + +def test_doctor_file_checks() -> None: + settings = _settings() + checks = cli._doctor_file_checks(settings) + assert checks[0].detail == "disabled" + + settings = _settings( + { + "transports": { + "telegram": { + "bot_token": "token", + "chat_id": 1, + "files": {"enabled": True}, + } + } + } + ) + checks = cli._doctor_file_checks(settings) + assert checks[0].status == "warning" + + +def test_doctor_voice_checks(monkeypatch) -> None: + settings = _settings() + checks = cli._doctor_voice_checks(settings) + assert checks[0].detail == "disabled" + + settings = _settings( + { + "transports": { + "telegram": { + "bot_token": "token", + "chat_id": 1, + "voice_transcription": True, + } + } + } + ) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + checks = cli._doctor_voice_checks(settings) + assert checks[0].status == "error" + + monkeypatch.setenv("OPENAI_API_KEY", "key") + checks = cli._doctor_voice_checks(settings) + assert checks[0].status == "ok" + + +def test_load_settings_optional(monkeypatch, tmp_path) -> None: + def _raise() -> None: + raise ConfigError("boom") + + monkeypatch.setattr(cli, "load_settings_if_exists", _raise) + assert cli._load_settings_optional() == (None, None) + + monkeypatch.setattr(cli, "load_settings_if_exists", lambda: None) + assert cli._load_settings_optional() == (None, None) + + settings = _settings() + config_path = tmp_path / "takopi.toml" + monkeypatch.setattr(cli, "load_settings_if_exists", lambda: (settings, config_path)) + assert cli._load_settings_optional() == (settings, config_path) + + +def test_acquire_config_lock_reports_error(monkeypatch, tmp_path) -> None: + config_path = tmp_path / "takopi.toml" + error = LockError(path=config_path, state="running") + + def _raise(*_args, **_kwargs): + raise error + + messages: list[tuple[str, bool]] = [] + monkeypatch.setattr(cli, "acquire_lock", _raise) + monkeypatch.setattr( + cli.typer, "echo", lambda msg, err=False: messages.append((msg, err)) + ) + + with pytest.raises(cli.typer.Exit) as exc: + cli.acquire_config_lock(config_path, "token") + + assert exc.value.exit_code == 1 + assert any("already running" in msg for msg, _ in messages) diff --git a/tests/test_codex_runner_helpers.py b/tests/test_codex_runner_helpers.py new file mode 100644 index 0000000..83af546 --- /dev/null +++ b/tests/test_codex_runner_helpers.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from takopi.backends import EngineConfig +from takopi.config import ConfigError +from takopi.events import EventFactory +from takopi.model import ActionEvent, CompletedEvent, StartedEvent +from takopi.runners.codex import ( + CodexRunner, + _format_change_summary, + _normalize_change_list, + _parse_reconnect_message, + _short_tool_name, + _summarize_todo_list, + _summarize_tool_result, + _todo_title, + build_runner, + find_exec_only_flag, + translate_codex_event, +) +from takopi.schemas import codex as codex_schema + + +def test_codex_helper_functions() -> None: + assert find_exec_only_flag(["--json"]) == "--json" + assert find_exec_only_flag(["--output-schema=foo"]) == "--output-schema=foo" + assert find_exec_only_flag(["--model", "gpt-4"]) is None + + assert _parse_reconnect_message("Reconnecting... 2/5") == (2, 5) + assert _parse_reconnect_message("Reconnecting... x/y") is None + assert _parse_reconnect_message("nope") is None + + assert _short_tool_name("docs", "search") == "docs.search" + assert _short_tool_name(None, "search") == "search" + assert _short_tool_name(None, None) == "tool" + + summary = _summarize_tool_result({"content": ["hi"], "structured": {"ok": True}}) + assert summary == {"content_blocks": 1, "has_structured": True} + summary = _summarize_tool_result({"content": "hello", "structured_content": None}) + assert summary == {"content_blocks": 1, "has_structured": False} + assert _summarize_tool_result({"other": 1}) is None + + changes = [ + codex_schema.FileUpdateChange(path="a.txt", kind="update"), + {"path": "b.txt", "kind": "delete"}, + {"path": ""}, + ] + assert _normalize_change_list(changes) == [ + {"path": "a.txt", "kind": "update"}, + {"path": "b.txt", "kind": "delete"}, + ] + assert _format_change_summary(changes) == "a.txt, b.txt" + assert _format_change_summary([{"path": ""}]) == "1 files" + + +def test_summarize_todo_list_and_title() -> None: + items = [ + codex_schema.TodoItem(text="first", completed=True), + codex_schema.TodoItem(text="next", completed=False), + {"text": "later", "completed": False}, + ] + summary = _summarize_todo_list(items) + assert summary.done == 1 + assert summary.total == 3 + assert summary.next_text == "next" + assert _todo_title(summary) == "todo 1/3: next" + + done_summary = _summarize_todo_list([{"text": "done", "completed": True}]) + assert _todo_title(done_summary) == "todo 1/1: done" + assert _todo_title(_summarize_todo_list("nope")) == "todo" + + +def test_translate_codex_events_for_items() -> None: + factory = EventFactory("codex") + event = codex_schema.ItemStarted( + item=codex_schema.WebSearchItem(id="w1", query="query") + ) + out = translate_codex_event(event, title="Codex", factory=factory) + assert len(out) == 1 + assert isinstance(out[0], ActionEvent) + assert out[0].action.kind == "web_search" + assert out[0].phase == "started" + + event = codex_schema.ItemCompleted( + item=codex_schema.WebSearchItem(id="w1", query="query") + ) + out = translate_codex_event(event, title="Codex", factory=factory) + assert isinstance(out[0], ActionEvent) + assert out[0].phase == "completed" + assert out[0].ok is True + + event = codex_schema.ItemStarted( + item=codex_schema.ReasoningItem(id="r1", text="thinking") + ) + out = translate_codex_event(event, title="Codex", factory=factory) + assert isinstance(out[0], ActionEvent) + assert out[0].action.kind == "note" + assert out[0].action.title == "thinking" + + event = codex_schema.ItemUpdated( + item=codex_schema.TodoListItem( + id="t1", + items=[ + codex_schema.TodoItem(text="todo one", completed=False), + codex_schema.TodoItem(text="todo two", completed=True), + ], + ) + ) + out = translate_codex_event(event, title="Codex", factory=factory) + assert isinstance(out[0], ActionEvent) + assert out[0].action.detail["done"] == 1 + assert out[0].action.detail["total"] == 2 + assert "todo 1/2" in out[0].action.title + + started = codex_schema.ItemStarted( + item=codex_schema.ErrorItem(id="e1", message="boom") + ) + assert translate_codex_event(started, title="Codex", factory=factory) == [] + + completed = codex_schema.ItemCompleted( + item=codex_schema.ErrorItem(id="e1", message="boom") + ) + out = translate_codex_event(completed, title="Codex", factory=factory) + assert isinstance(out[0], ActionEvent) + assert out[0].action.kind == "warning" + assert out[0].ok is False + + +def test_translate_codex_thread_started() -> None: + factory = EventFactory("codex") + event = codex_schema.ThreadStarted(thread_id="sess-1") + out = translate_codex_event(event, title="Codex", factory=factory) + assert len(out) == 1 + assert isinstance(out[0], StartedEvent) + assert out[0].resume.value == "sess-1" + + +def test_codex_runner_translate_reconnect_message() -> None: + runner = CodexRunner(codex_cmd="codex", extra_args=[]) + state = runner.new_state("hi", None) + event = codex_schema.StreamError(message="Reconnecting... 2/3") + out = runner.translate(event, state=state, resume=None, found_session=None) + assert len(out) == 1 + assert isinstance(out[0], ActionEvent) + assert out[0].phase == "updated" + assert out[0].action.detail["attempt"] == 2 + assert out[0].action.detail["max"] == 3 + + +def test_codex_runner_process_and_stream_end_events() -> None: + runner = CodexRunner(codex_cmd="codex", extra_args=[]) + state = runner.new_state("hi", None) + + out = runner.process_error_events(2, resume=None, found_session=None, state=state) + assert len(out) == 2 + completed = out[-1] + assert isinstance(completed, CompletedEvent) + assert completed.ok is False + + end = runner.stream_end_events(resume=None, found_session=None, state=state) + assert len(end) == 1 + end_event = end[0] + assert isinstance(end_event, CompletedEvent) + assert end_event.ok is False + + started = translate_codex_event( + codex_schema.ThreadStarted(thread_id="sess-2"), + title="Codex", + factory=EventFactory("codex"), + )[0] + assert isinstance(started, StartedEvent) + end = runner.stream_end_events( + resume=None, + found_session=started.resume, + state=state, + ) + end_event = end[0] + assert isinstance(end_event, CompletedEvent) + assert end_event.ok is True + + +def test_codex_build_runner_configs(tmp_path: Path) -> None: + cfg: EngineConfig = {} + runner = build_runner(cfg, tmp_path) + assert isinstance(runner, CodexRunner) + assert runner.extra_args == ["-c", "notify=[]"] + + cfg = {"extra_args": ["--foo"], "profile": "Demo"} + runner = build_runner(cfg, tmp_path) + assert isinstance(runner, CodexRunner) + assert runner.extra_args[-2:] == ["--profile", "Demo"] + assert runner.session_title == "Demo" + + with pytest.raises(ConfigError): + build_runner({"extra_args": ["--json"]}, tmp_path) + + with pytest.raises(ConfigError): + build_runner({"extra_args": ["--foo", 1]}, tmp_path) + + with pytest.raises(ConfigError): + build_runner({"profile": 123}, tmp_path) diff --git a/tests/test_exec_bridge.py b/tests/test_exec_bridge.py index 6a89f3c..32ff833 100644 --- a/tests/test_exec_bridge.py +++ b/tests/test_exec_bridge.py @@ -16,7 +16,7 @@ from tests.factories import action_completed, action_started CODEX_ENGINE = "codex" -class _FakeTransport: +class FakeTransport: def __init__(self) -> None: self._next_id = 1 self.send_calls: list[dict] = [] @@ -181,7 +181,7 @@ def test_prepare_telegram_preserves_entities_on_truncate() -> None: @pytest.mark.anyio async def test_final_notify_sends_loud_final_message() -> None: - transport = _FakeTransport() + transport = FakeTransport() runner = _return_runner(answer="ok") cfg = ExecBridgeConfig( transport=transport, @@ -203,7 +203,7 @@ async def test_final_notify_sends_loud_final_message() -> None: @pytest.mark.anyio async def test_handle_message_strips_resume_line_from_prompt() -> None: - transport = _FakeTransport() + transport = FakeTransport() runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) cfg = ExecBridgeConfig( transport=transport, @@ -228,7 +228,7 @@ async def test_handle_message_strips_resume_line_from_prompt() -> None: @pytest.mark.anyio async def test_long_final_message_edits_progress_message() -> None: - transport = _FakeTransport() + transport = FakeTransport() runner = _return_runner(answer="x" * 10_000) cfg = ExecBridgeConfig( transport=transport, @@ -251,7 +251,7 @@ async def test_long_final_message_edits_progress_message() -> None: @pytest.mark.anyio async def test_progress_edits_are_best_effort() -> None: - transport = _FakeTransport() + transport = FakeTransport() clock = _FakeClock() events: list[TakopiEvent] = [ action_started("item_0", "command", "echo 1"), @@ -288,7 +288,7 @@ async def test_progress_edits_are_best_effort() -> None: @pytest.mark.anyio async def test_bridge_flow_sends_progress_edits_and_final_resume() -> None: - transport = _FakeTransport() + transport = FakeTransport() clock = _FakeClock() events: list[TakopiEvent] = [ action_started("item_0", "command", "echo ok"), @@ -336,7 +336,7 @@ async def test_bridge_flow_sends_progress_edits_and_final_resume() -> None: @pytest.mark.anyio async def test_final_message_includes_ctx_line() -> None: - transport = _FakeTransport() + transport = FakeTransport() clock = _FakeClock() session_id = "123e4567-e89b-12d3-a456-426614174000" runner = ScriptRunner( @@ -367,7 +367,7 @@ async def test_final_message_includes_ctx_line() -> None: @pytest.mark.anyio async def test_handle_message_cancelled_renders_cancelled_state() -> None: - transport = _FakeTransport() + transport = FakeTransport() session_id = "019b66fc-64c2-7a71-81cd-081c504cfeb2" hold = anyio.Event() runner = ScriptRunner( @@ -414,7 +414,7 @@ async def test_handle_message_cancelled_renders_cancelled_state() -> None: @pytest.mark.anyio async def test_handle_message_error_preserves_resume_token() -> None: - transport = _FakeTransport() + transport = FakeTransport() session_id = "019b66fc-64c2-7a71-81cd-081c504cfeb2" runner = ScriptRunner( [Raise(RuntimeError("boom"))], diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index 70f9024..5ef22d3 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -1,5 +1,7 @@ from pathlib import Path +import subprocess +from takopi.utils.git import git_is_worktree, git_ok, git_run, git_stdout from takopi.utils.git import resolve_default_base, resolve_main_worktree_root @@ -53,3 +55,77 @@ def test_resolve_default_base_prefers_master_over_main(monkeypatch) -> None: monkeypatch.setattr("takopi.utils.git.git_stdout", _fake_stdout) monkeypatch.setattr("takopi.utils.git.git_ok", _fake_ok) assert resolve_default_base(Path("/repo")) == "master" + + +def test_resolve_default_base_uses_origin_head(monkeypatch) -> None: + def _fake_stdout(args, **kwargs): + if args[:2] == ["symbolic-ref", "-q"]: + return "refs/remotes/origin/main" + return None + + monkeypatch.setattr("takopi.utils.git.git_stdout", _fake_stdout) + assert resolve_default_base(Path("/repo")) == "main" + + +def test_resolve_default_base_uses_current_branch(monkeypatch) -> None: + def _fake_stdout(args, **kwargs): + if args[:2] == ["symbolic-ref", "-q"]: + return None + if args == ["branch", "--show-current"]: + return "feature" + return None + + monkeypatch.setattr("takopi.utils.git.git_stdout", _fake_stdout) + assert resolve_default_base(Path("/repo")) == "feature" + + +def test_git_run_handles_missing_git(monkeypatch) -> None: + def _raise(*_args, **_kwargs): + raise FileNotFoundError + + monkeypatch.setattr("takopi.utils.git.subprocess.run", _raise) + assert git_run(["status"], cwd=Path("/repo")) is None + + +def test_git_stdout_returns_none_on_error(monkeypatch) -> None: + def _fake_run(*_args, **_kwargs): + return subprocess.CompletedProcess( + args=["git"], + returncode=1, + stdout="oops", + stderr="", + ) + + monkeypatch.setattr("takopi.utils.git._run_git", _fake_run) + assert git_stdout(["status"], cwd=Path("/repo")) is None + + +def test_git_ok_false_when_run_missing(monkeypatch) -> None: + monkeypatch.setattr("takopi.utils.git._run_git", lambda *_args, **_kwargs: None) + assert git_ok(["status"], cwd=Path("/repo")) is False + + +def test_git_stdout_returns_trimmed_output(monkeypatch) -> None: + def _fake_run(*_args, **_kwargs): + return subprocess.CompletedProcess( + args=["git"], + returncode=0, + stdout=" ok \n", + stderr="", + ) + + monkeypatch.setattr("takopi.utils.git._run_git", _fake_run) + assert git_stdout(["status"], cwd=Path("/repo")) == "ok" + + +def test_git_is_worktree_false_when_no_top(monkeypatch) -> None: + monkeypatch.setattr("takopi.utils.git.git_stdout", lambda *_a, **_k: None) + assert git_is_worktree(Path("/repo")) is False + + +def test_git_is_worktree_matches_path(monkeypatch) -> None: + monkeypatch.setattr( + "takopi.utils.git.git_stdout", + lambda *_a, **_k: "/repo", + ) + assert git_is_worktree(Path("/repo")) is True diff --git a/tests/test_onboarding_helpers.py b/tests/test_onboarding_helpers.py new file mode 100644 index 0000000..83124a6 --- /dev/null +++ b/tests/test_onboarding_helpers.py @@ -0,0 +1,481 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +import pytest +from rich.table import Table +from rich.text import Text + +from takopi.config import ConfigError +from takopi.telegram import onboarding +from takopi.telegram.api_models import Update, User +from takopi.telegram.client import TelegramRetryAfter + + +class DummyUI: + def __init__( + self, + *, + confirms: list[bool | None] | None = None, + selects: list[Any] | None = None, + passwords: list[str | None] | None = None, + ) -> None: + self.confirms = iter(confirms or []) + self.selects = iter(selects or []) + self.passwords = iter(passwords or []) + self.printed: list[object] = [] + self.steps: list[tuple[str, int]] = [] + + def panel( + self, title: str | None, body: str, *, border_style: str = "yellow" + ) -> None: + self.printed.append(("panel", title, body, border_style)) + + def step(self, title: str, *, number: int) -> None: + self.steps.append((title, number)) + + def print(self, text: object = "", *, markup: bool | None = None) -> None: + _ = markup + self.printed.append(text) + + async def confirm(self, _prompt: str, default: bool = True) -> bool | None: + _ = default + return next(self.confirms) + + async def select(self, _prompt: str, choices: list[tuple[str, Any]]) -> Any | None: + _ = choices + return next(self.selects) + + async def password(self, _prompt: str) -> str | None: + return next(self.passwords) + + +class DummyServices: + def __init__( + self, + *, + bot_info: list[User | None] | None = None, + chat: onboarding.ChatInfo | None = None, + topics_issue: ConfigError | None = None, + engines: list[tuple[str, bool, str | None]] | None = None, + config: dict[str, Any] | None = None, + ) -> None: + self.bot_info = iter(bot_info or []) + self.chat = chat + self.topics_issue = topics_issue + self.engines = engines or [] + self.config = config or {} + self.writes: list[tuple[Path, dict[str, Any]]] = [] + + async def get_bot_info(self, _token: str) -> User | None: + return next(self.bot_info) + + async def wait_for_chat(self, _token: str) -> onboarding.ChatInfo: + assert self.chat is not None + return self.chat + + async def validate_topics( + self, _token: str, _chat_id: int, _scope: onboarding.TopicScope + ) -> ConfigError | None: + return self.topics_issue + + def list_engines(self) -> list[tuple[str, bool, str | None]]: + return list(self.engines) + + def read_config(self, _path: Path) -> dict[str, Any]: + return dict(self.config) + + def write_config(self, path: Path, data: dict[str, Any]) -> None: + self.writes.append((path, data)) + + +def test_chat_info_display_and_kind() -> None: + chat = onboarding.ChatInfo( + chat_id=1, + username="alice", + title=None, + first_name=None, + last_name=None, + chat_type="private", + ) + assert chat.display == "@alice" + assert chat.kind == "private chat" + + group = onboarding.ChatInfo( + chat_id=2, + username=None, + title="Team", + first_name=None, + last_name=None, + chat_type="supergroup", + ) + assert group.display == 'group "Team"' + assert group.kind == 'supergroup "Team"' + + channel = onboarding.ChatInfo( + chat_id=3, + username=None, + title=None, + first_name=None, + last_name=None, + chat_type="channel", + ) + assert channel.display == "channel" + assert channel.kind == "channel" + + unnamed = onboarding.ChatInfo( + chat_id=4, + username=None, + title=None, + first_name="Ada", + last_name="Lovelace", + chat_type=None, + ) + assert unnamed.display == "Ada Lovelace" + assert unnamed.kind == "private chat" + + +def test_onboarding_state_helpers(tmp_path: Path) -> None: + state = onboarding.OnboardingState(config_path=tmp_path / "cfg", force=False) + assert state.is_stateful is False + assert state.bot_ref == "your bot" + + state.session_mode = "chat" + assert state.is_stateful is True + + state.session_mode = "stateless" + state.topics_enabled = True + assert state.is_stateful is True + + state.bot_name = "Takopi" + assert state.bot_ref == "Takopi" + state.bot_username = "takopi_bot" + assert state.bot_ref == "@takopi_bot" + + +def test_display_path(tmp_path: Path) -> None: + home_path = Path.home() / "takopi" / "cfg.toml" + assert onboarding.display_path(home_path).startswith("~/") + assert onboarding.display_path(tmp_path / "cfg.toml") == str(tmp_path / "cfg.toml") + + +def test_build_transport_patch_requires_fields(tmp_path: Path) -> None: + state = onboarding.OnboardingState(config_path=tmp_path / "cfg", force=False) + with pytest.raises(RuntimeError, match="missing chat"): + onboarding.build_transport_patch(state, bot_token="x") + + state.chat = onboarding.ChatInfo( + chat_id=1, + username=None, + title=None, + first_name=None, + last_name=None, + chat_type="private", + ) + with pytest.raises(RuntimeError, match="missing session mode"): + onboarding.build_transport_patch(state, bot_token="x") + + state.session_mode = "chat" + with pytest.raises(RuntimeError, match="missing resume choice"): + onboarding.build_transport_patch(state, bot_token="x") + + +def test_build_config_patch_and_merge(tmp_path: Path) -> None: + state = onboarding.OnboardingState(config_path=tmp_path / "cfg", force=False) + state.chat = onboarding.ChatInfo( + chat_id=10, + username=None, + title=None, + first_name=None, + last_name=None, + chat_type="private", + ) + state.session_mode = "chat" + state.show_resume_line = False + state.default_engine = "codex" + state.topics_enabled = True + state.topics_scope = "all" + + patch = onboarding.build_config_patch(state, bot_token="token") + assert patch["default_engine"] == "codex" + + merged = onboarding.merge_config( + {"bot_token": "old", "chat_id": 1, "transports": {}}, + patch, + config_path=tmp_path / "cfg.toml", + ) + assert merged["transport"] == "telegram" + assert merged["transports"]["telegram"]["bot_token"] == "token" + assert merged["transports"]["telegram"]["topics"]["enabled"] is True + assert "bot_token" not in merged + assert "chat_id" not in merged + + +def test_render_helpers() -> None: + text = onboarding.render_botfather_instructions() + assert "BotFather" in text.plain + assert "send /newbot" in text.plain + + topics = onboarding.render_topics_group_instructions("@bot") + assert "topics" in topics.plain + + generic = onboarding.render_generic_capture_prompt("@bot") + assert "send /start" in generic.plain + + warning = onboarding.render_topics_validation_warning(ConfigError("boom")) + assert "warning" in warning.plain + + config_warning = onboarding.render_config_malformed_warning(ConfigError("bad")) + assert "config is malformed" in config_warning.plain + + backup_warning = onboarding.render_backup_failed_warning(OSError("nope")) + assert "failed to back up" in backup_warning.plain + + tabs = onboarding.render_persona_tabs() + assert isinstance(tabs, Table) + + preview = onboarding.render_workspace_preview() + assert "memory-box" in preview.plain + + assistant = onboarding.render_assistant_preview() + assert "make happy wings fit" in assistant.plain + + handoff = onboarding.render_handoff_preview() + assert "resume" in handoff.plain + + convo = Text() + onboarding.append_dialogue(convo, "bot", "hello", speaker_style="cyan") + assert "hello" in convo.plain + + +def test_render_engine_table_prints() -> None: + ui = DummyUI() + onboarding.render_engine_table( + cast(onboarding.UI, ui), + [("codex", True, None), ("other", False, "brew install other")], + ) + assert ui.printed + + +@pytest.mark.anyio +async def test_get_bot_info_retries(monkeypatch) -> None: + class _Bot: + def __init__(self, _token: str) -> None: + self.calls = 0 + self.closed = False + + async def get_me(self) -> User | None: + self.calls += 1 + if self.calls < 3: + raise TelegramRetryAfter(0) + return User(id=1, username="bot") + + async def close(self) -> None: + self.closed = True + + async def _sleep(_seconds: float) -> None: + return None + + monkeypatch.setattr(onboarding, "TelegramClient", _Bot) + monkeypatch.setattr(onboarding.anyio, "sleep", _sleep) + + info = await onboarding.get_bot_info("token") + assert info is not None + assert info.username == "bot" + + +@pytest.mark.anyio +async def test_get_bot_info_gives_up(monkeypatch) -> None: + class _Bot: + def __init__(self, _token: str) -> None: + self.calls = 0 + + async def get_me(self) -> User | None: + self.calls += 1 + raise TelegramRetryAfter(0) + + async def close(self) -> None: + return None + + async def _sleep(_seconds: float) -> None: + return None + + monkeypatch.setattr(onboarding, "TelegramClient", _Bot) + monkeypatch.setattr(onboarding.anyio, "sleep", _sleep) + + info = await onboarding.get_bot_info("token") + assert info is None + + +@pytest.mark.anyio +async def test_wait_for_chat_filters_updates(monkeypatch) -> None: + updates = [ + [Update(update_id=1, message={"from": {"is_bot": True}, "chat": {"id": 1}})], + None, + [], + [Update(update_id=2, message=None)], + [ + Update( + update_id=3, + message={"from": {"is_bot": True}, "chat": {"id": 2}}, + ) + ], + [ + Update( + update_id=4, + message={"from": {"is_bot": False}, "chat": "nope"}, + ) + ], + [ + Update( + update_id=5, + message={"from": {"is_bot": False}, "chat": {"id": "bad"}}, + ) + ], + [ + Update( + update_id=6, + message={ + "from": {"is_bot": False}, + "chat": {"id": 7, "username": "bob", "type": "private"}, + }, + ) + ], + ] + + class _Bot: + def __init__(self, _token: str) -> None: + self.calls = 0 + + async def get_updates(self, *args, **kwargs): + _ = args, kwargs + idx = self.calls + self.calls += 1 + return updates[idx] + + async def close(self) -> None: + return None + + async def _sleep(_seconds: float) -> None: + return None + + monkeypatch.setattr(onboarding, "TelegramClient", _Bot) + monkeypatch.setattr(onboarding.anyio, "sleep", _sleep) + + chat = await onboarding.wait_for_chat("token") + assert chat.chat_id == 7 + assert chat.username == "bob" + + +@pytest.mark.anyio +async def test_validate_topics_onboarding_errors(monkeypatch) -> None: + class _Bot: + def __init__(self, _token: str) -> None: + return None + + async def close(self) -> None: + return None + + async def _raise_config(*_args, **_kwargs): + raise ConfigError("bad") + + async def _raise_other(*_args, **_kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(onboarding, "TelegramClient", _Bot) + monkeypatch.setattr(onboarding, "_validate_topics_setup_for", _raise_config) + err = await onboarding.validate_topics_onboarding("token", 1, "auto", ()) + assert isinstance(err, ConfigError) + + monkeypatch.setattr(onboarding, "_validate_topics_setup_for", _raise_other) + err = await onboarding.validate_topics_onboarding("token", 1, "auto", ()) + assert isinstance(err, ConfigError) + assert "topics validation failed" in str(err) + + +@pytest.mark.anyio +async def test_prompt_token_success_and_retry() -> None: + ui = DummyUI(confirms=[True], passwords=["", "token", "token"]) # empty then retry + svc = DummyServices( + bot_info=[None, User(id=1, username="bot")], + ) + + token, info = await onboarding.prompt_token( + cast(onboarding.UI, ui), cast(onboarding.Services, svc) + ) + assert token == "token" + assert info.username == "bot" + + +@pytest.mark.anyio +async def test_prompt_token_cancel_on_failure() -> None: + ui = DummyUI(confirms=[False], passwords=["token"]) + svc = DummyServices(bot_info=[None]) + + with pytest.raises(onboarding.OnboardingCancelled): + await onboarding.prompt_token( + cast(onboarding.UI, ui), cast(onboarding.Services, svc) + ) + + +@pytest.mark.anyio +async def test_capture_chat_sets_state() -> None: + chat = onboarding.ChatInfo( + chat_id=5, + username=None, + title="Team", + first_name=None, + last_name=None, + chat_type="group", + ) + ui = DummyUI() + svc = DummyServices(chat=chat) + state = onboarding.OnboardingState(config_path=Path("/tmp/cfg"), force=False) + state.token = "token" + + await onboarding.capture_chat( + cast(onboarding.UI, ui), cast(onboarding.Services, svc), state + ) + assert state.chat == chat + + +@pytest.mark.anyio +async def test_step_capture_chat_workspace_switches_to_assistant( + tmp_path: Path, +) -> None: + chat = onboarding.ChatInfo( + chat_id=6, + username=None, + title=None, + first_name="Ada", + last_name=None, + chat_type="private", + ) + ui = DummyUI(selects=["assistant"]) + svc = DummyServices(chat=chat, topics_issue=ConfigError("nope")) + state = onboarding.OnboardingState(config_path=tmp_path / "cfg", force=False) + state.token = "token" + state.bot_name = "Takopi" + state.persona = "workspace" + state.topics_scope = "auto" + state.topics_enabled = True + + await onboarding.step_capture_chat( + cast(onboarding.UI, ui), cast(onboarding.Services, svc), state + ) + + assert state.persona == "assistant" + assert state.topics_enabled is False + + +@pytest.mark.anyio +async def test_step_default_engine_no_installed(tmp_path: Path) -> None: + ui = DummyUI(confirms=[False]) + svc = DummyServices(engines=[("codex", False, None)]) + state = onboarding.OnboardingState(config_path=tmp_path / "cfg", force=False) + + with pytest.raises(onboarding.OnboardingCancelled): + await onboarding.step_default_engine( + cast(onboarding.UI, ui), cast(onboarding.Services, svc), state + ) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 57c8452..6c61a93 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,9 +1,18 @@ +from collections.abc import Iterator + import pytest from takopi import plugins from tests.plugin_fixtures import FakeEntryPoint, install_entrypoints +@pytest.fixture(autouse=True) +def _reset_plugin_state() -> Iterator[None]: + plugins.reset_plugin_state() + yield + plugins.reset_plugin_state() + + def test_list_ids_does_not_load_entrypoints(monkeypatch) -> None: calls = {"count": 0} diff --git a/tests/test_telegram_agent_trigger_commands.py b/tests/test_telegram_agent_trigger_commands.py new file mode 100644 index 0000000..8e4dddc --- /dev/null +++ b/tests/test_telegram_agent_trigger_commands.py @@ -0,0 +1,225 @@ +from dataclasses import replace +from pathlib import Path + +import pytest + +from takopi.telegram.api_models import ChatMember +from takopi.telegram.commands.agent import _handle_agent_command +from takopi.telegram.commands.trigger import _handle_trigger_command +from takopi.telegram.chat_prefs import ChatPrefsStore +from takopi.telegram.topic_state import TopicStateStore +from takopi.telegram.types import TelegramIncomingMessage +from takopi.settings import TelegramTopicsSettings +from tests.telegram_fakes import FakeBot, FakeTransport, make_cfg + + +def _msg( + text: str, + *, + chat_id: int = 123, + message_id: int = 10, + sender_id: int | None = 42, + chat_type: str | None = "private", + thread_id: int | None = None, +) -> TelegramIncomingMessage: + return TelegramIncomingMessage( + transport="telegram", + chat_id=chat_id, + message_id=message_id, + text=text, + reply_to_message_id=None, + reply_to_text=None, + sender_id=sender_id, + chat_type=chat_type, + thread_id=thread_id, + ) + + +def _last_text(transport: FakeTransport) -> str: + assert transport.send_calls + return transport.send_calls[-1]["message"].text + + +class _MemberBot(FakeBot): + async def get_chat_member(self, chat_id: int, user_id: int) -> ChatMember | None: + _ = chat_id, user_id + return ChatMember(status="member", can_manage_topics=False) + + +@pytest.mark.anyio +async def test_agent_show_private_defaults() -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + msg = _msg("/agent", chat_type="private") + + await _handle_agent_command( + cfg, + msg, + args_text="", + ambient_context=None, + topic_store=None, + chat_prefs=None, + ) + + text = _last_text(transport) + assert "agent: codex" in text + assert "available: codex" in text + + +@pytest.mark.anyio +async def test_agent_set_clear_group_admin(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + prefs = ChatPrefsStore(tmp_path / "prefs.json") + msg = _msg("/agent set codex", chat_type="supergroup") + + await _handle_agent_command( + cfg, + msg, + args_text="set codex", + ambient_context=None, + topic_store=None, + chat_prefs=prefs, + ) + + assert await prefs.get_default_engine(msg.chat_id) == "codex" + assert "chat default agent set" in _last_text(transport) + + await _handle_agent_command( + cfg, + msg, + args_text="clear", + ambient_context=None, + topic_store=None, + chat_prefs=prefs, + ) + + assert await prefs.get_default_engine(msg.chat_id) is None + assert "chat default agent cleared" in _last_text(transport) + + +@pytest.mark.anyio +async def test_agent_set_denied_for_non_admin(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace(make_cfg(transport), bot=_MemberBot()) + prefs = ChatPrefsStore(tmp_path / "prefs.json") + msg = _msg("/agent set codex", chat_type="supergroup") + + await _handle_agent_command( + cfg, + msg, + args_text="set codex", + ambient_context=None, + topic_store=None, + chat_prefs=prefs, + ) + + assert await prefs.get_default_engine(msg.chat_id) is None + assert "restricted to group admins" in _last_text(transport) + + +@pytest.mark.anyio +async def test_agent_set_invalid_engine(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + msg = _msg("/agent set nope", chat_type="private") + + await _handle_agent_command( + cfg, + msg, + args_text="set nope", + ambient_context=None, + topic_store=None, + chat_prefs=ChatPrefsStore(tmp_path / "prefs.json"), + ) + + text = _last_text(transport) + assert "unknown engine" in text + assert "available agents" in text + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("topic_mode", "chat_mode", "expected_source", "expected_trigger"), + [ + ("mentions", None, "topic override", "mentions"), + (None, "mentions", "chat default", "mentions"), + (None, None, "default", "all"), + ], +) +async def test_trigger_show_sources( + tmp_path: Path, + topic_mode: str | None, + chat_mode: str | None, + expected_source: str, + expected_trigger: str, +) -> None: + transport = FakeTransport() + cfg = replace( + make_cfg(transport), + topics=TelegramTopicsSettings(enabled=True, scope="all"), + ) + topic_store = TopicStateStore(tmp_path / "topics.json") + chat_prefs = ChatPrefsStore(tmp_path / "prefs.json") + msg = _msg("/trigger", chat_type="supergroup", thread_id=7) + + if topic_mode is not None: + await topic_store.set_trigger_mode(msg.chat_id, msg.thread_id or 0, topic_mode) + if chat_mode is not None: + await chat_prefs.set_trigger_mode(msg.chat_id, chat_mode) + + await _handle_trigger_command( + cfg, + msg, + args_text="", + _ambient_context=None, + topic_store=topic_store, + chat_prefs=chat_prefs, + ) + + text = _last_text(transport) + assert f"trigger: {expected_trigger} ({expected_source})" in text + assert "available: all, mentions" in text + + +@pytest.mark.anyio +async def test_trigger_set_clear_permissions(tmp_path: Path) -> None: + transport = FakeTransport() + prefs = ChatPrefsStore(tmp_path / "prefs.json") + msg = _msg("/trigger mentions", chat_type="supergroup") + + denied_cfg = replace(make_cfg(transport), bot=_MemberBot()) + await _handle_trigger_command( + denied_cfg, + msg, + args_text="mentions", + _ambient_context=None, + topic_store=None, + chat_prefs=prefs, + ) + assert await prefs.get_trigger_mode(msg.chat_id) is None + assert "restricted to group admins" in _last_text(transport) + + transport = FakeTransport() + allow_cfg = make_cfg(transport) + await _handle_trigger_command( + allow_cfg, + msg, + args_text="mentions", + _ambient_context=None, + topic_store=None, + chat_prefs=prefs, + ) + assert await prefs.get_trigger_mode(msg.chat_id) == "mentions" + assert "chat trigger mode set" in _last_text(transport) + + await _handle_trigger_command( + allow_cfg, + msg, + args_text="clear", + _ambient_context=None, + topic_store=None, + chat_prefs=prefs, + ) + assert await prefs.get_trigger_mode(msg.chat_id) is None + assert "chat trigger mode reset" in _last_text(transport) diff --git a/tests/test_telegram_bridge.py b/tests/test_telegram_bridge.py index 8d91389..a26bb8b 100644 --- a/tests/test_telegram_bridge.py +++ b/tests/test_telegram_bridge.py @@ -14,15 +14,7 @@ from takopi.telegram.commands.topics import _handle_topic_command import takopi.telegram.loop as telegram_loop import takopi.telegram.topics as telegram_topics from takopi.directives import parse_directives -from takopi.telegram.api_models import ( - Chat, - ChatMember, - File, - ForumTopic, - Message, - Update, - User, -) +from takopi.telegram.api_models import File, ForumTopic, Message, Update, User from takopi.settings import TelegramFilesSettings, TelegramTopicsSettings from takopi.telegram.bridge import ( TelegramBridgeConfig, @@ -59,6 +51,13 @@ from takopi.telegram.types import ( ) from takopi.transport import MessageRef, RenderedMessage, SendOptions from tests.plugin_fixtures import FakeEntryPoint, install_entrypoints +from tests.telegram_fakes import ( + FakeBot, + FakeTransport, + _empty_projects, + make_cfg, + _make_router, +) CODEX_ENGINE = "codex" FAST_FORWARD_COALESCE_S = 0.0 @@ -67,271 +66,12 @@ BATCH_MEDIA_GROUP_DEBOUNCE_S = 0.05 DEBOUNCE_FORWARD_COALESCE_S = 0.05 -def _empty_projects() -> ProjectsConfig: - return ProjectsConfig(projects={}, default_project=None) - - -def _make_router(runner) -> AutoRouter: - return AutoRouter( - entries=[RunnerEntry(engine=runner.engine, runner=runner)], - default_engine=runner.engine, - ) - - class _NoopTaskGroup: def start_soon(self, func, *args: Any) -> None: _ = func, args return None -class _FakeTransport: - def __init__(self, progress_ready: anyio.Event | None = None) -> None: - self._next_id = 1 - self.send_calls: list[dict] = [] - self.edit_calls: list[dict] = [] - self.delete_calls: list[MessageRef] = [] - self.progress_ready = progress_ready - self.progress_ref: MessageRef | None = None - - async def send( - self, - *, - channel_id: int | str, - message: RenderedMessage, - options: SendOptions | None = None, - ) -> MessageRef: - ref = MessageRef(channel_id=channel_id, message_id=self._next_id) - self._next_id += 1 - self.send_calls.append( - { - "ref": ref, - "channel_id": channel_id, - "message": message, - "options": options, - } - ) - if ( - self.progress_ref is None - and options is not None - and options.reply_to is not None - and options.notify is False - ): - self.progress_ref = ref - if self.progress_ready is not None: - self.progress_ready.set() - return ref - - async def edit( - self, *, ref: MessageRef, message: RenderedMessage, wait: bool = True - ) -> MessageRef: - self.edit_calls.append({"ref": ref, "message": message, "wait": wait}) - return ref - - async def delete(self, *, ref: MessageRef) -> bool: - self.delete_calls.append(ref) - return True - - async def close(self) -> None: - return None - - -class _FakeBot(BotClient): - def __init__(self) -> None: - self.command_calls: list[dict] = [] - self.callback_calls: list[dict] = [] - self.send_calls: list[dict] = [] - self.document_calls: list[dict] = [] - self.edit_calls: list[dict] = [] - self.edit_topic_calls: list[dict[str, Any]] = [] - self.delete_calls: list[dict] = [] - - async def get_updates( - self, - offset: int | None, - timeout_s: int = 50, - allowed_updates: list[str] | None = None, - ) -> list[Update] | None: - _ = offset - _ = timeout_s - _ = allowed_updates - return [] - - async def get_file(self, file_id: str) -> File | None: - _ = file_id - return None - - async def download_file(self, file_path: str) -> bytes | None: - _ = file_path - return None - - async def send_message( - self, - chat_id: int, - text: str, - reply_to_message_id: int | None = None, - disable_notification: bool | None = False, - message_thread_id: int | None = None, - entities: list[dict[str, Any]] | None = None, - parse_mode: str | None = None, - reply_markup: dict | None = None, - *, - replace_message_id: int | None = None, - ) -> Message: - self.send_calls.append( - { - "chat_id": chat_id, - "text": text, - "reply_to_message_id": reply_to_message_id, - "disable_notification": disable_notification, - "message_thread_id": message_thread_id, - "entities": entities, - "parse_mode": parse_mode, - "reply_markup": reply_markup, - "replace_message_id": replace_message_id, - } - ) - return Message(message_id=1) - - async def send_document( - self, - chat_id: int, - filename: str, - content: bytes, - reply_to_message_id: int | None = None, - message_thread_id: int | None = None, - disable_notification: bool | None = False, - caption: str | None = None, - ) -> Message: - self.document_calls.append( - { - "chat_id": chat_id, - "filename": filename, - "content": content, - "reply_to_message_id": reply_to_message_id, - "message_thread_id": message_thread_id, - "disable_notification": disable_notification, - "caption": caption, - } - ) - return Message(message_id=2) - - async def edit_message_text( - self, - chat_id: int, - message_id: int, - text: str, - entities: list[dict[str, Any]] | None = None, - parse_mode: str | None = None, - reply_markup: dict | None = None, - *, - wait: bool = True, - ) -> Message: - self.edit_calls.append( - { - "chat_id": chat_id, - "message_id": message_id, - "text": text, - "entities": entities, - "parse_mode": parse_mode, - "reply_markup": reply_markup, - "wait": wait, - } - ) - return Message(message_id=message_id) - - async def delete_message(self, chat_id: int, message_id: int) -> bool: - self.delete_calls.append({"chat_id": chat_id, "message_id": message_id}) - return True - - async def set_my_commands( - self, - commands: list[dict[str, Any]], - *, - scope: dict[str, Any] | None = None, - language_code: str | None = None, - ) -> bool: - self.command_calls.append( - { - "commands": commands, - "scope": scope, - "language_code": language_code, - } - ) - return True - - async def get_me(self) -> User | None: - return User(id=1, username="bot") - - async def get_chat(self, chat_id: int) -> Chat | None: - _ = chat_id - return Chat(id=chat_id, type="supergroup", is_forum=True) - - async def get_chat_member(self, chat_id: int, user_id: int) -> ChatMember | None: - _ = chat_id - _ = user_id - return ChatMember(status="administrator", can_manage_topics=True) - - async def create_forum_topic(self, chat_id: int, name: str) -> ForumTopic | None: - _ = chat_id - _ = name - return ForumTopic(message_thread_id=1) - - async def edit_forum_topic( - self, chat_id: int, message_thread_id: int, name: str - ) -> bool: - self.edit_topic_calls.append( - { - "chat_id": chat_id, - "message_thread_id": message_thread_id, - "name": name, - } - ) - return True - - async def close(self) -> None: - return None - - async def answer_callback_query( - self, - callback_query_id: str, - text: str | None = None, - show_alert: bool | None = None, - ) -> bool: - self.callback_calls.append( - { - "callback_query_id": callback_query_id, - "text": text, - "show_alert": show_alert, - } - ) - return True - - -def _make_cfg( - transport: _FakeTransport, runner: ScriptRunner | None = None -) -> TelegramBridgeConfig: - if runner is None: - runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) - exec_cfg = ExecBridgeConfig( - transport=transport, - presenter=MarkdownPresenter(), - final_notify=True, - ) - runtime = TransportRuntime( - router=_make_router(runner), - projects=_empty_projects(), - ) - return TelegramBridgeConfig( - bot=_FakeBot(), - runtime=runtime, - chat_id=123, - startup_msg="", - exec_cfg=exec_cfg, - forward_coalesce_s=FAST_FORWARD_COALESCE_S, - media_group_debounce_s=FAST_MEDIA_GROUP_DEBOUNCE_S, - ) - - def test_parse_directives_inline_engine() -> None: directives = parse_directives( "/claude do it", @@ -547,7 +287,7 @@ def test_telegram_presenter_split_overflow_adds_followups() -> None: @pytest.mark.anyio async def test_telegram_transport_passes_replace_and_wait() -> None: - bot = _FakeBot() + bot = FakeBot() transport = TelegramTransport(bot) reply = MessageRef(channel_id=123, message_id=10) replace = MessageRef(channel_id=123, message_id=11) @@ -571,7 +311,7 @@ async def test_telegram_transport_passes_replace_and_wait() -> None: @pytest.mark.anyio async def test_telegram_transport_passes_reply_markup() -> None: - bot = _FakeBot() + bot = FakeBot() transport = TelegramTransport(bot) markup = {"inline_keyboard": []} @@ -593,7 +333,7 @@ async def test_telegram_transport_passes_reply_markup() -> None: @pytest.mark.anyio async def test_telegram_transport_sends_followups() -> None: - bot = _FakeBot() + bot = FakeBot() transport = TelegramTransport(bot) reply = MessageRef(channel_id=123, message_id=10) followup = RenderedMessage(text="part 2") @@ -614,7 +354,7 @@ async def test_telegram_transport_sends_followups() -> None: @pytest.mark.anyio async def test_telegram_transport_edits_and_sends_followups() -> None: - bot = _FakeBot() + bot = FakeBot() transport = TelegramTransport(bot) followup = RenderedMessage(text="part 2") @@ -772,8 +512,8 @@ async def test_telegram_transport_edit_wait_false_returns_ref() -> None: @pytest.mark.anyio async def test_handle_cancel_without_reply_prompts_user() -> None: - transport = _FakeTransport() - cfg = _make_cfg(transport) + transport = FakeTransport() + cfg = make_cfg(transport) msg = TelegramIncomingMessage( transport="telegram", chat_id=123, @@ -793,8 +533,8 @@ async def test_handle_cancel_without_reply_prompts_user() -> None: @pytest.mark.anyio async def test_handle_cancel_with_no_progress_message_says_nothing_running() -> None: - transport = _FakeTransport() - cfg = _make_cfg(transport) + transport = FakeTransport() + cfg = make_cfg(transport) msg = TelegramIncomingMessage( transport="telegram", chat_id=123, @@ -814,8 +554,8 @@ async def test_handle_cancel_with_no_progress_message_says_nothing_running() -> @pytest.mark.anyio async def test_handle_cancel_with_finished_task_says_nothing_running() -> None: - transport = _FakeTransport() - cfg = _make_cfg(transport) + transport = FakeTransport() + cfg = make_cfg(transport) progress_id = 99 msg = TelegramIncomingMessage( transport="telegram", @@ -836,8 +576,8 @@ async def test_handle_cancel_with_finished_task_says_nothing_running() -> None: @pytest.mark.anyio async def test_handle_cancel_cancels_running_task() -> None: - transport = _FakeTransport() - cfg = _make_cfg(transport) + transport = FakeTransport() + cfg = make_cfg(transport) progress_id = 42 msg = TelegramIncomingMessage( transport="telegram", @@ -859,8 +599,8 @@ async def test_handle_cancel_cancels_running_task() -> None: @pytest.mark.anyio async def test_handle_cancel_only_cancels_matching_progress_message() -> None: - transport = _FakeTransport() - cfg = _make_cfg(transport) + transport = FakeTransport() + cfg = make_cfg(transport) task_first = RunningTask() task_second = RunningTask() msg = TelegramIncomingMessage( @@ -886,8 +626,8 @@ async def test_handle_cancel_only_cancels_matching_progress_message() -> None: @pytest.mark.anyio async def test_handle_cancel_cancels_queued_job() -> None: - transport = _FakeTransport() - cfg = _make_cfg(transport) + transport = FakeTransport() + cfg = make_cfg(transport) async def _noop_run_job(_) -> None: return None @@ -924,7 +664,7 @@ async def test_handle_cancel_cancels_queued_job() -> None: async def test_handle_file_put_writes_file(tmp_path: Path) -> None: payload = b"hello" - class _FileBot(_FakeBot): + class _FileBot(FakeBot): async def get_file(self, file_id: str) -> File | None: _ = file_id return File(file_path="files/hello.txt") @@ -933,7 +673,7 @@ async def test_handle_file_put_writes_file(tmp_path: Path) -> None: _ = file_path return payload - transport = _FakeTransport() + transport = FakeTransport() bot = _FileBot() runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) projects = ProjectsConfig( @@ -998,8 +738,8 @@ async def test_handle_file_get_sends_document_for_allowed_user( target = tmp_path / "hello.txt" target.write_bytes(payload) - transport = _FakeTransport() - bot = _FakeBot() + transport = FakeTransport() + bot = FakeBot() runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) projects = ProjectsConfig( projects={ @@ -1050,8 +790,8 @@ async def test_handle_file_get_sends_document_for_allowed_user( @pytest.mark.anyio async def test_handle_callback_cancel_cancels_running_task() -> None: - transport = _FakeTransport() - cfg = _make_cfg(transport) + transport = FakeTransport() + cfg = make_cfg(transport) progress_id = 42 running_task = RunningTask() running_tasks = {MessageRef(channel_id=123, message_id=progress_id): running_task} @@ -1068,15 +808,15 @@ async def test_handle_callback_cancel_cancels_running_task() -> None: assert running_task.cancel_requested.is_set() is True assert len(transport.send_calls) == 0 - bot = cast(_FakeBot, cfg.bot) + bot = cast(FakeBot, cfg.bot) assert bot.callback_calls assert bot.callback_calls[-1]["text"] == "cancelling..." @pytest.mark.anyio async def test_handle_callback_cancel_cancels_queued_job() -> None: - transport = _FakeTransport() - cfg = _make_cfg(transport) + transport = FakeTransport() + cfg = make_cfg(transport) async def _noop_run_job(_) -> None: return None @@ -1105,15 +845,15 @@ async def test_handle_callback_cancel_cancels_queued_job() -> None: assert transport.edit_calls assert "cancelled" in transport.edit_calls[0]["message"].text.lower() - bot = cast(_FakeBot, cfg.bot) + bot = cast(FakeBot, cfg.bot) assert bot.callback_calls assert bot.callback_calls[-1]["text"] == "dropped from queue." @pytest.mark.anyio async def test_handle_callback_cancel_without_task_acknowledges() -> None: - transport = _FakeTransport() - cfg = _make_cfg(transport) + transport = FakeTransport() + cfg = make_cfg(transport) query = TelegramCallbackQuery( transport="telegram", chat_id=123, @@ -1126,7 +866,7 @@ async def test_handle_callback_cancel_without_task_acknowledges() -> None: await handle_callback_cancel(cfg, query, {}) assert len(transport.send_calls) == 0 - bot = cast(_FakeBot, cfg.bot) + bot = cast(FakeBot, cfg.bot) assert bot.callback_calls assert "nothing is currently running" in bot.callback_calls[-1]["text"].lower() @@ -1176,8 +916,8 @@ def test_is_forwarded_detects_forward_fields() -> None: def test_topic_title_matches_command_syntax() -> None: - transport = _FakeTransport() - cfg = _make_cfg(transport) + transport = FakeTransport() + cfg = make_cfg(transport) title = telegram_topics._topic_title( runtime=cfg.runtime, @@ -1202,9 +942,9 @@ def test_topic_title_matches_command_syntax() -> None: def test_topic_title_projects_scope_includes_project() -> None: - transport = _FakeTransport() + transport = FakeTransport() cfg = replace( - _make_cfg(transport), + make_cfg(transport), topics=TelegramTopicsSettings( enabled=True, scope="projects", @@ -1221,8 +961,8 @@ def test_topic_title_projects_scope_includes_project() -> None: @pytest.mark.anyio async def test_maybe_rename_topic_updates_title(tmp_path: Path) -> None: - transport = _FakeTransport() - cfg = _make_cfg(transport) + transport = FakeTransport() + cfg = make_cfg(transport) store = TopicStateStore(tmp_path / "telegram_topics_state.json") await store.set_context( @@ -1240,7 +980,7 @@ async def test_maybe_rename_topic_updates_title(tmp_path: Path) -> None: context=RunContext(project="takopi", branch="new"), ) - bot = cast(_FakeBot, cfg.bot) + bot = cast(FakeBot, cfg.bot) assert bot.edit_topic_calls assert bot.edit_topic_calls[-1]["name"] == "takopi @new" snapshot = await store.get_thread(123, 77) @@ -1250,8 +990,8 @@ async def test_maybe_rename_topic_updates_title(tmp_path: Path) -> None: @pytest.mark.anyio async def test_maybe_rename_topic_skips_when_title_matches(tmp_path: Path) -> None: - transport = _FakeTransport() - cfg = _make_cfg(transport) + transport = FakeTransport() + cfg = make_cfg(transport) store = TopicStateStore(tmp_path / "telegram_topics_state.json") await store.set_context( @@ -1271,13 +1011,13 @@ async def test_maybe_rename_topic_skips_when_title_matches(tmp_path: Path) -> No snapshot=snapshot, ) - bot = cast(_FakeBot, cfg.bot) + bot = cast(FakeBot, cfg.bot) assert bot.edit_topic_calls == [] @pytest.mark.anyio async def test_topic_command_recreates_stale_topic(tmp_path: Path) -> None: - class _StaleTopicBot(_FakeBot): + class _StaleTopicBot(FakeBot): def __init__(self) -> None: super().__init__() self.create_topic_calls: list[dict[str, Any]] = [] @@ -1300,7 +1040,7 @@ async def test_topic_command_recreates_stale_topic(tmp_path: Path) -> None: ) return False - transport = _FakeTransport() + transport = FakeTransport() bot = _StaleTopicBot() runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) projects = ProjectsConfig( @@ -1365,8 +1105,8 @@ async def test_topic_command_recreates_stale_topic(tmp_path: Path) -> None: @pytest.mark.anyio async def test_model_command_show_reports_overrides(tmp_path: Path) -> None: - transport = _FakeTransport() - cfg = _make_cfg(transport) + transport = FakeTransport() + cfg = make_cfg(transport) cfg = replace(cfg, topics=TelegramTopicsSettings(enabled=True, scope="main")) chat_prefs = ChatPrefsStore(tmp_path / "telegram_chat_prefs_state.json") topic_store = TopicStateStore(tmp_path / "telegram_topics_state.json") @@ -1412,8 +1152,8 @@ async def test_model_command_show_reports_overrides(tmp_path: Path) -> None: @pytest.mark.anyio async def test_model_command_set_and_clear_chat_override(tmp_path: Path) -> None: - transport = _FakeTransport() - cfg = _make_cfg(transport) + transport = FakeTransport() + cfg = make_cfg(transport) chat_prefs = ChatPrefsStore(tmp_path / "telegram_chat_prefs_state.json") await chat_prefs.set_engine_override( 123, @@ -1472,8 +1212,8 @@ async def test_model_command_set_and_clear_chat_override(tmp_path: Path) -> None @pytest.mark.anyio async def test_reasoning_command_set_and_clear_topic_override(tmp_path: Path) -> None: - transport = _FakeTransport() - cfg = _make_cfg(transport) + transport = FakeTransport() + cfg = make_cfg(transport) cfg = replace(cfg, topics=TelegramTopicsSettings(enabled=True, scope="main")) topic_store = TopicStateStore(tmp_path / "telegram_topics_state.json") await topic_store.set_engine_override( @@ -1542,8 +1282,8 @@ async def test_reasoning_command_set_and_clear_topic_override(tmp_path: Path) -> @pytest.mark.anyio async def test_reasoning_command_show_reports_overrides(tmp_path: Path) -> None: - transport = _FakeTransport() - cfg = _make_cfg(transport) + transport = FakeTransport() + cfg = make_cfg(transport) cfg = replace(cfg, topics=TelegramTopicsSettings(enabled=True, scope="main")) chat_prefs = ChatPrefsStore(tmp_path / "telegram_chat_prefs_state.json") topic_store = TopicStateStore(tmp_path / "telegram_topics_state.json") @@ -1589,8 +1329,8 @@ async def test_reasoning_command_show_reports_overrides(tmp_path: Path) -> None: @pytest.mark.anyio async def test_send_with_resume_waits_for_token() -> None: - transport = _FakeTransport() - cfg = _make_cfg(transport) + transport = FakeTransport() + cfg = make_cfg(transport) sent: list[ tuple[ int, @@ -1664,8 +1404,8 @@ async def test_send_with_resume_waits_for_token() -> None: @pytest.mark.anyio async def test_send_with_resume_reports_when_missing() -> None: - transport = _FakeTransport() - cfg = _make_cfg(transport) + transport = FakeTransport() + cfg = make_cfg(transport) sent: list[ tuple[ int, @@ -1766,8 +1506,8 @@ async def test_run_main_loop_routes_reply_to_running_resume() -> None: reply_ready = anyio.Event() hold = anyio.Event() - transport = _FakeTransport(progress_ready=progress_ready) - bot = _FakeBot() + transport = FakeTransport(progress_ready=progress_ready) + bot = FakeBot() resume_value = "abc123" runner = ScriptRunner( [Wait(hold), Sleep(0.05), Return(answer="ok")], @@ -1845,8 +1585,8 @@ async def test_run_main_loop_persists_topic_sessions_in_project_scope( project_chat_id = -100 resume_value = "resume-123" - transport = _FakeTransport() - bot = _FakeBot() + transport = FakeTransport() + bot = FakeBot() runner = ScriptRunner( [Return(answer="ok")], engine=CODEX_ENGINE, @@ -1924,8 +1664,8 @@ async def test_run_main_loop_auto_resumes_topic_default_engine( ) await store.set_default_engine(123, 77, "claude") - transport = _FakeTransport() - bot = _FakeBot() + transport = FakeTransport() + bot = FakeBot() codex_runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) claude_runner = ScriptRunner([Return(answer="ok")], engine="claude") router = AutoRouter( @@ -1996,8 +1736,8 @@ async def test_run_main_loop_auto_resumes_chat_sessions(tmp_path: Path) -> None: resume_value = "resume-123" state_path = tmp_path / "takopi.toml" - transport = _FakeTransport() - bot = _FakeBot() + transport = FakeTransport() + bot = FakeBot() runner = ScriptRunner( [Return(answer="ok")], engine=CODEX_ENGINE, @@ -2096,7 +1836,7 @@ async def test_run_main_loop_prompt_upload_uses_caption_directives( proj_dir.mkdir() other_dir.mkdir() - class _UploadBot(_FakeBot): + class _UploadBot(FakeBot): async def get_file(self, file_id: str) -> File | None: _ = file_id return File(file_path="files/hello.txt") @@ -2105,7 +1845,7 @@ async def test_run_main_loop_prompt_upload_uses_caption_directives( _ = file_path return payload - transport = _FakeTransport() + transport = FakeTransport() bot = _UploadBot() runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) exec_cfg = ExecBridgeConfig( @@ -2188,14 +1928,14 @@ async def test_run_main_loop_voice_transcript_preserves_directive( default_engine=claude_runner.engine, ) runtime = TransportRuntime(router=router, projects=_empty_projects()) - transport = _FakeTransport() + transport = FakeTransport() exec_cfg = ExecBridgeConfig( transport=transport, presenter=MarkdownPresenter(), final_notify=True, ) cfg = TelegramBridgeConfig( - bot=_FakeBot(), + bot=FakeBot(), runtime=runtime, chat_id=123, startup_msg="", @@ -2259,14 +1999,14 @@ async def test_run_main_loop_debounces_forwarded_messages_preserves_directives() default_engine=claude_runner.engine, ) runtime = TransportRuntime(router=router, projects=_empty_projects()) - transport = _FakeTransport() + transport = FakeTransport() exec_cfg = ExecBridgeConfig( transport=transport, presenter=MarkdownPresenter(), final_notify=True, ) cfg = TelegramBridgeConfig( - bot=_FakeBot(), + bot=FakeBot(), runtime=runtime, chat_id=123, startup_msg="", @@ -2329,14 +2069,14 @@ async def test_run_main_loop_debounces_forwarded_messages_preserves_directives() async def test_run_main_loop_ignores_forwarded_without_prompt() -> None: runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) runtime = TransportRuntime(router=_make_router(runner), projects=_empty_projects()) - transport = _FakeTransport() + transport = FakeTransport() exec_cfg = ExecBridgeConfig( transport=transport, presenter=MarkdownPresenter(), final_notify=True, ) cfg = TelegramBridgeConfig( - bot=_FakeBot(), + bot=FakeBot(), runtime=runtime, chat_id=123, startup_msg="", @@ -2378,7 +2118,7 @@ async def test_run_main_loop_forwarded_document_still_uploads( ) -> None: payload = b"hello" - class _UploadBot(_FakeBot): + class _UploadBot(FakeBot): async def get_file(self, file_id: str) -> File | None: _ = file_id return File(file_path="files/hello.txt") @@ -2399,7 +2139,7 @@ async def test_run_main_loop_forwarded_document_still_uploads( default_project="proj", ) runtime = TransportRuntime(router=_make_router(runner), projects=projects) - transport = _FakeTransport() + transport = FakeTransport() exec_cfg = ExecBridgeConfig( transport=transport, presenter=MarkdownPresenter(), @@ -2460,7 +2200,7 @@ async def test_run_main_loop_prompt_upload_auto_resumes_chat_sessions( project_dir = tmp_path / "proj" project_dir.mkdir() - class _UploadBot(_FakeBot): + class _UploadBot(FakeBot): async def get_file(self, file_id: str) -> File | None: _ = file_id return File(file_path="files/hello.txt") @@ -2481,7 +2221,7 @@ async def test_run_main_loop_prompt_upload_auto_resumes_chat_sessions( ) bot = _UploadBot() - transport = _FakeTransport() + transport = FakeTransport() runner = ScriptRunner( [Return(answer="ok")], engine=CODEX_ENGINE, @@ -2538,7 +2278,7 @@ async def test_run_main_loop_prompt_upload_auto_resumes_chat_sessions( stored = await store.get_session_resume(123, None, CODEX_ENGINE) assert stored == ResumeToken(engine=CODEX_ENGINE, value=resume_value) - transport2 = _FakeTransport() + transport2 = FakeTransport() runner2 = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) exec_cfg2 = ExecBridgeConfig( transport=transport2, @@ -2619,8 +2359,8 @@ async def test_run_main_loop_command_updates_chat_session_resume( resume_value = "resume-123" state_path = tmp_path / "takopi.toml" - transport = _FakeTransport() - bot = _FakeBot() + transport = FakeTransport() + bot = FakeBot() runner = ScriptRunner( [Return(answer="ok")], engine=CODEX_ENGINE, @@ -2666,7 +2406,7 @@ async def test_run_main_loop_command_updates_chat_session_resume( stored = await store.get_session_resume(123, None, CODEX_ENGINE) assert stored == ResumeToken(engine=CODEX_ENGINE, value=resume_value) - transport2 = _FakeTransport() + transport2 = FakeTransport() runner2 = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) exec_cfg2 = ExecBridgeConfig( transport=transport2, @@ -2717,8 +2457,8 @@ async def test_run_main_loop_hides_resume_line_when_disabled( resume_value = "resume-123" state_path = tmp_path / "takopi.toml" - transport = _FakeTransport() - bot = _FakeBot() + transport = FakeTransport() + bot = FakeBot() runner = ScriptRunner( [Return(answer="ok")], engine=CODEX_ENGINE, @@ -2782,8 +2522,8 @@ async def test_run_main_loop_chat_sessions_isolate_group_senders( resume_value = "resume-group" state_path = tmp_path / "takopi.toml" - transport = _FakeTransport() - bot = _FakeBot() + transport = FakeTransport() + bot = FakeBot() runner = ScriptRunner( [Return(answer="ok")], engine=CODEX_ENGINE, @@ -2866,8 +2606,8 @@ async def test_run_main_loop_new_clears_chat_sessions(tmp_path: Path) -> None: 123, None, ResumeToken(engine=CODEX_ENGINE, value="resume-1") ) - transport = _FakeTransport() - bot = _FakeBot() + transport = FakeTransport() + bot = FakeBot() runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) exec_cfg = ExecBridgeConfig( transport=transport, @@ -2916,8 +2656,8 @@ async def test_run_main_loop_new_clears_topic_sessions(tmp_path: Path) -> None: 123, 77, ResumeToken(engine=CODEX_ENGINE, value="resume-1") ) - transport = _FakeTransport() - bot = _FakeBot() + transport = FakeTransport() + bot = FakeBot() runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) exec_cfg = ExecBridgeConfig( transport=transport, @@ -2962,8 +2702,8 @@ async def test_run_main_loop_new_clears_topic_sessions(tmp_path: Path) -> None: @pytest.mark.anyio async def test_run_main_loop_replies_in_same_thread() -> None: - transport = _FakeTransport() - bot = _FakeBot() + transport = FakeTransport() + bot = FakeBot() runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) exec_cfg = ExecBridgeConfig( transport=transport, @@ -3020,7 +2760,7 @@ async def test_run_main_loop_batches_media_group_upload( "doc-2": "photos/file_2.jpg", } - class _MediaBot(_FakeBot): + class _MediaBot(FakeBot): async def get_file(self, file_id: str) -> File | None: file_path = file_map.get(file_id) if file_path is None: @@ -3030,7 +2770,7 @@ async def test_run_main_loop_batches_media_group_upload( async def download_file(self, file_path: str) -> bytes | None: return payloads.get(file_path) - transport = _FakeTransport() + transport = FakeTransport() bot = _MediaBot() runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) projects = ProjectsConfig( @@ -3144,8 +2884,8 @@ async def test_run_main_loop_handles_command_plugins(monkeypatch) -> None: ] install_entrypoints(monkeypatch, entrypoints) - transport = _FakeTransport() - bot = _FakeBot() + transport = FakeTransport() + bot = FakeBot() runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) exec_cfg = ExecBridgeConfig( transport=transport, @@ -3212,8 +2952,8 @@ async def test_run_main_loop_command_uses_project_default_engine( ] install_entrypoints(monkeypatch, entrypoints) - transport = _FakeTransport() - bot = _FakeBot() + transport = FakeTransport() + bot = FakeBot() codex_runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) pi_runner = ScriptRunner([Return(answer="ok")], engine="pi") router = AutoRouter( @@ -3296,8 +3036,8 @@ async def test_run_main_loop_command_defaults_to_chat_project( ] install_entrypoints(monkeypatch, entrypoints) - transport = _FakeTransport() - bot = _FakeBot() + transport = FakeTransport() + bot = FakeBot() codex_runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) pi_runner = ScriptRunner([Return(answer="ok")], engine="pi") router = AutoRouter( @@ -3387,8 +3127,8 @@ async def test_run_main_loop_refreshes_command_ids(monkeypatch) -> None: monkeypatch.setattr(telegram_loop, "list_command_ids", _list_command_ids) - transport = _FakeTransport() - bot = _FakeBot() + transport = FakeTransport() + bot = FakeBot() runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) exec_cfg = ExecBridgeConfig( transport=transport, @@ -3447,8 +3187,8 @@ async def test_run_main_loop_mentions_only_skips_voice_and_files( telegram_loop, "_handle_file_put_default", fake_handle_file_put_default ) - transport = _FakeTransport() - bot = _FakeBot() + transport = FakeTransport() + bot = FakeBot() runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) exec_cfg = ExecBridgeConfig( transport=transport, diff --git a/tests/test_telegram_client_api.py b/tests/test_telegram_client_api.py new file mode 100644 index 0000000..944e900 --- /dev/null +++ b/tests/test_telegram_client_api.py @@ -0,0 +1,174 @@ +import httpx +import pytest + +from takopi.telegram.client_api import ( + HttpBotClient, + TelegramRetryAfter, + retry_after_from_payload, +) +from takopi.telegram.api_models import User + + +def _response() -> httpx.Response: + request = httpx.Request("POST", "https://example.com") + return httpx.Response(200, request=request) + + +def test_retry_after_from_payload() -> None: + assert retry_after_from_payload({}) is None + assert retry_after_from_payload({"parameters": {"retry_after": 2}}) == 2.0 + + +def test_parse_envelope_invalid_payload() -> None: + client = HttpBotClient("token", http_client=httpx.AsyncClient()) + assert ( + client._parse_telegram_envelope( + method="sendMessage", + resp=_response(), + payload="nope", + ) + is None + ) + + +def test_parse_envelope_rate_limited() -> None: + client = HttpBotClient("token", http_client=httpx.AsyncClient()) + payload = {"ok": False, "error_code": 429, "parameters": {"retry_after": 1}} + with pytest.raises(TelegramRetryAfter) as exc: + client._parse_telegram_envelope( + method="sendMessage", + resp=_response(), + payload=payload, + ) + assert exc.value.retry_after == 1.0 + + +def test_parse_envelope_api_error() -> None: + client = HttpBotClient("token", http_client=httpx.AsyncClient()) + payload = {"ok": False, "error_code": 400, "description": "boom"} + assert ( + client._parse_telegram_envelope( + method="sendMessage", + resp=_response(), + payload=payload, + ) + is None + ) + + +def test_parse_envelope_ok() -> None: + client = HttpBotClient("token", http_client=httpx.AsyncClient()) + payload = {"ok": True, "result": {"message_id": 1}} + assert client._parse_telegram_envelope( + method="sendMessage", + resp=_response(), + payload=payload, + ) == {"message_id": 1} + + +@pytest.mark.anyio +async def test_client_methods_build_params_and_decode() -> None: + payloads = { + "getUpdates": [{"update_id": 1}], + "getFile": {"file_path": "path"}, + "sendMessage": {"message_id": 1}, + "sendDocument": {"message_id": 2}, + "editMessageText": {"message_id": 3}, + "deleteMessage": True, + "setMyCommands": True, + "getMe": {"id": 7}, + "answerCallbackQuery": True, + "getChat": {"id": 5, "type": "private"}, + "getChatMember": {"status": "member"}, + "createForumTopic": {"message_thread_id": 11}, + "editForumTopic": True, + } + + class _StubClient(HttpBotClient): + def __init__(self) -> None: + super().__init__("token", http_client=httpx.AsyncClient()) + self.calls: list[tuple[str, dict | None, dict | None, dict | None]] = [] + + async def _request( + self, + method: str, + *, + json: dict | None = None, + data: dict | None = None, + files: dict | None = None, + ) -> object | None: + self.calls.append((method, json, data, files)) + return payloads.get(method) + + client = _StubClient() + + updates = await client.get_updates(offset=10, allowed_updates=["message"]) + assert updates and updates[0].update_id == 1 + + assert await client.get_file("file") is not None + + msg = await client.send_message( + 1, + "hi", + reply_to_message_id=2, + disable_notification=True, + message_thread_id=3, + entities=[{"type": "bold", "offset": 0, "length": 2}], + parse_mode="Markdown", + reply_markup={"inline_keyboard": []}, + ) + assert msg and msg.message_id == 1 + + doc = await client.send_document( + 1, + "file.txt", + b"data", + reply_to_message_id=2, + message_thread_id=3, + disable_notification=True, + caption="doc", + ) + assert doc and doc.message_id == 2 + + edit = await client.edit_message_text( + 1, + 2, + "edit", + entities=[{"type": "italic", "offset": 0, "length": 4}], + parse_mode="Markdown", + reply_markup={"inline_keyboard": []}, + ) + assert edit and edit.message_id == 3 + + assert await client.delete_message(1, 2) is True + assert await client.set_my_commands( + [{"command": "ping", "description": "pong"}], + scope={"type": "chat"}, + language_code="en", + ) + assert await client.answer_callback_query("cb", text="ok", show_alert=True) is True + assert await client.get_chat(1) is not None + assert await client.get_chat_member(1, 2) is not None + assert await client.create_forum_topic(1, "topic") is not None + assert await client.edit_forum_topic(1, 2, "topic") is True + + await client.close() + + send_call = next(call for call in client.calls if call[0] == "sendMessage") + assert send_call[1]["disable_notification"] is True + assert send_call[1]["reply_to_message_id"] == 2 + assert send_call[1]["message_thread_id"] == 3 + assert send_call[1]["entities"] + assert send_call[1]["parse_mode"] == "Markdown" + assert send_call[1]["reply_markup"] + + doc_call = next(call for call in client.calls if call[0] == "sendDocument") + assert doc_call[2]["caption"] == "doc" + assert doc_call[3]["document"][0] == "file.txt" + + +@pytest.mark.anyio +async def test_decode_result_invalid_payload_returns_none() -> None: + client = HttpBotClient("token", http_client=httpx.AsyncClient()) + assert client._decode_result(method="getMe", payload=["bad"], model=User) is None + await client.close() diff --git a/tests/test_telegram_context_helpers.py b/tests/test_telegram_context_helpers.py new file mode 100644 index 0000000..83e7af3 --- /dev/null +++ b/tests/test_telegram_context_helpers.py @@ -0,0 +1,167 @@ +from dataclasses import replace +from pathlib import Path + +from takopi.config import ProjectConfig, ProjectsConfig +from takopi.context import RunContext +from takopi.router import AutoRouter, RunnerEntry +from takopi.runners.mock import Return, ScriptRunner +from takopi.telegram import context as tg_context +from takopi.telegram.topic_state import TopicThreadSnapshot +from takopi.transport_runtime import TransportRuntime +from tests.telegram_fakes import DEFAULT_ENGINE_ID, FakeTransport, make_cfg + + +def _runtime(tmp_path: Path) -> TransportRuntime: + runner = ScriptRunner([Return(answer="ok")], engine=DEFAULT_ENGINE_ID) + router = AutoRouter( + entries=[RunnerEntry(engine=runner.engine, runner=runner)], + default_engine=runner.engine, + ) + projects = ProjectsConfig( + projects={ + "alpha": ProjectConfig( + alias="Alpha", + path=tmp_path, + worktrees_dir=Path(".worktrees"), + ), + "beta": ProjectConfig( + alias="Beta", + path=tmp_path / "beta", + worktrees_dir=Path(".worktrees"), + ), + }, + default_project="alpha", + chat_map={123: "alpha"}, + ) + return TransportRuntime(router=router, projects=projects) + + +def _cfg(tmp_path: Path): + transport = FakeTransport() + return replace(make_cfg(transport), runtime=_runtime(tmp_path)) + + +def test_format_context_variants(tmp_path: Path) -> None: + runtime = _runtime(tmp_path) + assert tg_context._format_context(runtime, None) == "none" + assert tg_context._format_context(runtime, RunContext(project="alpha")) == "Alpha" + assert ( + tg_context._format_context(runtime, RunContext(project="alpha", branch="dev")) + == "Alpha @dev" + ) + + +def test_usage_helpers() -> None: + assert ( + tg_context._usage_ctx_set(chat_project=None) + == "usage: `/ctx set [@branch]`" + ) + assert ( + tg_context._usage_ctx_set(chat_project="alpha") == "usage: `/ctx set [@branch]`" + ) + assert ( + tg_context._usage_topic(chat_project=None) + == "usage: `/topic @branch`" + ) + assert tg_context._usage_topic(chat_project="alpha") == "usage: `/topic @branch`" + + +def test_parse_project_branch_args_missing_project(tmp_path: Path) -> None: + runtime = _runtime(tmp_path) + context, error = tg_context._parse_project_branch_args( + "", + runtime=runtime, + require_branch=False, + chat_project=None, + ) + assert context is None + assert error == "usage: `/ctx set [@branch]`" + + +def test_parse_project_branch_args_requires_branch(tmp_path: Path) -> None: + runtime = _runtime(tmp_path) + context, error = tg_context._parse_project_branch_args( + "alpha", + runtime=runtime, + require_branch=True, + chat_project=None, + ) + assert context is None + assert error == "branch is required" + + +def test_parse_project_branch_args_chat_project_mismatch(tmp_path: Path) -> None: + runtime = _runtime(tmp_path) + context, error = tg_context._parse_project_branch_args( + "beta @dev", + runtime=runtime, + require_branch=True, + chat_project="alpha", + ) + assert context is None + assert error is not None + assert "project mismatch" in error + assert "Alpha" in error + + +def test_parse_project_branch_args_missing_at_prefix(tmp_path: Path) -> None: + runtime = _runtime(tmp_path) + context, error = tg_context._parse_project_branch_args( + "alpha dev", + runtime=runtime, + require_branch=False, + chat_project=None, + ) + assert context is None + assert error == "branch must be prefixed with @" + + +def test_parse_project_branch_args_chat_project_branch_only(tmp_path: Path) -> None: + runtime = _runtime(tmp_path) + context, error = tg_context._parse_project_branch_args( + "@feature", + runtime=runtime, + require_branch=True, + chat_project="alpha", + ) + assert error is None + assert context == RunContext(project="alpha", branch="feature") + + +def test_format_ctx_status_includes_sessions(tmp_path: Path) -> None: + cfg = _cfg(tmp_path) + runtime = cfg.runtime + snapshot = TopicThreadSnapshot( + chat_id=cfg.chat_id, + thread_id=1, + context=None, + sessions={"b": "token", "a": "token2"}, + topic_title=None, + default_engine=None, + ) + text = tg_context._format_ctx_status( + cfg=cfg, + runtime=runtime, + bound=None, + resolved=RunContext(project="alpha", branch="main"), + context_source="directives", + snapshot=snapshot, + chat_project=None, + ) + assert "topics: enabled" in text + assert "bound ctx: none" in text + assert "resolved ctx: Alpha @main" in text + assert "note: unbound topic" in text + assert "sessions: a, b" in text + + +def test_merge_topic_context() -> None: + assert tg_context._merge_topic_context(chat_project=None, bound=None) is None + assert tg_context._merge_topic_context( + chat_project="alpha", + bound=None, + ) == RunContext(project="alpha", branch=None) + assert tg_context._merge_topic_context( + chat_project="alpha", + bound=RunContext(project=None, branch="dev"), + ) == RunContext(project="alpha", branch="dev") diff --git a/tests/test_telegram_file_transfer_helpers.py b/tests/test_telegram_file_transfer_helpers.py new file mode 100644 index 0000000..4de162d --- /dev/null +++ b/tests/test_telegram_file_transfer_helpers.py @@ -0,0 +1,1173 @@ +from dataclasses import replace +from pathlib import Path + +import pytest + +from takopi.config import ProjectConfig, ProjectsConfig +from takopi.context import RunContext +from takopi.router import AutoRouter, RunnerEntry +from takopi.runners.mock import Return, ScriptRunner +from takopi.telegram.api_models import ChatMember, File +from takopi.settings import TelegramFilesSettings +from takopi.telegram.commands import file_transfer as transfer +from takopi.telegram.types import TelegramDocument, TelegramIncomingMessage +from takopi.transport_runtime import ResolvedMessage, TransportRuntime +from tests.telegram_fakes import DEFAULT_ENGINE_ID, FakeBot, FakeTransport, make_cfg + + +class _FileBot(FakeBot): + def __init__(self, *, file_info: File | None, payload: bytes | None) -> None: + super().__init__() + self._file_info = file_info + self._payload = payload + + async def get_file(self, file_id: str) -> File | None: + _ = file_id + return self._file_info + + async def download_file(self, file_path: str) -> bytes | None: + _ = file_path + return self._payload + + +def _document( + *, + file_id: str = "file", + file_name: str | None = "upload.bin", + file_size: int | None = 1, +) -> TelegramDocument: + return TelegramDocument( + file_id=file_id, + file_name=file_name, + mime_type="application/octet-stream", + file_size=file_size, + raw={}, + ) + + +def _msg( + text: str, + *, + message_id: int = 1, + chat_id: int = 123, + sender_id: int | None = 1, + chat_type: str | None = None, + document: TelegramDocument | None = None, +) -> TelegramIncomingMessage: + return TelegramIncomingMessage( + transport="telegram", + chat_id=chat_id, + message_id=message_id, + text=text, + reply_to_message_id=None, + reply_to_text=None, + sender_id=sender_id, + chat_type=chat_type, + document=document, + ) + + +def _runtime(tmp_path: Path) -> TransportRuntime: + runner = ScriptRunner([Return(answer="ok")], engine=DEFAULT_ENGINE_ID) + router = AutoRouter( + entries=[RunnerEntry(engine=runner.engine, runner=runner)], + default_engine=runner.engine, + ) + projects = ProjectsConfig( + projects={ + "proj": ProjectConfig( + alias="proj", + path=tmp_path, + worktrees_dir=Path(".worktrees"), + ) + }, + default_project="proj", + ) + return TransportRuntime(router=router, projects=projects) + + +def _resolved() -> ResolvedMessage: + return ResolvedMessage( + prompt="", + resume_token=None, + engine_override=None, + context=None, + context_source="none", + ) + + +def _plan(tmp_path: Path, *, path_value: str | None) -> transfer._FilePutPlan: + return transfer._FilePutPlan( + resolved=_resolved(), + run_root=tmp_path, + path_value=path_value, + force=False, + ) + + +@pytest.mark.anyio +async def test_save_document_payload_rejects_large_file(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace( + make_cfg(transport), + bot=_FileBot(file_info=None, payload=None), + ) + document = _document(file_size=TelegramFilesSettings.max_upload_bytes + 1) + + result = await transfer._save_document_payload( + cfg, + document=document, + run_root=tmp_path, + rel_path=None, + base_dir=None, + force=False, + ) + + assert result.error == "file is too large to upload." + + +@pytest.mark.anyio +async def test_save_document_payload_denied_path(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace( + make_cfg(transport), + bot=_FileBot(file_info=File(file_path="files/x.bin"), payload=None), + ) + document = _document(file_name="x.bin") + + result = await transfer._save_document_payload( + cfg, + document=document, + run_root=tmp_path, + rel_path=None, + base_dir=Path(".git"), + force=False, + ) + + assert result.error == "path denied by rule: .git/**" + + +@pytest.mark.anyio +async def test_save_document_payload_existing_file(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace( + make_cfg(transport), + bot=_FileBot(file_info=File(file_path="files/report.txt"), payload=None), + ) + document = _document(file_name="report.txt") + target = tmp_path / cfg.files.uploads_dir / "report.txt" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("existing", encoding="utf-8") + + result = await transfer._save_document_payload( + cfg, + document=document, + run_root=tmp_path, + rel_path=None, + base_dir=None, + force=False, + ) + + assert result.error == "file already exists; use --force to overwrite." + + +@pytest.mark.anyio +async def test_save_document_payload_success(tmp_path: Path) -> None: + transport = FakeTransport() + payload = b"hello" + cfg = replace( + make_cfg(transport), + bot=_FileBot(file_info=File(file_path="files/report.txt"), payload=payload), + ) + document = _document(file_name="report.txt") + + result = await transfer._save_document_payload( + cfg, + document=document, + run_root=tmp_path, + rel_path=None, + base_dir=None, + force=False, + ) + + assert result.error is None + assert result.rel_path is not None + assert (tmp_path / result.rel_path).read_bytes() == payload + + +@pytest.mark.anyio +async def test_save_document_payload_missing_metadata(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace( + make_cfg(transport), + bot=_FileBot(file_info=None, payload=None), + ) + document = _document() + + result = await transfer._save_document_payload( + cfg, + document=document, + run_root=tmp_path, + rel_path=None, + base_dir=None, + force=False, + ) + + assert result.error == "failed to fetch file metadata." + + +@pytest.mark.anyio +async def test_save_document_payload_download_failed(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace( + make_cfg(transport), + bot=_FileBot(file_info=File(file_path="files/report.txt"), payload=None), + ) + document = _document(file_name="report.txt") + + result = await transfer._save_document_payload( + cfg, + document=document, + run_root=tmp_path, + rel_path=None, + base_dir=None, + force=False, + ) + + assert result.error == "failed to download file." + + +@pytest.mark.anyio +async def test_save_document_payload_target_is_dir(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace( + make_cfg(transport), + bot=_FileBot(file_info=File(file_path="files/report.txt"), payload=b"payload"), + ) + document = _document(file_name="report.txt") + target = tmp_path / "uploads" + target.mkdir() + + result = await transfer._save_document_payload( + cfg, + document=document, + run_root=tmp_path, + rel_path=Path("uploads"), + base_dir=None, + force=False, + ) + + assert result.error == "upload target is a directory." + + +def test_resolve_file_put_paths_invalid_dir(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + plan = _plan(tmp_path, path_value="../escape/") + + base_dir, rel_path, error = transfer.resolve_file_put_paths( + plan, + cfg=cfg, + require_dir=True, + ) + + assert base_dir is None + assert rel_path is None + assert error == "invalid upload path." + + +def test_resolve_file_put_paths_denied_rule(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + plan = _plan(tmp_path, path_value=".env/") + + base_dir, rel_path, error = transfer.resolve_file_put_paths( + plan, + cfg=cfg, + require_dir=True, + ) + + assert base_dir is None + assert rel_path is None + assert error == "path denied by rule: .env" + + +def test_resolve_file_put_paths_target_is_file(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + target = tmp_path / "uploads" + target.write_text("data", encoding="utf-8") + plan = _plan(tmp_path, path_value="uploads/") + + base_dir, rel_path, error = transfer.resolve_file_put_paths( + plan, + cfg=cfg, + require_dir=True, + ) + + assert base_dir is None + assert rel_path is None + assert error == "upload path is a file." + + +def test_resolve_file_put_paths_invalid_rel_path(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + plan = _plan(tmp_path, path_value="~/secret.txt") + + base_dir, rel_path, error = transfer.resolve_file_put_paths( + plan, + cfg=cfg, + require_dir=False, + ) + + assert base_dir is None + assert rel_path is None + assert error == "invalid upload path." + + +@pytest.mark.anyio +async def test_check_file_permissions_requires_sender(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + msg = _msg("/file put", sender_id=None) + + allowed = await transfer._check_file_permissions(cfg, msg) + + assert allowed is False + assert transport.send_calls + assert "cannot verify sender" in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_check_file_permissions_denies_unlisted_user(tmp_path: Path) -> None: + transport = FakeTransport() + files = TelegramFilesSettings(allowed_user_ids=[42]) + cfg = replace(make_cfg(transport), files=files) + msg = _msg("/file put", sender_id=1) + + allowed = await transfer._check_file_permissions(cfg, msg) + + assert allowed is False + assert transport.send_calls + assert "file transfer is not allowed" in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_check_file_permissions_denies_non_admin(tmp_path: Path) -> None: + class _MemberBot(FakeBot): + async def get_chat_member(self, chat_id: int, user_id: int): + _ = chat_id + _ = user_id + return ChatMember(status="member") + + transport = FakeTransport() + cfg = replace(make_cfg(transport), bot=_MemberBot()) + msg = _msg("/file put", chat_id=-123, chat_type="group") + + allowed = await transfer._check_file_permissions(cfg, msg) + + assert allowed is False + assert transport.send_calls + assert ( + "file transfer is restricted to group admins" + in transport.send_calls[-1]["message"].text + ) + + +@pytest.mark.anyio +async def test_prepare_file_put_plan_denied_user(tmp_path: Path) -> None: + transport = FakeTransport() + files = TelegramFilesSettings(allowed_user_ids=[42]) + cfg = replace(make_cfg(transport), files=files, runtime=_runtime(tmp_path)) + msg = _msg("/file put", sender_id=1) + + plan = await transfer._prepare_file_put_plan( + cfg, + msg, + "note.txt", + ambient_context=None, + topic_store=None, + ) + + assert plan is None + assert transport.send_calls + assert "file transfer is not allowed" in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_prepare_file_put_plan_directive_error(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace(make_cfg(transport), runtime=_runtime(tmp_path)) + msg = _msg("/file put") + + plan = await transfer._prepare_file_put_plan( + cfg, + msg, + "/proj /proj note.txt", + ambient_context=None, + topic_store=None, + ) + + assert plan is None + assert transport.send_calls + assert "multiple project directives" in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_prepare_file_put_plan_requires_context(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + msg = _msg("/file put") + + plan = await transfer._prepare_file_put_plan( + cfg, + msg, + "note.txt", + ambient_context=None, + topic_store=None, + ) + + assert plan is None + assert transport.send_calls + assert ( + "no project context available for file upload" + in transport.send_calls[-1]["message"].text + ) + + +@pytest.mark.anyio +async def test_prepare_file_put_plan_rejects_unknown_flag(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace(make_cfg(transport), runtime=_runtime(tmp_path)) + msg = _msg("/file put") + + plan = await transfer._prepare_file_put_plan( + cfg, + msg, + "--bogus note.txt", + ambient_context=None, + topic_store=None, + ) + + assert plan is None + assert transport.send_calls + assert "unknown flag" in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_save_file_put_group_requires_documents(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace(make_cfg(transport), runtime=_runtime(tmp_path)) + msg = _msg("/file put") + + result = await transfer._save_file_put_group( + cfg, + msg, + "", + [], + ambient_context=None, + topic_store=None, + ) + + assert result is None + assert transport.send_calls + assert "usage: /file put " in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_save_file_put_group_saves_documents(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace( + make_cfg(transport), + runtime=_runtime(tmp_path), + bot=_FileBot(file_info=File(file_path="files/doc.bin"), payload=b"payload"), + ) + msg = _msg( + "/file put uploads/", + document=_document(file_id="a", file_name="a.txt"), + ) + extra = _msg( + "/file put uploads/", + message_id=2, + document=_document(file_id="b", file_name="b.txt"), + ) + + result = await transfer._save_file_put_group( + cfg, + msg, + "uploads/", + [msg, extra], + ambient_context=None, + topic_store=None, + ) + + assert result is not None + assert result.base_dir == Path("uploads") + assert [item.name for item in result.saved] == ["a.txt", "b.txt"] + assert result.failed == [] + assert (tmp_path / "uploads" / "a.txt").read_bytes() == b"payload" + assert (tmp_path / "uploads" / "b.txt").read_bytes() == b"payload" + + +@pytest.mark.anyio +async def test_handle_file_put_saves_and_replies(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace( + make_cfg(transport), + runtime=_runtime(tmp_path), + bot=_FileBot(file_info=File(file_path="files/note.txt"), payload=b"hello"), + ) + msg = _msg("/file put note.txt", document=_document(file_name="note.txt")) + + await transfer._handle_file_put( + cfg, + msg, + "note.txt", + ambient_context=None, + topic_store=None, + ) + + assert (tmp_path / "note.txt").read_bytes() == b"hello" + assert transport.send_calls + assert "saved note.txt" in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_handle_file_put_default_delegates(monkeypatch) -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + msg = _msg("/file put") + called: dict[str, int] = {"count": 0} + + async def _fake_handle(*_args, **_kwargs) -> None: + called["count"] += 1 + + monkeypatch.setattr(transfer, "_handle_file_put", _fake_handle) + + await transfer._handle_file_put_default( + cfg, + msg, + ambient_context=None, + topic_store=None, + ) + + assert called["count"] == 1 + + +@pytest.mark.anyio +async def test_handle_file_command_routes(monkeypatch) -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + msg = _msg("/file") + calls: dict[str, int] = {"put": 0, "get": 0} + + async def _fake_put(*_args, **_kwargs) -> None: + calls["put"] += 1 + + async def _fake_get(*_args, **_kwargs) -> None: + calls["get"] += 1 + + monkeypatch.setattr(transfer, "_handle_file_put", _fake_put) + monkeypatch.setattr(transfer, "_handle_file_get", _fake_get) + + await transfer._handle_file_command( + cfg, + msg, + "put uploads/", + ambient_context=None, + topic_store=None, + ) + await transfer._handle_file_command( + cfg, + msg, + "get downloads/report.txt", + ambient_context=None, + topic_store=None, + ) + + assert calls["put"] == 1 + assert calls["get"] == 1 + + +@pytest.mark.anyio +async def test_handle_file_command_invalid_usage() -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + msg = _msg("/file") + + await transfer._handle_file_command( + cfg, + msg, + "unknown arg", + ambient_context=None, + topic_store=None, + ) + + assert transport.send_calls + assert "usage: /file put " in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_handle_file_put_group_formats_failures( + tmp_path: Path, monkeypatch +) -> None: + transport = FakeTransport() + cfg = replace(make_cfg(transport), runtime=_runtime(tmp_path)) + msg = _msg("/file put uploads/") + saved_group = transfer._SavedFilePutGroup( + context=RunContext(project="proj", branch=None), + base_dir=Path("uploads"), + saved=[ + transfer._FilePutResult( + name="a.txt", + rel_path=Path("uploads/a.txt"), + size=1, + error=None, + ) + ], + failed=[ + transfer._FilePutResult( + name="b.txt", + rel_path=None, + size=None, + error="boom", + ) + ], + ) + + async def _fake_save(*_args, **_kwargs): + return saved_group + + monkeypatch.setattr(transfer, "_save_file_put_group", _fake_save) + + await transfer._handle_file_put_group( + cfg, + msg, + "uploads/", + [msg], + ambient_context=None, + topic_store=None, + ) + + assert transport.send_calls + text = transport.send_calls[-1]["message"].text + assert "saved a.txt to uploads/" in text + assert "failed:" in text + + +@pytest.mark.anyio +async def test_handle_file_get_requires_path(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace(make_cfg(transport), runtime=_runtime(tmp_path)) + msg = _msg("/file get") + + await transfer._handle_file_get( + cfg, + msg, + "", + ambient_context=None, + topic_store=None, + ) + + assert transport.send_calls + assert "usage: /file get " in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_handle_file_get_invalid_path(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace(make_cfg(transport), runtime=_runtime(tmp_path)) + msg = _msg("/file get") + + await transfer._handle_file_get( + cfg, + msg, + "../secret.txt", + ambient_context=None, + topic_store=None, + ) + + assert transport.send_calls + assert "invalid download path" in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_handle_file_get_missing_file(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace(make_cfg(transport), runtime=_runtime(tmp_path)) + msg = _msg("/file get") + + await transfer._handle_file_get( + cfg, + msg, + "missing.txt", + ambient_context=None, + topic_store=None, + ) + + assert transport.send_calls + assert "file does not exist" in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_handle_file_get_sends_file(tmp_path: Path) -> None: + transport = FakeTransport() + bot = FakeBot() + cfg = replace(make_cfg(transport), runtime=_runtime(tmp_path), bot=bot) + target = tmp_path / "notes.txt" + target.write_bytes(b"hello") + msg = _msg("/file get") + + await transfer._handle_file_get( + cfg, + msg, + "notes.txt", + ambient_context=None, + topic_store=None, + ) + + assert bot.document_calls + call = bot.document_calls[-1] + assert call["filename"] == "notes.txt" + assert call["content"] == b"hello" + + +@pytest.mark.anyio +async def test_handle_file_get_sends_directory_zip(tmp_path: Path) -> None: + transport = FakeTransport() + bot = FakeBot() + cfg = replace(make_cfg(transport), runtime=_runtime(tmp_path), bot=bot) + bundle = tmp_path / "bundle" + bundle.mkdir() + (bundle / "file.txt").write_text("data", encoding="utf-8") + msg = _msg("/file get") + + await transfer._handle_file_get( + cfg, + msg, + "bundle", + ambient_context=None, + topic_store=None, + ) + + assert bot.document_calls + call = bot.document_calls[-1] + assert call["filename"] == "bundle.zip" + assert call["content"][:2] == b"PK" + + +@pytest.mark.anyio +async def test_save_document_payload_rejects_large_payload( + tmp_path: Path, monkeypatch +) -> None: + transport = FakeTransport() + cfg = replace( + make_cfg(transport), + bot=_FileBot(file_info=File(file_path="files/report.txt"), payload=b"xx"), + ) + document = _document(file_name="report.txt", file_size=None) + monkeypatch.setattr(TelegramFilesSettings, "max_upload_bytes", 1) + + result = await transfer._save_document_payload( + cfg, + document=document, + run_root=tmp_path, + rel_path=None, + base_dir=None, + force=False, + ) + + assert result.error == "file is too large to upload." + + +@pytest.mark.anyio +async def test_save_document_payload_write_error(tmp_path: Path, monkeypatch) -> None: + transport = FakeTransport() + cfg = replace( + make_cfg(transport), + bot=_FileBot(file_info=File(file_path="files/report.txt"), payload=b"data"), + ) + document = _document(file_name="report.txt") + + def _raise(*_args, **_kwargs): + raise OSError("boom") + + monkeypatch.setattr(transfer, "write_bytes_atomic", _raise) + + result = await transfer._save_document_payload( + cfg, + document=document, + run_root=tmp_path, + rel_path=None, + base_dir=None, + force=False, + ) + + assert result.error == "failed to write file: boom" + + +@pytest.mark.anyio +async def test_check_file_permissions_missing_member(tmp_path: Path) -> None: + class _NoMemberBot(FakeBot): + async def get_chat_member(self, chat_id: int, user_id: int): + _ = chat_id + _ = user_id + return None + + transport = FakeTransport() + cfg = replace(make_cfg(transport), bot=_NoMemberBot()) + msg = _msg("/file put", chat_id=-123, chat_type="group") + + allowed = await transfer._check_file_permissions(cfg, msg) + + assert allowed is False + assert transport.send_calls + assert ( + "failed to verify file transfer permissions" + in transport.send_calls[-1]["message"].text + ) + + +@pytest.mark.anyio +async def test_check_file_permissions_allows_admin(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + msg = _msg("/file put", chat_id=-123, chat_type="group") + + allowed = await transfer._check_file_permissions(cfg, msg) + + assert allowed is True + assert transport.send_calls == [] + + +@pytest.mark.anyio +async def test_save_file_put_requires_document(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace(make_cfg(transport), runtime=_runtime(tmp_path)) + msg = _msg("/file put") + + result = await transfer._save_file_put( + cfg, + msg, + "note.txt", + ambient_context=None, + topic_store=None, + ) + + assert result is None + assert transport.send_calls + assert "usage: /file put " in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_handle_file_put_skips_when_no_save(monkeypatch) -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + msg = _msg("/file put") + + async def _fake_save(*_args, **_kwargs): + return None + + monkeypatch.setattr(transfer, "_save_file_put", _fake_save) + + await transfer._handle_file_put( + cfg, + msg, + "note.txt", + ambient_context=None, + topic_store=None, + ) + + assert transport.send_calls == [] + + +@pytest.mark.anyio +async def test_handle_file_put_group_skips_when_no_save(monkeypatch) -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + msg = _msg("/file put") + + async def _fake_save(*_args, **_kwargs): + return None + + monkeypatch.setattr(transfer, "_save_file_put_group", _fake_save) + + await transfer._handle_file_put_group( + cfg, + msg, + "uploads/", + [msg], + ambient_context=None, + topic_store=None, + ) + + assert transport.send_calls == [] + + +@pytest.mark.anyio +async def test_handle_file_put_group_infers_dir(tmp_path: Path, monkeypatch) -> None: + transport = FakeTransport() + cfg = replace(make_cfg(transport), runtime=_runtime(tmp_path)) + msg = _msg("/file put") + saved_group = transfer._SavedFilePutGroup( + context=RunContext(project="proj", branch=None), + base_dir=None, + saved=[ + transfer._FilePutResult( + name="a.txt", + rel_path=Path("incoming/a.txt"), + size=1, + error=None, + ) + ], + failed=[], + ) + + async def _fake_save(*_args, **_kwargs): + return saved_group + + monkeypatch.setattr(transfer, "_save_file_put_group", _fake_save) + + await transfer._handle_file_put_group( + cfg, + msg, + "", + [msg], + ambient_context=None, + topic_store=None, + ) + + assert transport.send_calls + text = transport.send_calls[-1]["message"].text + assert "saved a.txt to incoming/" in text + + +@pytest.mark.anyio +async def test_handle_file_get_permission_denied(tmp_path: Path) -> None: + transport = FakeTransport() + files = TelegramFilesSettings(allowed_user_ids=[42]) + cfg = replace(make_cfg(transport), runtime=_runtime(tmp_path), files=files) + msg = _msg("/file get", sender_id=1) + + await transfer._handle_file_get( + cfg, + msg, + "notes.txt", + ambient_context=None, + topic_store=None, + ) + + assert transport.send_calls + assert "file transfer is not allowed" in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_handle_file_get_send_failure(tmp_path: Path) -> None: + class _NoSendBot(FakeBot): + async def send_document(self, *args, **kwargs): + _ = args + _ = kwargs + return None + + transport = FakeTransport() + cfg = replace(make_cfg(transport), runtime=_runtime(tmp_path), bot=_NoSendBot()) + target = tmp_path / "notes.txt" + target.write_text("data", encoding="utf-8") + msg = _msg("/file get") + + await transfer._handle_file_get( + cfg, + msg, + "notes.txt", + ambient_context=None, + topic_store=None, + ) + + assert transport.send_calls + assert "failed to send file" in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_save_file_put_reports_invalid_path(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace( + make_cfg(transport), + runtime=_runtime(tmp_path), + bot=_FileBot(file_info=File(file_path="files/note.txt"), payload=b"hi"), + ) + msg = _msg("/file put", document=_document(file_name="note.txt")) + + result = await transfer._save_file_put( + cfg, + msg, + "../bad/path", + ambient_context=None, + topic_store=None, + ) + + assert result is None + assert transport.send_calls + assert "invalid upload path" in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_save_file_put_reports_document_error(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace( + make_cfg(transport), + runtime=_runtime(tmp_path), + bot=_FileBot(file_info=None, payload=None), + ) + msg = _msg("/file put", document=_document(file_name="note.txt")) + + result = await transfer._save_file_put( + cfg, + msg, + "note.txt", + ambient_context=None, + topic_store=None, + ) + + assert result is None + assert transport.send_calls + assert "failed to fetch file metadata" in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_save_file_put_reports_missing_path(tmp_path: Path, monkeypatch) -> None: + transport = FakeTransport() + cfg = replace(make_cfg(transport), runtime=_runtime(tmp_path)) + msg = _msg("/file put", document=_document(file_name="note.txt")) + + async def _fake_save(*_args, **_kwargs): + return transfer._FilePutResult( + name="note.txt", + rel_path=None, + size=None, + error=None, + ) + + monkeypatch.setattr(transfer, "_save_document_payload", _fake_save) + + result = await transfer._save_file_put( + cfg, + msg, + "note.txt", + ambient_context=None, + topic_store=None, + ) + + assert result is None + assert transport.send_calls + assert "failed to save file" in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_handle_file_get_requires_context(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + msg = _msg("/file get") + + await transfer._handle_file_get( + cfg, + msg, + "note.txt", + ambient_context=None, + topic_store=None, + ) + + assert transport.send_calls + assert ( + "no project context available for file download" + in transport.send_calls[-1]["message"].text + ) + + +@pytest.mark.anyio +async def test_handle_file_get_denies_path(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace(make_cfg(transport), runtime=_runtime(tmp_path)) + msg = _msg("/file get") + + await transfer._handle_file_get( + cfg, + msg, + ".env", + ambient_context=None, + topic_store=None, + ) + + assert transport.send_calls + assert "path denied by rule: .env" in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_handle_file_get_escape_root(tmp_path: Path, monkeypatch) -> None: + transport = FakeTransport() + cfg = replace(make_cfg(transport), runtime=_runtime(tmp_path)) + msg = _msg("/file get") + + monkeypatch.setattr(transfer, "resolve_path_within_root", lambda *_a, **_k: None) + + await transfer._handle_file_get( + cfg, + msg, + "note.txt", + ambient_context=None, + topic_store=None, + ) + + assert transport.send_calls + assert ( + "download path escapes the repo root" + in transport.send_calls[-1]["message"].text + ) + + +@pytest.mark.anyio +async def test_handle_file_get_zip_too_large(tmp_path: Path, monkeypatch) -> None: + transport = FakeTransport() + cfg = replace(make_cfg(transport), runtime=_runtime(tmp_path)) + bundle = tmp_path / "bundle" + bundle.mkdir() + (bundle / "file.txt").write_text("data", encoding="utf-8") + msg = _msg("/file get") + + def _raise(*_args, **_kwargs): + raise transfer.ZipTooLargeError() + + monkeypatch.setattr(transfer, "zip_directory", _raise) + + await transfer._handle_file_get( + cfg, + msg, + "bundle", + ambient_context=None, + topic_store=None, + ) + + assert transport.send_calls + assert "file is too large to send" in transport.send_calls[-1]["message"].text + + +@pytest.mark.anyio +async def test_handle_file_get_file_too_large(tmp_path: Path, monkeypatch) -> None: + transport = FakeTransport() + cfg = replace(make_cfg(transport), runtime=_runtime(tmp_path)) + target = tmp_path / "notes.txt" + target.write_bytes(b"data") + msg = _msg("/file get") + + monkeypatch.setattr(TelegramFilesSettings, "max_download_bytes", 1) + + await transfer._handle_file_get( + cfg, + msg, + "notes.txt", + ambient_context=None, + topic_store=None, + ) + + assert transport.send_calls + assert "file is too large to send" in transport.send_calls[-1]["message"].text diff --git a/tests/test_telegram_files.py b/tests/test_telegram_files.py index 520cc2a..48a5fae 100644 --- a/tests/test_telegram_files.py +++ b/tests/test_telegram_files.py @@ -6,6 +6,7 @@ from pathlib import Path import pytest +from takopi.telegram import files as tg_files from takopi.telegram.files import ZipTooLargeError, zip_directory @@ -41,3 +42,70 @@ def test_zip_directory_limits_size(tmp_path: Path) -> None: with pytest.raises(ZipTooLargeError): zip_directory(root, Path("dir"), deny_globs=(), max_bytes=10) + + +def test_split_command_args_falls_back_on_bad_quotes() -> None: + assert tg_files.split_command_args('bad "quote') == ("bad", '"quote') + + +def test_parse_file_command_unknown_command() -> None: + command, rest, error = tg_files.parse_file_command("nope arg") + assert command is None + assert rest == "arg" + assert error == tg_files.file_usage() + + +def test_parse_file_prompt_errors() -> None: + path, force, error = tg_files.parse_file_prompt("--wat", allow_empty=False) + assert path is None + assert force is False + assert error == "unknown flag: --wat" + + path, force, error = tg_files.parse_file_prompt("", allow_empty=False) + assert path is None + assert force is False + assert error == "missing path" + + +def test_parse_file_prompt_force_flag() -> None: + path, force, error = tg_files.parse_file_prompt( + "--force note.txt", allow_empty=False + ) + assert path == "note.txt" + assert force is True + assert error is None + + +def test_normalize_relative_path_rejects_invalid() -> None: + for value in ("", " ", "~/.ssh", "/etc/passwd", "../secret", ".git/config"): + assert tg_files.normalize_relative_path(value) is None + + +def test_normalize_relative_path_rejects_dot_only() -> None: + assert tg_files.normalize_relative_path("./") is None + + +def test_normalize_relative_path_strips_dots() -> None: + assert tg_files.normalize_relative_path("docs/./guide.txt") == Path( + "docs/guide.txt" + ) + + +def test_resolve_path_within_root_rejects_escape(tmp_path: Path) -> None: + assert tg_files.resolve_path_within_root(tmp_path, Path("../escape")) is None + + +def test_deny_reason_matches_patterns() -> None: + assert tg_files.deny_reason(Path(".git/config"), ["**/*.pem"]) == ".git/**" + assert tg_files.deny_reason(Path("secrets/key.pem"), ["**/*.pem"]) == "**/*.pem" + + +def test_format_bytes_various_units() -> None: + assert tg_files.format_bytes(0) == "0 b" + assert tg_files.format_bytes(1536) == "1.5 kb" + assert tg_files.format_bytes(20480) == "20 kb" + + +def test_default_upload_name_fallbacks() -> None: + assert tg_files.default_upload_name("", "files/report.txt") == "report.txt" + assert tg_files.default_upload_name(None, None) == "upload.bin" diff --git a/tests/test_telegram_media_command.py b/tests/test_telegram_media_command.py new file mode 100644 index 0000000..20da02c --- /dev/null +++ b/tests/test_telegram_media_command.py @@ -0,0 +1,212 @@ +from dataclasses import replace +from pathlib import Path + +import pytest + +from takopi.context import RunContext +from takopi.settings import TelegramFilesSettings +from takopi.telegram.commands import media as media_commands +from takopi.telegram.commands.file_transfer import _FilePutResult, _SavedFilePutGroup +from takopi.telegram.types import TelegramDocument, TelegramIncomingMessage +from takopi.transport_runtime import ResolvedMessage +from tests.telegram_fakes import FakeTransport, make_cfg + + +def _msg( + text: str, + *, + message_id: int = 1, + chat_id: int = 123, + document: TelegramDocument | None = None, +) -> TelegramIncomingMessage: + return TelegramIncomingMessage( + transport="telegram", + chat_id=chat_id, + message_id=message_id, + text=text, + reply_to_message_id=None, + reply_to_text=None, + sender_id=1, + document=document, + ) + + +@pytest.mark.anyio +async def test_media_group_empty_is_noop() -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + + await media_commands._handle_media_group(cfg, [], topic_store=None) + + assert transport.send_calls == [] + + +@pytest.mark.anyio +async def test_media_group_file_command_reports_usage() -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + msg = _msg("/file") + + await media_commands._handle_media_group(cfg, [msg], topic_store=None) + + assert transport.send_calls + text = transport.send_calls[-1]["message"].text + assert "usage: /file put " in text + assert "or /file get " in text + + +@pytest.mark.anyio +async def test_media_group_file_put_delegates(monkeypatch) -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + msg = _msg("/file put uploads/") + calls: dict[str, int] = {"count": 0} + + async def _fake_handle(*_args, **_kwargs) -> None: + calls["count"] += 1 + + monkeypatch.setattr(media_commands, "_handle_file_put_group", _fake_handle) + + await media_commands._handle_media_group(cfg, [msg], topic_store=None) + + assert calls["count"] == 1 + + +@pytest.mark.anyio +async def test_media_group_auto_put_without_caption_delegates(monkeypatch) -> None: + transport = FakeTransport() + cfg = replace(make_cfg(transport), files=TelegramFilesSettings(enabled=True)) + msg = _msg("") + calls: dict[str, int] = {"count": 0} + + async def _fake_handle(*_args, **_kwargs) -> None: + calls["count"] += 1 + + monkeypatch.setattr(media_commands, "_handle_file_put_group", _fake_handle) + + await media_commands._handle_media_group(cfg, [msg], topic_store=None) + + assert calls["count"] == 1 + + +@pytest.mark.anyio +async def test_media_group_auto_put_prompt_resolve_none(monkeypatch) -> None: + transport = FakeTransport() + files = TelegramFilesSettings(enabled=True, auto_put=True, auto_put_mode="prompt") + cfg = replace(make_cfg(transport), files=files) + msg = _msg("caption") + + async def _resolve_prompt(*_args, **_kwargs): + return None + + monkeypatch.setattr(media_commands, "_save_file_put_group", lambda *_a, **_k: None) + + await media_commands._handle_media_group( + cfg, + [msg], + topic_store=None, + resolve_prompt=_resolve_prompt, + ) + + assert transport.send_calls == [] + + +@pytest.mark.anyio +async def test_media_group_auto_put_prompt_runs_prompt(monkeypatch) -> None: + transport = FakeTransport() + files = TelegramFilesSettings(enabled=True, auto_put=True, auto_put_mode="prompt") + cfg = replace(make_cfg(transport), files=files) + msg = _msg("caption") + resolved = ResolvedMessage( + prompt="do the thing", + resume_token=None, + engine_override=None, + context=RunContext(project="proj"), + context_source="directives", + ) + saved_group = _SavedFilePutGroup( + context=resolved.context, + base_dir=None, + saved=[ + _FilePutResult( + name="a.txt", + rel_path=Path("incoming/a.txt"), + size=1, + error=None, + ) + ], + failed=[], + ) + prompt_calls: list[str] = [] + + async def _resolve_prompt(*_args, **_kwargs): + return resolved + + async def _save_group(*_args, **_kwargs): + return saved_group + + async def _run_prompt(_msg, prompt: str, _resolved: ResolvedMessage) -> None: + prompt_calls.append(prompt) + + monkeypatch.setattr(media_commands, "_save_file_put_group", _save_group) + + await media_commands._handle_media_group( + cfg, + [msg], + topic_store=None, + resolve_prompt=_resolve_prompt, + run_prompt=_run_prompt, + ) + + assert prompt_calls + assert "[uploaded files]" in prompt_calls[0] + assert "incoming/a.txt" in prompt_calls[0] + + +@pytest.mark.anyio +async def test_media_group_auto_put_prompt_saved_failure(monkeypatch) -> None: + transport = FakeTransport() + files = TelegramFilesSettings(enabled=True, auto_put=True, auto_put_mode="prompt") + cfg = replace(make_cfg(transport), files=files) + msg = _msg("caption") + resolved = ResolvedMessage( + prompt="do the thing", + resume_token=None, + engine_override=None, + context=RunContext(project="proj"), + context_source="directives", + ) + saved_group = _SavedFilePutGroup( + context=resolved.context, + base_dir=None, + saved=[], + failed=[ + _FilePutResult( + name="a.txt", + rel_path=None, + size=None, + error="boom", + ) + ], + ) + + async def _resolve_prompt(*_args, **_kwargs): + return resolved + + async def _save_group(*_args, **_kwargs): + return saved_group + + monkeypatch.setattr(media_commands, "_save_file_put_group", _save_group) + + await media_commands._handle_media_group( + cfg, + [msg], + topic_store=None, + resolve_prompt=_resolve_prompt, + ) + + assert transport.send_calls + text = transport.send_calls[-1]["message"].text + assert "failed to upload files" in text + assert "failed:" in text + assert "boom" in text diff --git a/tests/test_telegram_queue.py b/tests/test_telegram_queue.py index 169467a..9d83089 100644 --- a/tests/test_telegram_queue.py +++ b/tests/test_telegram_queue.py @@ -7,7 +7,7 @@ from takopi.telegram.api_models import File, Message, Update, User from takopi.telegram.client import BotClient, TelegramClient, TelegramRetryAfter -class _FakeBot(BotClient): +class FakeBot(BotClient): def __init__(self) -> None: self.calls: list[str] = [] self.edit_calls: list[str] = [] @@ -157,7 +157,7 @@ class _FakeBot(BotClient): @pytest.mark.anyio async def test_edit_forum_topic_uses_outbox() -> None: - bot = _FakeBot() + bot = FakeBot() client = TelegramClient(client=bot, private_chat_rps=0.0, group_chat_rps=0.0) result = await client.edit_forum_topic( @@ -171,7 +171,7 @@ async def test_edit_forum_topic_uses_outbox() -> None: @pytest.mark.anyio async def test_edits_coalesce_latest() -> None: - class _BlockingBot(_FakeBot): + class _BlockingBot(FakeBot): def __init__(self) -> None: super().__init__() self.edit_started = anyio.Event() @@ -240,7 +240,7 @@ async def test_edits_coalesce_latest() -> None: @pytest.mark.anyio async def test_send_preempts_pending_edit() -> None: - bot = _FakeBot() + bot = FakeBot() client = TelegramClient(client=bot, private_chat_rps=0.0, group_chat_rps=0.0) await client.edit_message_text( @@ -269,7 +269,7 @@ async def test_send_preempts_pending_edit() -> None: @pytest.mark.anyio async def test_delete_drops_pending_edits() -> None: - bot = _FakeBot() + bot = FakeBot() client = TelegramClient(client=bot, private_chat_rps=0.0, group_chat_rps=0.0) await client.edit_message_text( @@ -300,7 +300,7 @@ async def test_delete_drops_pending_edits() -> None: @pytest.mark.anyio async def test_retry_after_retries_once() -> None: - bot = _FakeBot() + bot = FakeBot() bot.retry_after = 0.0 client = TelegramClient(client=bot, private_chat_rps=0.0, group_chat_rps=0.0) @@ -317,7 +317,7 @@ async def test_retry_after_retries_once() -> None: @pytest.mark.anyio async def test_get_updates_retries_on_retry_after() -> None: - bot = _FakeBot() + bot = FakeBot() bot.updates_retry_after = 0.0 client = TelegramClient(client=bot, private_chat_rps=0.0, group_chat_rps=0.0) diff --git a/tests/test_telegram_topics_command.py b/tests/test_telegram_topics_command.py new file mode 100644 index 0000000..2c67bbf --- /dev/null +++ b/tests/test_telegram_topics_command.py @@ -0,0 +1,131 @@ +from dataclasses import replace +from pathlib import Path + +import pytest + +from takopi.settings import TelegramTopicsSettings +from takopi.telegram.chat_sessions import ChatSessionStore +from takopi.telegram.commands.topics import ( + _handle_chat_new_command, + _handle_ctx_command, + _handle_new_command, + _handle_topic_command, +) +from takopi.telegram.topic_state import TopicStateStore +from takopi.telegram.types import TelegramIncomingMessage +from tests.telegram_fakes import FakeTransport, make_cfg + + +def _msg( + text: str, + *, + chat_id: int = 123, + message_id: int = 1, + thread_id: int | None = None, + chat_type: str | None = "private", +) -> TelegramIncomingMessage: + return TelegramIncomingMessage( + transport="telegram", + chat_id=chat_id, + message_id=message_id, + text=text, + reply_to_message_id=None, + reply_to_text=None, + sender_id=1, + thread_id=thread_id, + chat_type=chat_type, + ) + + +@pytest.mark.anyio +async def test_ctx_command_requires_topic(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace( + make_cfg(transport), + topics=TelegramTopicsSettings(enabled=True, scope="all"), + ) + store = TopicStateStore(tmp_path / "topics.json") + msg = _msg("/ctx") + + await _handle_ctx_command( + cfg, + msg, + args_text="", + store=store, + resolved_scope="all", + scope_chat_ids=frozenset({msg.chat_id}), + ) + + text = transport.send_calls[-1]["message"].text + assert "only works inside a topic" in text + + +@pytest.mark.anyio +async def test_new_command_requires_topic(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace( + make_cfg(transport), + topics=TelegramTopicsSettings(enabled=True, scope="all"), + ) + store = TopicStateStore(tmp_path / "topics.json") + msg = _msg("/new") + + await _handle_new_command( + cfg, + msg, + store=store, + resolved_scope="all", + scope_chat_ids=frozenset({msg.chat_id}), + ) + + text = transport.send_calls[-1]["message"].text + assert "only works inside a topic" in text + + +@pytest.mark.anyio +async def test_chat_new_command_no_sessions(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + store = ChatSessionStore(tmp_path / "sessions.json") + msg = _msg("/new", chat_type="private") + + await _handle_chat_new_command(cfg, msg, store, session_key=None) + + text = transport.send_calls[-1]["message"].text + assert "no stored sessions" in text + + +@pytest.mark.anyio +async def test_chat_new_command_group_clears(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + store = ChatSessionStore(tmp_path / "sessions.json") + msg = _msg("/new", chat_type="supergroup") + + await _handle_chat_new_command(cfg, msg, store, session_key=(msg.chat_id, 1)) + + text = transport.send_calls[-1]["message"].text + assert "cleared stored sessions for you in this chat" in text + + +@pytest.mark.anyio +async def test_topic_command_requires_args(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = replace( + make_cfg(transport), + topics=TelegramTopicsSettings(enabled=True, scope="all"), + ) + store = TopicStateStore(tmp_path / "topics.json") + msg = _msg("/topic") + + await _handle_topic_command( + cfg, + msg, + args_text="", + store=store, + resolved_scope="all", + scope_chat_ids=frozenset({msg.chat_id}), + ) + + text = transport.send_calls[-1]["message"].text + assert "usage: /topic" in text diff --git a/tests/test_telegram_topics_helpers.py b/tests/test_telegram_topics_helpers.py new file mode 100644 index 0000000..bf61d88 --- /dev/null +++ b/tests/test_telegram_topics_helpers.py @@ -0,0 +1,34 @@ +from dataclasses import replace + +from takopi.settings import TelegramTopicsSettings +from takopi.telegram.topics import _resolve_topics_scope_raw, _topics_command_error +from tests.telegram_fakes import FakeTransport, make_cfg + + +def test_resolve_topics_scope_raw() -> None: + resolved, chat_ids = _resolve_topics_scope_raw("auto", 1, ()) + assert resolved == "main" + assert chat_ids == frozenset({1}) + + resolved, chat_ids = _resolve_topics_scope_raw("projects", 1, (2, 3)) + assert resolved == "projects" + assert chat_ids == frozenset({2, 3}) + + resolved, chat_ids = _resolve_topics_scope_raw("all", 1, (2,)) + assert resolved == "all" + assert chat_ids == frozenset({1, 2}) + + +def test_topics_command_error_for_wrong_chat() -> None: + transport = FakeTransport() + cfg = replace( + make_cfg(transport), + topics=TelegramTopicsSettings(enabled=True, scope="main"), + ) + error = _topics_command_error( + cfg, + chat_id=999, + resolved_scope="main", + scope_chat_ids=frozenset({cfg.chat_id}), + ) + assert error == "topics commands are only available in the main chat." diff --git a/tests/test_telegram_voice.py b/tests/test_telegram_voice.py index be88192..87d4795 100644 --- a/tests/test_telegram_voice.py +++ b/tests/test_telegram_voice.py @@ -13,7 +13,7 @@ from takopi.telegram.api_models import ( ) from takopi.telegram.client import BotClient from takopi.telegram.types import TelegramIncomingMessage, TelegramVoice -from takopi.telegram.voice import transcribe_voice +from takopi.telegram.voice import VOICE_TRANSCRIPTION_DISABLED_HINT, transcribe_voice class _Bot(BotClient): @@ -176,6 +176,42 @@ def _voice_message(*, file_size: int = 123) -> TelegramIncomingMessage: ) +class _Transcriber: + def __init__(self, *, result: str | None = None, error: Exception | None = None): + self.calls: list[tuple[str, bytes]] = [] + self._result = result + self._error = error + + async def transcribe(self, *, model: str, audio_bytes: bytes) -> str: + self.calls.append((model, audio_bytes)) + if self._error is not None: + raise self._error + assert self._result is not None + return self._result + + +@pytest.mark.anyio +async def test_transcribe_voice_disabled_replies_with_hint() -> None: + replies: list[str] = [] + + async def reply(**kwargs) -> None: + replies.append(kwargs["text"]) + + transcriber = _Transcriber(result="should-not-run") + result = await transcribe_voice( + bot=_Bot(file_info=None, audio=None), + msg=_voice_message(), + enabled=False, + model="whisper-1", + reply=reply, + transcriber=transcriber, + ) + + assert result is None + assert replies[-1] == VOICE_TRANSCRIPTION_DISABLED_HINT + assert transcriber.calls == [] + + @pytest.mark.anyio async def test_transcribe_voice_handles_missing_file() -> None: replies: list[str] = [] @@ -244,3 +280,73 @@ async def test_transcribe_voice_rejects_large_voice_without_downloading() -> Non assert result is None assert replies[-1] == "voice message is too large to transcribe." + + +@pytest.mark.anyio +async def test_transcribe_voice_rejects_large_download() -> None: + replies: list[str] = [] + + async def reply(**kwargs) -> None: + replies.append(kwargs["text"]) + + transcriber = _Transcriber(result="should-not-run") + bot = _Bot(file_info=File(file_path="voice.ogg"), audio=b"x" * 200) + result = await transcribe_voice( + bot=bot, + msg=_voice_message(file_size=10), + enabled=True, + model="whisper-1", + max_bytes=100, + reply=reply, + transcriber=transcriber, + ) + + assert result is None + assert replies[-1] == "voice message is too large to transcribe." + assert transcriber.calls == [] + + +@pytest.mark.anyio +async def test_transcribe_voice_handles_transcriber_error() -> None: + replies: list[str] = [] + + async def reply(**kwargs) -> None: + replies.append(kwargs["text"]) + + transcriber = _Transcriber(error=RuntimeError("boom")) + bot = _Bot(file_info=File(file_path="voice.ogg"), audio=b"ok") + result = await transcribe_voice( + bot=bot, + msg=_voice_message(file_size=2), + enabled=True, + model="whisper-1", + reply=reply, + transcriber=transcriber, + ) + + assert result is None + assert replies[-1] == "boom" + assert transcriber.calls + + +@pytest.mark.anyio +async def test_transcribe_voice_success() -> None: + replies: list[str] = [] + + async def reply(**kwargs) -> None: + replies.append(kwargs["text"]) + + transcriber = _Transcriber(result="transcribed") + bot = _Bot(file_info=File(file_path="voice.ogg"), audio=b"ok") + result = await transcribe_voice( + bot=bot, + msg=_voice_message(file_size=2), + enabled=True, + model="whisper-1", + reply=reply, + transcriber=transcriber, + ) + + assert result == "transcribed" + assert replies == [] + assert transcriber.calls diff --git a/tests/test_tool_actions.py b/tests/test_tool_actions.py new file mode 100644 index 0000000..9d32be0 --- /dev/null +++ b/tests/test_tool_actions.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import pytest + +from takopi.runners import tool_actions +from takopi.utils.paths import reset_run_base_dir, set_run_base_dir + + +def test_tool_input_path_picks_first_match() -> None: + tool_input = {"path": "src/main.py", "file": "ignored.txt"} + assert ( + tool_actions.tool_input_path(tool_input, path_keys=("file", "path")) + == "ignored.txt" + ) + assert ( + tool_actions.tool_input_path(tool_input, path_keys=("path", "file")) + == "src/main.py" + ) + assert tool_actions.tool_input_path(tool_input, path_keys=("missing",)) is None + + +@pytest.mark.parametrize( + ("tool_name", "tool_input", "expected_kind", "expected_title"), + [ + ("bash", {"command": "echo hi"}, "command", "echo hi"), + ("shell", {"command": "pwd"}, "command", "pwd"), + ("edit", {"path": "src/app.py"}, "file_change", "src/app.py"), + ("write", {"path": "notes.txt"}, "file_change", "notes.txt"), + ("read", {"path": "README.md"}, "tool", "read: `README.md`"), + ("glob", {"pattern": "*.py"}, "tool", "glob: `*.py`"), + ("grep", {"pattern": "TODO"}, "tool", "grep: TODO"), + ("find", {"pattern": "*.toml"}, "tool", "find: *.toml"), + ("ls", {"path": "src"}, "tool", "ls: `src`"), + ("websearch", {"query": "takopi"}, "web_search", "takopi"), + ( + "webfetch", + {"url": "https://example.com"}, + "web_search", + "https://example.com", + ), + ("todowrite", {}, "note", "update todos"), + ("todoread", {}, "note", "read todos"), + ("askuserquestion", {}, "note", "ask user"), + ("task", {"description": "do work"}, "subagent", "do work"), + ("agent", {"prompt": "assist"}, "subagent", "assist"), + ("unknown", {}, "tool", "unknown"), + ], +) +def test_tool_kind_and_title_cases( + tool_name: str, + tool_input: dict[str, object], + expected_kind: str, + expected_title: str, +) -> None: + token = set_run_base_dir(None) + try: + kind, title = tool_actions.tool_kind_and_title( + tool_name, + tool_input, + path_keys=("path", "file"), + ) + finally: + reset_run_base_dir(token) + + assert kind == expected_kind + assert title == expected_title + + +def test_tool_kind_and_title_task_kind_override() -> None: + kind, title = tool_actions.tool_kind_and_title( + "agent", + {"description": "spawn worker"}, + path_keys=(), + task_kind="warning", + ) + + assert kind == "warning" + assert title == "spawn worker"