from __future__ import annotations from pathlib import Path from typing import Any, cast import pytest from rich.console import Console 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 Chat, Message, 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 def test_debug_onboarding_paths_prints_table() -> None: console = Console(record=True, width=120) onboarding.debug_onboarding_paths(console=console) output = console.export_text() assert "onboarding paths (15)" in output assert "workspace" in output assert "assistant" in output assert "handoff" in output @pytest.mark.anyio async def test_confirm_prompt_returns_question_result(monkeypatch) -> None: seen: dict[str, object] = {} class _PromptSession: def __init__(self, *args, **kwargs) -> None: seen["args"] = args seen["kwargs"] = kwargs self.app = object() class _Question: def __init__(self, app) -> None: seen["app"] = app async def ask_async(self) -> bool | None: return True monkeypatch.setattr(onboarding, "PromptSession", _PromptSession) monkeypatch.setattr(onboarding, "Question", _Question) result = await onboarding.confirm_prompt("continue?", default=False) assert result is True assert "kwargs" in seen @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=Message( message_id=1, from_=User(id=1, is_bot=True), chat=Chat(id=1, type="private"), ), ) ], None, [], [Update(update_id=2, message=None)], [ Update( update_id=3, message=Message( message_id=3, from_=User(id=2, is_bot=True), chat=Chat(id=2, type="private"), ), ) ], [ Update( update_id=4, message=Message( message_id=4, from_=User(id=3, is_bot=True), chat=Chat(id=3, type="private"), ), ) ], [ Update( update_id=5, message=Message( message_id=5, from_=User(id=4, is_bot=True), chat=Chat(id=4, type="private"), ), ) ], [ Update( update_id=6, message=Message( message_id=6, from_=User(id=5, is_bot=False), chat=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 )