diff --git a/docs/explanation/routing-and-sessions.md b/docs/explanation/routing-and-sessions.md index 6490069..d71b36d 100644 --- a/docs/explanation/routing-and-sessions.md +++ b/docs/explanation/routing-and-sessions.md @@ -9,12 +9,15 @@ Takopi supports three ways to continue a thread: 1. **Reply-to-continue** (always available) - Reply to any bot message that contains a resume line in the footer. - Takopi extracts the resume token and resumes that engine thread. + - Reply resume lines always take precedence over chat sessions or topic storage. + - The resumed run updates the stored session for that engine when the token is known. 2. **Forum topics** (optional) - Topics can store resume tokens per topic and auto-resume new messages in that topic. - Topic state is stored in `telegram_topics_state.json`. - Reset with `/new`. 3. **Chat sessions** (optional) - Set `session_mode = "chat"` to store one resume token per chat (per sender in groups). + - Stored sessions are per engine; resuming a different engine does not overwrite others. - State is stored in `telegram_chat_sessions_state.json`. - Reset with `/new`. @@ -39,6 +42,7 @@ The precise invariants are specified in the [Specification](../reference/specifi ## Related +- [Conversation modes](../tutorials/conversation-modes.md) +- [Chat sessions](../how-to/chat-sessions.md) - [Commands & directives](../reference/commands-and-directives.md) - [Context resolution](../reference/context-resolution.md) - diff --git a/docs/how-to/chat-sessions.md b/docs/how-to/chat-sessions.md new file mode 100644 index 0000000..e6ff3f9 --- /dev/null +++ b/docs/how-to/chat-sessions.md @@ -0,0 +1,43 @@ +# Chat sessions + +Chat sessions store one resume token per engine per chat (per sender in group chats), so new messages can auto-resume without replying. Reply-to-continue still works and updates the stored session for that engine. + +## Enable chat sessions + +```toml +[transports.telegram] +session_mode = "chat" # stateless | chat +``` + +With `session_mode = "chat"`, new messages in the chat continue the current thread automatically. + +## Reset a session + +Use `/new` to clear the stored session for the current scope: + +- In a private chat, it resets the chat. +- In a group, it resets **your** session in that chat. +- In a forum topic, it resets the topic session. + +See `/new` in [Commands & directives](../reference/commands-and-directives.md). + +## Resume lines and branching + +Chat sessions do not remove reply-to-continue. If resume lines are visible, you can reply to any older message to branch the conversation. + +If you prefer a cleaner chat, hide resume lines: + +```toml +[transports.telegram] +show_resume_line = false +``` + +## How it behaves in groups + +In group chats, Takopi stores a session per sender, so different people can work independently in the same chat. + +## Related + +- [Conversation modes](../tutorials/conversation-modes.md) +- [Forum topics](topics.md) +- [Commands & directives](../reference/commands-and-directives.md) diff --git a/docs/how-to/index.md b/docs/how-to/index.md index 0c403c1..85be2cc 100644 --- a/docs/how-to/index.md +++ b/docs/how-to/index.md @@ -11,8 +11,8 @@ If you need exact options and defaults, use **[Reference](../reference/index.md) - [Projects](projects.md) (register repos + run from anywhere) - [Worktrees](worktrees.md) (run work on `@branch` without switching your main checkout) - [Route by chat](route-by-chat.md) (dedicated chats per project) -- [Topics](topics.md) (forum threads bound to repo/branch + auto-resume) -- [Chat sessions](topics.md#chat-sessions) (auto-resume without replying) +- [Topics](topics.md) (forum threads bound to repo/branch + per-topic defaults) +- [Chat sessions](chat-sessions.md) (auto-resume without replying) ## Messaging extras diff --git a/docs/how-to/topics.md b/docs/how-to/topics.md index d39e5a6..6ea4ed7 100644 --- a/docs/how-to/topics.md +++ b/docs/how-to/topics.md @@ -1,6 +1,19 @@ # Topics -Topics bind Telegram forum threads to a specific project/branch context. They can also store resume tokens and a default agent per topic. +Topics bind Telegram **forum threads** to a project/branch context. Each topic keeps its own session and default agent, which is ideal for teams or multi-project work. + +## Why use topics + +- Keep each thread tied to a repo + branch +- Avoid context collisions in busy team chats +- Set a default agent per topic with `/agent set` + +## Requirements checklist + +- The chat is a **forum-enabled supergroup** +- **Topics are enabled** in the group settings +- The bot is an **admin** with **Manage Topics** permission +- If you want topics in project chats, set `projects..chat_id` ## Enable topics @@ -10,44 +23,65 @@ enabled = true scope = "auto" # auto | main | projects | all ``` -Your bot needs **Manage Topics** permission in the group. +### Scope explained -If any `projects..chat_id` are configured, topics are managed in those project chats; otherwise topics are managed in the main chat. +- `auto` (default): uses `projects` if any project chats exist, otherwise `main` +- `main`: topics only in the main `chat_id` +- `projects`: topics only in project chats (`projects..chat_id`) +- `all`: topics available in both the main chat and project chats -## Topic commands +## Create and bind a topic -Run these inside a topic thread: +Run this inside a forum topic thread: -| Command | Description | -|---------|-------------| -| `/topic @branch` | Create a new topic bound to context | -| `/ctx` | Show the current binding | -| `/ctx set @branch` | Update the binding | -| `/ctx clear` | Remove the binding | -| `/new` | Clear stored sessions for this topic | - -In project chats, omit the project: `/topic @branch` or `/ctx set @branch`. - -## Chat sessions - -Chat sessions store one resume token per chat (per sender in groups) so new messages can auto-resume without replying. - -Enable: - -```toml -[transports.telegram] -session_mode = "chat" # stateless | chat +``` +/topic @branch ``` -Reset the stored session with `/new`. +Examples: + +- In the main chat: `/topic backend @feat/api` +- In a project chat: `/topic @feat/api` (project is implied) + +Takopi will bind the topic and rename it to match the context. + +## Inspect or change the binding + +- `/ctx` shows the current binding +- `/ctx set @branch` updates it +- `/ctx clear` removes it + +## Reset a topic session + +Use `/new` inside the topic to clear stored sessions for that thread. + +## Set a default agent per topic + +Use `/agent set` inside the topic: + +``` +/agent set claude +``` ## State files -- Topic state: `telegram_topics_state.json` -- Chat sessions state: `telegram_chat_sessions_state.json` -- Chat defaults (e.g. `/agent`): `telegram_chat_prefs_state.json` +Topic bindings and sessions live in: + +- `telegram_topics_state.json` + +## Common issues and fixes + +- **"topics commands are only available..."** + - Your `scope` does not include this chat. Update `topics.scope`. +- **"chat is not a supergroup" / "topics enabled but chat does not have topics"** + - Convert the group to a supergroup and enable topics. +- **"bot lacks manage topics permission"** + - Promote the bot to admin and grant Manage Topics. ## Related +- [Projects and branches](../tutorials/projects-and-branches.md) +- [Route by chat](route-by-chat.md) +- [Chat sessions](chat-sessions.md) +- [Multi-engine workflows](../tutorials/multi-engine.md) - [Switch engines](switch-engines.md) -- [Commands & directives](../reference/commands-and-directives.md) diff --git a/docs/how-to/troubleshooting.md b/docs/how-to/troubleshooting.md index 6dc842d..e2ec3c5 100644 --- a/docs/how-to/troubleshooting.md +++ b/docs/how-to/troubleshooting.md @@ -8,3 +8,10 @@ takopi --debug Then check `debug.log` for errors and include it when reporting issues. +You can also run a preflight check: + +```sh +takopi doctor +``` + +This validates your Telegram token, chat id, topics setup, file transfer permissions, and voice transcription configuration. diff --git a/docs/index.md b/docs/index.md index 8ae7e01..ecb668e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,6 +4,29 @@ Takopi lets you run an engine CLI in a local repo while controlling it from Telegram: send a task, stream updates, and continue safely (reply-to-continue, topics, or sessions). +## Workflows + +
+- :lucide-message-circle:{ .lg } **Solo chat workflow** + + --- + + For a single developer in a private chat. + + - [Conversation modes](tutorials/conversation-modes.md) + - [First run](tutorials/first-run.md) + +- :lucide-users:{ .lg } **Team topics workflow** + + --- + + For teams using forum topics and per-topic defaults. + + - [Topics](how-to/topics.md) + - [Projects and branches](tutorials/projects-and-branches.md) + +
+ ## Choose your path
@@ -14,6 +37,7 @@ Takopi lets you run an engine CLI in a local repo while controlling it from Tele Start with [Tutorials](tutorials/index.md). - [Install & onboard](tutorials/install-and-onboard.md) + - [Conversation modes](tutorials/conversation-modes.md) - [First run](tutorials/first-run.md) - :lucide-compass:{ .lg } **I know what I want to do** diff --git a/docs/reference/commands-and-directives.md b/docs/reference/commands-and-directives.md index ad6befb..baedee7 100644 --- a/docs/reference/commands-and-directives.md +++ b/docs/reference/commands-and-directives.md @@ -57,6 +57,7 @@ Takopi’s CLI is an auto-router by default; engine subcommands override the def | `takopi init ` | Register the current repo as a project. | | `takopi chat-id` | Capture the current chat id. | | `takopi chat-id --project ` | Save the captured chat id to a project. | +| `takopi doctor` | Validate Telegram connectivity and related config. | | `takopi plugins` | List discovered plugins without loading them. | | `takopi plugins --load` | Load each plugin to validate types and surface import errors. | @@ -68,4 +69,3 @@ Takopi’s CLI is an auto-router by default; engine subcommands override the def | `--transport ` | Override the configured transport backend id. | | `--debug` | Write debug logs to `debug.log`. | | `--final-notify/--no-final-notify` | Send the final response as a new message vs an edit. | - diff --git a/docs/reference/transports/telegram.md b/docs/reference/transports/telegram.md index c2517d9..e4bcbb4 100644 --- a/docs/reference/transports/telegram.md +++ b/docs/reference/transports/telegram.md @@ -54,8 +54,9 @@ session_mode = "chat" # or "stateless" Behavior: -- Stores one resume token per chat (per sender in group chats). +- Stores one resume token per engine per chat (per sender in group chats). - Auto-resumes when no explicit resume token is present. +- Reply resume lines always take precedence and update the stored session for that engine. - Reset with `/new`. State is stored in `telegram_chat_sessions_state.json` alongside the config file. diff --git a/docs/tutorials/conversation-modes.md b/docs/tutorials/conversation-modes.md new file mode 100644 index 0000000..cd7ce9c --- /dev/null +++ b/docs/tutorials/conversation-modes.md @@ -0,0 +1,88 @@ +# Conversation modes + +Takopi can handle follow-up messages in two ways: **chat mode** (auto-resume) or **stateless** (reply-to-continue). Pick the one that matches how you want Telegram to feel. + +## Quick pick + +- **Choose chat mode** if you want a normal chat flow where new messages continue the same thread. +- **Choose stateless** if you want every message to start clean unless you explicitly reply. + +## Chat mode (auto-resume) + +**What it feels like:** a normal chat assistant. + +!!! user "You" + explain what this repo does + +!!! takopi "Takopi" + done · codex · 8s + ... + +!!! user "You" + now add tests + +Takopi treats the second message as a continuation. If you want a clean slate, use: + +!!! user "You" + /new + +Tip: set a default agent for this chat with `/agent set claude`. + +## Stateless (reply-to-continue) + +**What it feels like:** every message is independent until you reply. + +!!! user "You" + explain what this repo does + +!!! takopi "Takopi" + done · codex · 8s + ... + codex resume abc123 + +To continue the same session, **reply** to a message with a resume line: + +!!! takopi "Takopi" + done · codex · 8s + + !!! user "You" + now add tests + +## Where to set it + +Onboarding will ask you, or you can set it in config: + +```toml +[transports.telegram] +session_mode = "chat" # or "stateless" +show_resume_line = false # optional, see below +``` + +## Resume lines in chat mode + +If you enable chat mode (or topics), Takopi can auto-resume, so you can hide resume lines for a cleaner chat. +Resume lines are still shown when no project context is set, so replies can branch there. + +If you prefer always-visible resume lines, set: + +```toml +[transports.telegram] +show_resume_line = true +``` + +## Reply-to-continue still works + +Even in chat mode, replying to a message with a resume line takes precedence and branches from that point. + +## Related + +- [Routing and sessions](../explanation/routing-and-sessions.md) +- [Chat sessions](../how-to/chat-sessions.md) +- [Forum topics](../how-to/topics.md) +- [Commands & directives](../reference/commands-and-directives.md) + +## Next + +Now that you know which mode you want, move on to your first run: + +[First run →](first-run.md) diff --git a/docs/tutorials/first-run.md b/docs/tutorials/first-run.md index f5f4ce6..07c5adf 100644 --- a/docs/tutorials/first-run.md +++ b/docs/tutorials/first-run.md @@ -21,6 +21,9 @@ Takopi keeps running in your terminal. In Telegram, your bot will post a startup default: codex
agents: codex, claude
projects: none
+ mode: chat
+ topics: disabled
+ resume lines: hidden
working in: /Users/you/dev/your-project The engines/projects list reflects your setup. This tells you: @@ -76,7 +79,16 @@ That last line is the **resume line**—it's how Takopi knows which conversation ## 5. Continue the conversation -To follow up, **reply** to the bot's message: +How you continue depends on your mode. + +**If you're in chat mode:** just send another message (no reply needed). + +!!! user "You" + now add tests for the API + +Use `/new` any time you want a fresh thread. + +**If you're in stateless mode:** **reply** to a message that has a resume line. !!! takopi "Takopi" done · codex · 11s · step 5 @@ -84,25 +96,14 @@ To follow up, **reply** to the bot's message: !!! user "You" what command line arguments does it support? -Takopi extracts the resume token from the message you replied to and continues the same agent session. The agent remembers everything from before. +Takopi extracts the resume token from the message you replied to and continues the same agent session. -!!! takopi "Takopi" - done · codex · 47s · step 11 +!!! tip "Reply-to-continue still works in chat mode" + If resume lines are visible, replying to any older message branches the conversation from that point. + Use `show_resume_line = true` if you want this behavior all the time. - CLI Args - - - Global/auto-router (when you run just takopi): --version, --final-notify/--no-final-notify, --onboard/--no-onboard, --transport , --debug/--no-debug. This is the same option set used by engine subcommands. - - init [alias]: optional positional alias, plus --default to set the project as default_project. - - chat-id: --token , --project to store a captured chat id into the project config. - - plugins: --load/--no-load to validate plugin imports. - - Engine subcommands: one per engine id; built-ins are codex, claude, opencode, pi, plus any plugin engines. Each accepts --final-notify, --onboard, --transport, --debug. - - If you want, I can also summarize takopi --help output verbatim for your local build. - - codex resume 019bb89b-1b0b-7e90-96e4-c33181b49714 - -!!! tip "You can reply to any message with a resume line" - The resume line doesn't have to be in the most recent message. Reply to any earlier message to "branch" the conversation from that point. +!!! tip "Reset with /new" + `/new` clears stored sessions for the current chat or topic. ## 6. Cancel a run @@ -139,6 +140,9 @@ This uses Claude Code for just this message. The resume line will show `claude - Available prefixes depend on what you have installed: `/codex`, `/claude`, `/opencode`, `/pi`. +!!! tip "Set a default engine" + Use `/agent set claude` to make this chat (or topic) use Claude by default. Run `/agent` to see what's set. + ## What just happened Key points: @@ -147,7 +151,7 @@ Key points: - The agent streams JSONL events (tool calls, progress, answer) - Takopi renders these as an editable progress message - When done, the progress message is replaced with the final answer -- The resume line lets you continue the conversation +- Chat mode auto-resumes; resume lines let you reply to branch ## The core loop @@ -156,7 +160,8 @@ You now know the three fundamental interactions: | Action | How | |--------|-----| | **Start** | Send a message to your bot | -| **Continue** | Reply to any message with a resume line | +| **Continue** | Chat mode: send another message. Stateless: reply to a resume line. | +| **Reset** | `/new` | | **Cancel** | Tap **cancel** on a progress message | Everything else in Takopi builds on this loop. @@ -177,7 +182,7 @@ Check that Takopi is still running in your terminal. You should also see a start **Resume doesn't work (starts a new conversation)** -Make sure you're **replying** to a message, not sending a new one. The reply must be to a message that contains a resume line. +Make sure you're **replying** to a message that contains a resume line. If you hid resume lines (`show_resume_line = false`), turn them on or use chat mode to continue by sending another message. ## Next diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md index f16e856..a876b7c 100644 --- a/docs/tutorials/index.md +++ b/docs/tutorials/index.md @@ -31,7 +31,15 @@ Set up Takopi, create a Telegram bot, and generate your config. [Start here →](install-and-onboard.md) -### 2. First run +### 2. Conversation modes + +Decide how Takopi should handle follow-up messages: chat mode or reply-to-continue. + +**Time:** ~5 minutes + +[Continue →](conversation-modes.md) + +### 3. First run Send your first task, watch it stream, and learn the core loop: run → continue → cancel. @@ -39,7 +47,7 @@ Send your first task, watch it stream, and learn the core loop: run → continue [Continue →](first-run.md) -### 3. Projects and branches +### 4. Projects and branches Register a repo as a project so you can target it from anywhere. Run tasks on feature branches without leaving your main worktree. @@ -47,7 +55,7 @@ Register a repo as a project so you can target it from anywhere. Run tasks on fe [Continue →](projects-and-branches.md) -### 4. Multi-engine workflows +### 5. Multi-engine workflows Use different agents for different tasks. Set defaults per chat or topic. @@ -62,6 +70,7 @@ By the end of these tutorials, you'll have: ``` ~/.takopi/takopi.toml ├── bot_token + chat_id configured +├── session_mode chosen ├── default_engine set └── projects.your-repo registered ``` @@ -69,7 +78,7 @@ By the end of these tutorials, you'll have: And you'll know how to: - Send tasks from Telegram and watch progress stream -- Continue conversations by replying +- Continue conversations by replying or sending a new message (chat mode) - Cancel runs mid-flight - Target specific repos and branches - Switch between agents on the fly diff --git a/docs/tutorials/install-and-onboard.md b/docs/tutorials/install-and-onboard.md index 6cc4dd9..2b86809 100644 --- a/docs/tutorials/install-and-onboard.md +++ b/docs/tutorials/install-and-onboard.md @@ -2,7 +2,7 @@ This tutorial walks you through installing Takopi, creating a Telegram bot, and generating your config file. -**What you'll have at the end:** A working `~/.takopi/takopi.toml` with your bot token, chat ID, and default engine. +**What you'll have at the end:** A working `~/.takopi/takopi.toml` with your bot token, chat ID, conversation mode, and default engine. ## 1. Install Python 3.14 and uv @@ -150,12 +150,51 @@ Open Telegram and send `/start` (or any message) to your bot. Takopi will captur !!! tip "Using Takopi in a group" You can also send a message in a group where the bot is a member. Takopi will capture that group's chat ID instead. This is useful if you want multiple people to share the same bot. -## 8. Choose your default engine +## 8. Choose a conversation mode + +Takopi asks how you want follow-up messages to behave: + +``` +? choose conversation mode: + ❯ chat mode (auto-resume; use /new to start fresh) + stateless (reply-to-continue) +``` + +**Chat mode** keeps one active thread per chat (per sender in groups). New messages continue automatically. +**Stateless** makes every message start fresh unless you reply to a resume line. + +## 9. (Optional) Enable Topics + +If you plan to use Telegram **forum topics**, Takopi will ask: + +``` +? will you use topics? + ❯ no, keep topics off + yes, in the main chat (this chat_id) + yes, in project chats (projects..chat_id) + yes, in both main and project chats +``` + +Topics require a **forum-enabled supergroup** and the bot must have **Manage Topics** permission. + +## 10. Choose resume line visibility + +If you picked chat mode or enabled topics, Takopi asks whether to show resume lines: + +``` +? show resume lines in messages? + ❯ hide resume lines (cleaner chat; use /new to reset) + show resume lines (best for reply-to-continue) +``` + +Resume lines let you reply to older messages to branch a conversation. + +## 11. Choose your default engine Takopi scans your PATH for installed agent CLIs: ``` -step 2: agent cli tools +step 4: agent cli tools agent status install command ─────────────────────────────────────────── @@ -171,12 +210,12 @@ step 2: agent cli tools Pick whichever you prefer. You can always switch engines per-message later. -## 9. Save your config +## 12. Save your config Takopi shows you a preview of what it will save: ``` -step 3: save configuration +step 5: save configuration ~/.takopi/takopi.toml @@ -186,6 +225,12 @@ step 3: save configuration [transports.telegram] bot_token = "123456789:ABC..." chat_id = 123456789 + session_mode = "chat" + show_resume_line = false + + [transports.telegram.topics] + enabled = false + scope = "auto" ? save this config to ~/.takopi/takopi.toml? (yes/no) ``` @@ -194,6 +239,7 @@ Press **Enter** to save. You'll see: ``` config saved to ~/.takopi/takopi.toml + sent confirmation message setup complete. starting takopi... ``` @@ -211,6 +257,12 @@ transport = "telegram" # how Takopi talks to you [transports.telegram] bot_token = "..." # your bot's API key chat_id = 123456789 # where to send messages +session_mode = "chat" +show_resume_line = false + +[transports.telegram.topics] +enabled = false +scope = "auto" ``` This config file controls all of Takopi's behavior. You'll edit it directly for advanced features. @@ -245,6 +297,6 @@ You can only run one Takopi instance per bot token. Find and stop the other proc ## Next -Now that Takopi is configured, let's send your first task. +Next, learn how conversation modes affect follow-ups. -[First run →](first-run.md) +[Conversation modes →](conversation-modes.md) diff --git a/docs/tutorials/multi-engine.md b/docs/tutorials/multi-engine.md index 4a6ceb6..5f1acf2 100644 --- a/docs/tutorials/multi-engine.md +++ b/docs/tutorials/multi-engine.md @@ -130,6 +130,16 @@ When Takopi picks an engine, it checks (highest to lowest): This means: resume lines always win, then explicit directives, then the most specific default applies. +!!! note + With `session_mode = "chat"`, stored sessions are per engine. Replying to a resume line for another engine runs that engine and updates its stored session without overwriting other engines. + +!!! example + Chat sessions with two engines (assume default engine is `codex`): + + 1. You send: `fix the failing tests` -> bot replies with `codex resume A` (stores Codex session A). + 2. You reply to an older Claude message containing `claude --resume B` -> runs Claude and stores Claude session B. + 3. You send a new message (not a reply) -> auto-resumes Codex session A (default engine), Claude session B remains stored for future replies or defaults. + ## 7. Practical patterns **Pattern: Quick questions vs. deep work** diff --git a/scripts/onboarding_preview.py b/scripts/onboarding_preview.py new file mode 100644 index 0000000..f6b3cba --- /dev/null +++ b/scripts/onboarding_preview.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable, Iterator + +import anyio +from rich.console import Console +from rich.panel import Panel +from rich.text import Text + +from takopi.config import ConfigError +from takopi.telegram import onboarding as ob +from takopi.telegram.api_models import User + + +def section(console: Console, title: str) -> None: + console.print("") + console.print(f"=== {title} ===", markup=False) + + +def render_confirm(console: Console, prompt: str) -> None: + console.print(f"? {prompt} (yes/no)", markup=False) + + +def render_password(console: Console, prompt: str) -> None: + console.print(f"? {prompt} {'*' * 28}", markup=False) + + +def render_select(console: Console, prompt: str, choices: list[str]) -> None: + console.print(f"? {prompt} (use arrow keys)", markup=False) + for index, choice in enumerate(choices): + marker = ">" if index == 0 else " " + console.print(f"{marker} {choice}", markup=False) + + +def next_value(values: Iterator[Any], label: str) -> Any: + try: + return next(values) + except StopIteration as exc: + raise RuntimeError(f"scripted ui ran out of {label} responses") from exc + + +class ScriptedUI: + def __init__( + self, + console: Console, + *, + confirms: Iterable[bool | None], + selects: Iterable[Any], + passwords: Iterable[str | None], + ) -> None: + self._console = console + self._confirms = iter(confirms) + self._selects = iter(selects) + self._passwords = iter(passwords) + + @property + def console(self) -> Console: + return self._console + + def panel( + self, + title: str | None, + body: str, + *, + border_style: str = "yellow", + ) -> None: + panel = Panel( + body, + title=title, + border_style=border_style, + padding=(1, 2), + expand=False, + ) + self._console.print(panel) + + def step(self, title: str, *, number: int) -> None: + self._console.print("") + self._console.print(Text(f"step {number}: {title}", style="bold yellow")) + self._console.print("") + + def print(self, text: object = "", *, markup: bool | None = None) -> None: + if markup is None: + self._console.print(text) + return + self._console.print(text, markup=markup) + + async def confirm(self, prompt: str, default: bool = True) -> bool | None: + render_confirm(self._console, prompt) + return next_value(self._confirms, "confirm") + + async def select(self, prompt: str, choices: list[tuple[str, Any]]) -> Any | None: + rendered = [label for label, _value in choices] + render_select(self._console, prompt, rendered) + return next_value(self._selects, "select") + + async def password(self, prompt: str) -> str | None: + render_password(self._console, prompt) + return next_value(self._passwords, "password") + + +@dataclass +class ScriptedServices: + bot: User + chat: ob.ChatInfo + engines: list[tuple[str, bool, str | None]] + topics_issue: ConfigError | None = None + existing_config: dict[str, Any] | None = None + written_config: dict[str, Any] | None = None + + async def get_bot_info(self, _token: str) -> User | None: + return self.bot + + async def wait_for_chat(self, _token: str) -> ob.ChatInfo: + return self.chat + + async def validate_topics( + self, _token: str, _chat_id: int, _scope: ob.TopicScope + ) -> ConfigError | None: + return self.topics_issue + + def list_engines(self) -> list[tuple[str, bool, str | None]]: + return self.engines + + def read_config(self, _path) -> dict[str, Any]: + return dict(self.existing_config or {}) + + def write_config(self, _path, data: dict[str, Any]) -> None: + self.written_config = data + + +async def run_flow(title: str, ui: ScriptedUI, svc: ScriptedServices) -> None: + section(ui.console, title) + state = ob.OnboardingState(config_path=ob.HOME_CONFIG_PATH, force=False) + await ob.run_onboarding(ui, svc, state) + + +def main() -> None: + console = Console() + + bot = User(id=1, username="bunny_agent_bot", first_name="Bunny") + group_chat = ob.ChatInfo( + chat_id=-1001234567890, + username=None, + title="takopi devs", + first_name=None, + last_name=None, + chat_type="supergroup", + ) + private_chat = ob.ChatInfo( + chat_id=462722, + username="banteg", + title=None, + first_name="Banteg", + last_name=None, + chat_type="private", + ) + engines_installed = [ + ("codex", True, "brew install codex"), + ("claude", True, "brew install claude"), + ("opencode", False, "brew install opencode"), + ] + engines_missing = [ + ("codex", False, "brew install codex"), + ("claude", False, "brew install claude"), + ("opencode", False, "brew install opencode"), + ] + + anyio.run( + run_flow, + "assistant mode (private chat)", + ScriptedUI( + console, + confirms=[True, True], + selects=["assistant", "codex"], + passwords=["123456789:ABCdef"], + ), + ScriptedServices(bot=bot, chat=private_chat, engines=engines_installed), + ) + + anyio.run( + run_flow, + "handoff mode (token instructions)", + ScriptedUI( + console, + confirms=[False, True], + selects=["handoff", "codex"], + passwords=["123456789:ABCdef"], + ), + ScriptedServices(bot=bot, chat=private_chat, engines=engines_installed), + ) + + anyio.run( + run_flow, + "workspace mode (topics)", + ScriptedUI( + console, + confirms=[True, True], + selects=["workspace", "codex"], + passwords=["123456789:ABCdef"], + ), + ScriptedServices(bot=bot, chat=group_chat, engines=engines_installed), + ) + + anyio.run( + run_flow, + "topics validation warning", + ScriptedUI( + console, + confirms=[True, True], + selects=["workspace", "assistant", "codex"], + passwords=["123456789:ABCdef"], + ), + ScriptedServices( + bot=bot, + chat=group_chat, + engines=engines_installed, + topics_issue=ConfigError("bot is missing admin rights"), + ), + ) + + anyio.run( + run_flow, + "no engines installed", + ScriptedUI( + console, + confirms=[True, False], + selects=["assistant"], + passwords=["123456789:ABCdef"], + ), + ScriptedServices(bot=bot, chat=private_chat, engines=engines_missing), + ) + +if __name__ == "__main__": + main() diff --git a/src/takopi/cli.py b/src/takopi/cli.py index 6b6d8a0..6bec64d 100644 --- a/src/takopi/cli.py +++ b/src/takopi/cli.py @@ -2,10 +2,14 @@ from __future__ import annotations import os import sys +from dataclasses import dataclass from collections.abc import Callable from importlib.metadata import EntryPoint from pathlib import Path +from typing import Literal +import anyio +from functools import partial import typer from . import __version__ @@ -14,12 +18,13 @@ from .config_migrations import migrate_config from .commands import get_command from .backends import EngineBackend from .engines import get_backend, list_backend_ids -from .ids import RESERVED_COMMAND_IDS, RESERVED_ENGINE_IDS +from .ids import RESERVED_CHAT_COMMANDS, RESERVED_COMMAND_IDS, RESERVED_ENGINE_IDS from .lockfile import LockError, LockHandle, acquire_lock, token_fingerprint from .logging import get_logger, setup_logging from .runtime_loader import build_runtime_spec, resolve_plugins_allowlist from .settings import ( TakopiSettings, + TelegramTopicsSettings, load_settings, load_settings_if_exists, validate_settings_data, @@ -37,6 +42,8 @@ from .plugins import ( from .transports import SetupResult, get_transport from .utils.git import resolve_default_base, resolve_main_worktree_root from .telegram import onboarding +from .telegram.client import TelegramClient +from .telegram.topics import _validate_topics_setup_for logger = get_logger(__name__) @@ -51,6 +58,21 @@ def _load_settings_optional() -> tuple[TakopiSettings | None, Path | None]: return loaded +DoctorStatus = Literal["ok", "warning", "error"] + + +@dataclass(frozen=True, slots=True) +class DoctorCheck: + label: str + status: DoctorStatus + detail: str | None = None + + def render(self) -> str: + if self.detail: + return f"- {self.label}: {self.status} ({self.detail})" + return f"- {self.label}: {self.status}" + + def _print_version_and_exit() -> None: typer.echo(__version__) raise typer.Exit() @@ -156,6 +178,72 @@ def _fail_missing_config(path: Path) -> None: typer.echo(f"error: missing takopi config at {display}", err=True) +def _doctor_file_checks(settings: TakopiSettings) -> list[DoctorCheck]: + files = settings.transports.telegram.files + if not files.enabled: + return [DoctorCheck("file transfer", "ok", "disabled")] + if files.allowed_user_ids: + count = len(files.allowed_user_ids) + detail = f"restricted to {count} user id(s)" + return [DoctorCheck("file transfer", "ok", detail)] + return [DoctorCheck("file transfer", "warning", "enabled for all users")] + + +def _doctor_voice_checks(settings: TakopiSettings) -> list[DoctorCheck]: + if not settings.transports.telegram.voice_transcription: + return [DoctorCheck("voice transcription", "ok", "disabled")] + if os.environ.get("OPENAI_API_KEY"): + return [DoctorCheck("voice transcription", "ok", "OPENAI_API_KEY set")] + return [DoctorCheck("voice transcription", "error", "OPENAI_API_KEY not set")] + + +async def _doctor_telegram_checks( + token: str, + chat_id: int, + topics: TelegramTopicsSettings, + project_chat_ids: tuple[int, ...], +) -> list[DoctorCheck]: + checks: list[DoctorCheck] = [] + bot = TelegramClient(token) + try: + me = await bot.get_me() + if me is None: + checks.append( + DoctorCheck("telegram token", "error", "failed to fetch bot info") + ) + checks.append(DoctorCheck("chat_id", "error", "skipped (token invalid)")) + if topics.enabled: + checks.append(DoctorCheck("topics", "error", "skipped (token invalid)")) + else: + checks.append(DoctorCheck("topics", "ok", "disabled")) + return checks + bot_label = f"@{me.username}" if me.username else f"id={me.id}" + checks.append(DoctorCheck("telegram token", "ok", bot_label)) + chat = await bot.get_chat(chat_id) + if chat is None: + checks.append(DoctorCheck("chat_id", "error", f"unreachable ({chat_id})")) + else: + checks.append(DoctorCheck("chat_id", "ok", f"{chat.type} ({chat_id})")) + if topics.enabled: + try: + await _validate_topics_setup_for( + bot=bot, + topics=topics, + chat_id=chat_id, + project_chat_ids=project_chat_ids, + ) + checks.append(DoctorCheck("topics", "ok", f"scope={topics.scope}")) + except ConfigError as exc: + checks.append(DoctorCheck("topics", "error", str(exc))) + else: + checks.append(DoctorCheck("topics", "ok", "disabled")) + except Exception as exc: # noqa: BLE001 + checks.append(DoctorCheck("telegram", "error", str(exc))) + finally: + await bot.close() + return checks + + def _run_auto_router( *, default_engine_override: str | None, @@ -185,7 +273,7 @@ def _run_auto_router( if not _should_run_interactive(): typer.echo("error: --onboard requires a TTY", err=True) raise typer.Exit(code=1) - if not transport_backend.interactive_setup(force=True): + if not anyio.run(partial(transport_backend.interactive_setup, force=True)): raise typer.Exit(code=1) ( settings_hint, @@ -207,7 +295,9 @@ def _run_auto_router( f"{transport_backend.id}, run onboarding now?", default=False, ) - if run_onboard and transport_backend.interactive_setup(force=True): + if run_onboard and anyio.run( + partial(transport_backend.interactive_setup, force=True) + ): ( settings_hint, config_hint, @@ -219,7 +309,7 @@ def _run_auto_router( engine_backend, transport_override=transport_override, ) - elif transport_backend.interactive_setup(force=False): + elif anyio.run(partial(transport_backend.interactive_setup, force=False)): ( settings_hint, config_hint, @@ -246,7 +336,7 @@ def _run_auto_router( settings=settings, config_path=config_path, default_engine_override=default_engine_override, - reserved=("cancel",), + reserved=RESERVED_CHAT_COMMANDS, ) if settings.transport == "telegram": transport_config = settings.transports.telegram @@ -335,7 +425,7 @@ def init( projects_cfg = settings.to_projects_config( config_path=config_path, engine_ids=engine_ids, - reserved=("cancel",), + reserved=RESERVED_CHAT_COMMANDS, ) alias_key = alias.lower() @@ -343,7 +433,7 @@ def init( raise ConfigError( f"Invalid project alias {alias!r}; aliases must not match engine ids." ) - if alias_key == "cancel": + if alias_key in RESERVED_CHAT_COMMANDS: raise ConfigError( f"Invalid project alias {alias!r}; aliases must not match reserved commands." ) @@ -399,7 +489,7 @@ def chat_id( if settings is not None: tg = settings.transports.telegram token = tg.bot_token or None - chat = onboarding.capture_chat_id(token=token) + chat = anyio.run(partial(onboarding.capture_chat_id, token=token)) if chat is None: raise typer.Exit(code=1) if project: @@ -438,6 +528,63 @@ def chat_id( typer.echo(f"chat_id = {chat.chat_id}") +def onboarding_paths() -> None: + """Print all possible onboarding paths.""" + setup_logging(debug=False, cache_logger_on_first_use=False) + onboarding.debug_onboarding_paths() + + +def doctor() -> None: + """Run configuration checks for the active transport.""" + setup_logging(debug=False, cache_logger_on_first_use=False) + try: + settings, config_path = load_settings() + except ConfigError as exc: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(code=1) from exc + + if settings.transport != "telegram": + typer.echo( + "error: takopi doctor currently supports the telegram transport only.", + err=True, + ) + raise typer.Exit(code=1) + + allowlist = resolve_plugins_allowlist(settings) + engine_ids = list_backend_ids(allowlist=allowlist) + try: + projects_cfg = settings.to_projects_config( + config_path=config_path, + engine_ids=engine_ids, + reserved=RESERVED_CHAT_COMMANDS, + ) + except ConfigError as exc: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(code=1) from exc + + tg = settings.transports.telegram + project_chat_ids = projects_cfg.project_chat_ids() + telegram_checks = anyio.run( + _doctor_telegram_checks, + tg.bot_token, + tg.chat_id, + tg.topics, + project_chat_ids, + ) + if telegram_checks is None: + telegram_checks = [] + checks = [ + *telegram_checks, + *_doctor_file_checks(settings), + *_doctor_voice_checks(settings), + ] + typer.echo("takopi doctor") + for check in checks: + typer.echo(check.render()) + if any(check.status == "error" for check in checks): + raise typer.Exit(code=1) + + def _print_entrypoints( label: str, entrypoints: list[EntryPoint], *, allowlist: set[str] | None ) -> None: @@ -629,6 +776,8 @@ def create_app() -> typer.Typer: ) app.command(name="init")(init) app.command(name="chat-id")(chat_id) + app.command(name="doctor")(doctor) + app.command(name="onboarding-paths")(onboarding_paths) app.command(name="plugins")(plugins_cmd) app.callback()(app_main) for engine_id in _engine_ids_for_cli(): diff --git a/src/takopi/config_watch.py b/src/takopi/config_watch.py index aa69847..6151dbe 100644 --- a/src/takopi/config_watch.py +++ b/src/takopi/config_watch.py @@ -7,6 +7,7 @@ from collections.abc import Awaitable, Callable, Iterable from watchfiles import awatch from .config import ConfigError +from .ids import RESERVED_CHAT_COMMANDS from .logging import get_logger from .runtime_loader import RuntimeSpec, build_runtime_spec from .settings import TakopiSettings, load_settings @@ -71,7 +72,7 @@ async def watch_config( config_path: Path, runtime: TransportRuntime, default_engine_override: str | None = None, - reserved: Iterable[str] = ("cancel",), + reserved: Iterable[str] = RESERVED_CHAT_COMMANDS, on_reload: Callable[[ConfigReload], Awaitable[None]] | None = None, ) -> None: reserved_tuple = tuple(reserved) diff --git a/src/takopi/ids.py b/src/takopi/ids.py index eab937b..db6dcdc 100644 --- a/src/takopi/ids.py +++ b/src/takopi/ids.py @@ -5,8 +5,8 @@ import re ID_PATTERN = r"^[a-z0-9_]{1,32}$" _ID_RE = re.compile(ID_PATTERN) -RESERVED_CLI_COMMANDS = frozenset({"init", "plugins"}) -RESERVED_CHAT_COMMANDS = frozenset({"cancel", "file"}) +RESERVED_CLI_COMMANDS = frozenset({"init", "plugins", "doctor"}) +RESERVED_CHAT_COMMANDS = frozenset({"cancel", "file", "new", "agent", "topic", "ctx"}) RESERVED_ENGINE_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS RESERVED_COMMAND_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS diff --git a/src/takopi/runtime_loader.py b/src/takopi/runtime_loader.py index 314ac4a..6ecfc6b 100644 --- a/src/takopi/runtime_loader.py +++ b/src/takopi/runtime_loader.py @@ -9,6 +9,7 @@ from collections.abc import Iterable, Mapping from .backends import EngineBackend from .config import ConfigError, ProjectsConfig from .engines import get_backend, list_backend_ids +from .ids import RESERVED_CHAT_COMMANDS from .logging import get_logger from .router import AutoRouter, EngineStatus, RunnerEntry from .settings import TakopiSettings @@ -171,7 +172,7 @@ def build_runtime_spec( settings: TakopiSettings, config_path: Path, default_engine_override: str | None = None, - reserved: Iterable[str] = ("cancel",), + reserved: Iterable[str] = RESERVED_CHAT_COMMANDS, ) -> RuntimeSpec: allowlist = resolve_plugins_allowlist(settings) engine_ids = list_backend_ids(allowlist=allowlist) diff --git a/src/takopi/telegram/backend.py b/src/takopi/telegram/backend.py index 25b1f3f..9956c6f 100644 --- a/src/takopi/telegram/backend.py +++ b/src/takopi/telegram/backend.py @@ -2,13 +2,14 @@ from __future__ import annotations import os from pathlib import Path +from typing import Literal import anyio from ..backends import EngineBackend from ..logging import get_logger from ..runner_bridge import ExecBridgeConfig -from ..settings import TelegramTransportSettings +from ..settings import TelegramTopicsSettings, TelegramTransportSettings from ..transport_runtime import TransportRuntime from ..transports import SetupResult, TransportBackend from .bridge import ( @@ -19,6 +20,7 @@ from .bridge import ( ) from .client import TelegramClient from .onboarding import check_setup, interactive_setup +from .topics import _resolve_topics_scope_raw logger = get_logger(__name__) @@ -33,6 +35,10 @@ def _build_startup_message( runtime: TransportRuntime, *, startup_pwd: str, + chat_id: int, + session_mode: Literal["stateless", "chat"], + show_resume_line: bool, + topics: TelegramTopicsSettings, ) -> str: available_engines = list(runtime.available_engine_ids()) missing_engines = list(runtime.missing_engine_ids()) @@ -52,11 +58,24 @@ def _build_startup_message( engine_list = f"{engine_list} ({'; '.join(notes)})" project_aliases = sorted(set(runtime.project_aliases()), key=str.lower) project_list = ", ".join(project_aliases) if project_aliases else "none" + resume_label = "shown" if show_resume_line else "hidden" + topics_label = "disabled" + if topics.enabled: + resolved_scope, _ = _resolve_topics_scope_raw( + topics.scope, chat_id, runtime.project_chat_ids() + ) + scope_label = ( + f"auto ({resolved_scope})" if topics.scope == "auto" else resolved_scope + ) + topics_label = f"enabled (scope={scope_label})" return ( f"\N{OCTOPUS} **takopi is ready**\n\n" f"default: `{runtime.default_engine}` \n" f"agents: `{engine_list}` \n" f"projects: `{project_list}` \n" + f"mode: `{session_mode}` \n" + f"topics: `{topics_label}` \n" + f"resume lines: `{resume_label}` \n" f"working in: `{startup_pwd}`" ) @@ -73,8 +92,8 @@ class TelegramBackend(TransportBackend): ) -> SetupResult: return check_setup(engine_backend, transport_override=transport_override) - def interactive_setup(self, *, force: bool) -> bool: - return interactive_setup(force=force) + async def interactive_setup(self, *, force: bool) -> bool: + return await interactive_setup(force=force) def lock_token(self, *, transport_config: object, _config_path: Path) -> str | None: settings = _expect_transport_settings(transport_config) @@ -95,6 +114,10 @@ class TelegramBackend(TransportBackend): startup_msg = _build_startup_message( runtime, startup_pwd=os.getcwd(), + chat_id=chat_id, + session_mode=settings.session_mode, + show_resume_line=settings.show_resume_line, + topics=settings.topics, ) bot = TelegramClient(token) transport = TelegramTransport(bot) diff --git a/src/takopi/telegram/bridge.py b/src/takopi/telegram/bridge.py index 8ff89f7..bfe234b 100644 --- a/src/takopi/telegram/bridge.py +++ b/src/takopi/telegram/bridge.py @@ -311,10 +311,19 @@ async def send_plain( ) -def build_bot_commands(runtime: TransportRuntime, *, include_file: bool = True): +def build_bot_commands( + runtime: TransportRuntime, + *, + include_file: bool = True, + include_topics: bool = False, +): from .commands import build_bot_commands as _build - return _build(runtime, include_file=include_file) + return _build( + runtime, + include_file=include_file, + include_topics=include_topics, + ) def is_cancel_command(text: str) -> bool: diff --git a/src/takopi/telegram/commands/menu.py b/src/takopi/telegram/commands/menu.py index 1cbfbaa..4deab6d 100644 --- a/src/takopi/telegram/commands/menu.py +++ b/src/takopi/telegram/commands/menu.py @@ -18,7 +18,10 @@ _MAX_BOT_COMMANDS = 100 def build_bot_commands( - runtime: TransportRuntime, *, include_file: bool = True + runtime: TransportRuntime, + *, + include_file: bool = True, + include_topics: bool = False, ) -> list[dict[str, str]]: commands: list[dict[str, str]] = [] seen: set[str] = set() @@ -67,6 +70,23 @@ def build_bot_commands( description = backend.description or f"command: {cmd}" commands.append({"command": cmd, "description": description}) seen.add(cmd) + for cmd, description in [ + ("new", "start a new thread"), + ("agent", "set default agent"), + ]: + if cmd in seen: + continue + commands.append({"command": cmd, "description": description}) + seen.add(cmd) + if include_topics: + for cmd, description in [ + ("topic", "create or bind a topic"), + ("ctx", "show or update topic context"), + ]: + if cmd in seen: + continue + commands.append({"command": cmd, "description": description}) + seen.add(cmd) if include_file and "file" not in seen: commands.append({"command": "file", "description": "upload or fetch files"}) seen.add("file") @@ -93,7 +113,11 @@ def _reserved_commands(runtime: TransportRuntime) -> set[str]: async def _set_command_menu(cfg: TelegramBridgeConfig) -> None: - commands = build_bot_commands(cfg.runtime, include_file=cfg.files.enabled) + commands = build_bot_commands( + cfg.runtime, + include_file=cfg.files.enabled, + include_topics=cfg.topics.enabled, + ) if not commands: return try: diff --git a/src/takopi/telegram/onboarding.py b/src/takopi/telegram/onboarding.py index 9525b7a..1f613e9 100644 --- a/src/takopi/telegram/onboarding.py +++ b/src/takopi/telegram/onboarding.py @@ -2,9 +2,10 @@ from __future__ import annotations import shutil from contextlib import contextmanager +from collections.abc import Awaitable, Callable from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import Any, Literal, Protocol, cast import anyio import questionary @@ -16,35 +17,47 @@ from questionary.constants import DEFAULT_QUESTION_PREFIX from questionary.question import Question from questionary.styles import merge_styles_default from rich import box -from rich.console import Console +from rich.columns import Columns +from rich.console import Console, Group from rich.panel import Panel from rich.table import Table +from rich.text import Text from ..backends import EngineBackend, SetupIssue from ..backends_helpers import install_issue from ..config import ( ConfigError, - dump_toml, ensure_table, read_config, write_config, ) from ..engines import list_backends from ..logging import suppress_logs -from ..settings import HOME_CONFIG_PATH, load_settings, require_telegram +from ..settings import ( + HOME_CONFIG_PATH, + TelegramTopicsSettings, + load_settings, + require_telegram, +) from ..transports import SetupResult from .api_models import User from .client import TelegramClient, TelegramRetryAfter +from .topics import _validate_topics_setup_for __all__ = [ "ChatInfo", "check_setup", + "debug_onboarding_paths", "interactive_setup", "mask_token", "get_bot_info", "wait_for_chat", ] +TopicScope = Literal["auto", "main", "projects", "all"] +SessionMode = Literal["chat", "stateless"] +Persona = Literal["workspace", "assistant", "handoff"] + @dataclass(frozen=True, slots=True) class ChatInfo: @@ -74,8 +87,95 @@ class ChatInfo: full_name = " ".join(part for part in [self.first_name, self.last_name] if part) return full_name or "private chat" + @property + def kind(self) -> str: + if self.chat_type in {None, "private"}: + return "private chat" + if self.chat_type in {"group", "supergroup"}: + if self.title: + return f'{self.chat_type} "{self.title}"' + return self.chat_type + if self.chat_type == "channel": + if self.title: + return f'channel "{self.title}"' + return "channel" + if self.chat_type: + return self.chat_type + return "unknown chat" -def _display_path(path: Path) -> str: + +@dataclass(slots=True) +class OnboardingState: + config_path: Path + force: bool + + token: str | None = None + bot_username: str | None = None + bot_name: str | None = None + chat: ChatInfo | None = None + persona: Persona | None = None + + session_mode: SessionMode | None = None + topics_enabled: bool = False + topics_scope: TopicScope = "auto" + show_resume_line: bool | None = None + default_engine: str | None = None + + @property + def is_stateful(self) -> bool: + return self.session_mode == "chat" or self.topics_enabled + + @property + def bot_ref(self) -> str: + if self.bot_username: + return f"@{self.bot_username}" + if self.bot_name: + return self.bot_name + return "your bot" + + +class OnboardingCancelled(Exception): + pass + + +def require_value(value: Any) -> Any: + if value is None: + raise OnboardingCancelled() + return value + + +class UI(Protocol): + def panel( + self, + title: str | None, + body: str, + *, + border_style: str = "yellow", + ) -> None: ... + + def step(self, title: str, *, number: int) -> None: ... + def print(self, text: object = "", *, markup: bool | None = None) -> None: ... + async def confirm(self, prompt: str, default: bool = True) -> bool | None: ... + async def select( + self, prompt: str, choices: list[tuple[str, Any]] + ) -> Any | None: ... + async def password(self, prompt: str) -> str | None: ... + + +class Services(Protocol): + async def get_bot_info(self, token: str) -> User | None: ... + async def wait_for_chat(self, token: str) -> ChatInfo: ... + + async def validate_topics( + self, token: str, chat_id: int, scope: TopicScope + ) -> ConfigError | None: ... + + def list_engines(self) -> list[tuple[str, bool, str | None]]: ... + def read_config(self, path: Path) -> dict[str, Any]: ... + def write_config(self, path: Path, data: dict[str, Any]) -> None: ... + + +def display_path(path: Path) -> str: home = Path.home() try: return f"~/{path.relative_to(home)}" @@ -88,7 +188,7 @@ _CONFIGURE_TELEGRAM_TITLE = "configure telegram" def config_issue(path: Path, *, title: str) -> SetupIssue: - return SetupIssue(title, (f" {_display_path(path)}",)) + return SetupIssue(title, (f" {display_path(path)}",)) def check_setup( @@ -198,46 +298,252 @@ async def wait_for_chat(token: str) -> ChatInfo: await bot.close() -async def _send_confirmation(token: str, chat_id: int) -> bool: +def render_engine_table(ui: UI, rows: list[tuple[str, bool, str | None]]) -> None: + table = Table(show_header=True, header_style="bold", box=box.SIMPLE) + table.add_column("engine") + table.add_column("status") + table.add_column("install command") + for engine_id, installed, install_cmd in rows: + status = "[green]✓ installed[/]" if installed else "[dim]✗ not found[/]" + table.add_row( + engine_id, + status, + "" if installed else (install_cmd or "-"), + ) + ui.print(table) + + +def append_dialogue( + text: Text, + speaker: str, + message: str, + *, + speaker_style: str, + message_style: str | None = None, +) -> None: + text.append(f"[{speaker}] ", style=speaker_style) + text.append(message, style=message_style) + text.append("\n") + + +def render_private_chat_instructions(bot_ref: str) -> Text: + return Text.assemble( + f" 1. open a chat with {bot_ref}\n", + " 2. send /start\n", + ) + + +def render_topics_group_instructions(bot_ref: str) -> Text: + return Text.assemble( + " set up a topics group:\n", + " 1. create a group and enable topics (settings → topics)\n", + f' 2. add {bot_ref} as admin with "manage topics"\n', + " 3. send any message in the group\n", + ) + + +def render_generic_capture_prompt(bot_ref: str) -> Text: + return Text.assemble( + f" send /start to {bot_ref} in the chat you want takopi to use " + "(private chat or group)" + ) + + +def render_botfather_instructions() -> Text: + return Text.assemble( + " 1. open telegram and message @BotFather\n", + " 2. send /newbot and follow the prompts\n", + " 3. copy the token (looks like 123456789:ABCdef...)", + ) + + +def render_topics_validation_warning(issue: ConfigError) -> Text: + return Text.assemble( + ("warning: ", "yellow"), + f"topics validation failed: {issue}\n", + ' ensure the bot is admin with "manage topics" permission.', + ) + + +def render_config_malformed_warning(error: ConfigError) -> Text: + return Text.assemble(("warning: ", "yellow"), f"config is malformed: {error}") + + +def render_backup_failed_warning(error: OSError) -> Text: + return Text.assemble(("warning: ", "yellow"), f"failed to back up config: {error}") + + +def render_persona_tabs() -> Table: + active_label = "happian @memory-box" + inactive_label = "takopi @master" + grid = Table.grid(padding=(0, 2)) + grid.pad_edge = False + grid.add_column() + grid.add_column() + grid.add_row(Text(active_label, style="cyan"), Text(inactive_label, style="dim")) + grid.add_row(Text("─" * len(active_label), style="cyan"), Text("")) + return grid + + +def render_workspace_preview() -> Text: + return Text.assemble( + ("[bot] ", "bold magenta"), + ("topic bound to @memory-box\n", "dim"), + ("[you] ", "bold cyan"), + "store artifacts forever\n", + ("[bot] ", "bold magenta"), + ("done · codex · 10s\n", "dim"), + ("[you] ", "bold cyan"), + "also freeze them\n", + ("[bot] ", "bold magenta"), + ("done · codex · 6s\n", "dim"), + ("[you] ", "bold cyan"), + "automatically adjust size\n", + ("[bot] ", "bold magenta"), + ("done · codex · 6s", "dim"), + ) + + +def render_assistant_preview() -> Text: + return Text.assemble( + ("[you] ", "bold cyan"), + "make happy wings fit\n", + ("[bot] ", "bold magenta"), + ("done · codex · 8s\n", "dim"), + ("[you] ", "bold cyan"), + "carry heavy creatures\n", + ("[bot] ", "bold magenta"), + ("done · codex · 12s\n", "dim"), + ("[you] ", "bold cyan"), + ("/new", "green"), + (" ← start fresh\n", "yellow"), + ("[you] ", "bold cyan"), + "add flower pin\n", + ("[bot] ", "bold magenta"), + ("done · codex · 6s\n", "dim"), + ("[you] ", "bold cyan"), + "make wearer appear as flower\n", + ("[bot] ", "bold magenta"), + ("done · codex · 4s", "dim"), + ) + + +def render_handoff_preview() -> Text: + return Text.assemble( + ("[you] ", "bold cyan"), + "make it go back in time\n", + ("[bot] ", "bold magenta"), + ("done · codex · 8s\n", "dim"), + (" codex resume ", "dim"), + ("abc123 ", "cyan"), + ("← reply\n", "yellow"), + ("[you] ", "bold cyan"), + "add reconciliation ribbon\n", + ("[bot] ", "bold magenta"), + ("done · codex · 3s\n", "dim"), + (" codex resume ", "dim"), + ("def456\n", "blue"), + ("[you] ", "bold cyan"), + ("(reply) ", "green"), + "more than once\n", + ("[bot] ", "bold magenta"), + ("done · codex · 8s\n", "dim"), + (" codex resume ", "dim"), + ("abc123", "cyan"), + ) + + +def render_persona_preview(ui: UI) -> None: + panel_width = 40 + workspace_layout = Group( + render_persona_tabs(), + render_workspace_preview(), + ) + assistant_panel = Panel( + render_assistant_preview(), + title=Text("assistant", style="bold"), + subtitle="ongoing chat (recommended)", + border_style="green", + box=box.ROUNDED, + padding=(0, 1), + width=panel_width, + ) + handoff_panel = Panel( + render_handoff_preview(), + title=Text("handoff", style="bold"), + subtitle="reply · terminal resume", + border_style="magenta", + box=box.ROUNDED, + padding=(0, 1), + width=panel_width, + ) + workspace_panel = Panel( + workspace_layout, + title=Text("workspace", style="bold"), + subtitle="project/branch workspaces", + border_style="cyan", + box=box.ROUNDED, + padding=(0, 1), + width=panel_width, + ) + ui.print( + Columns( + [assistant_panel, workspace_panel, handoff_panel], + expand=False, + equal=True, + padding=(0, 2), + ), + markup=False, + ) + + +async def prompt_persona(ui: UI) -> Persona | None: + render_persona_preview(ui) + ui.print("") + return cast( + Persona, + await ui.select( + "how will you use takopi?", + choices=[ + ("assistant (ongoing chat, /new to reset)", "assistant"), + ("workspace (projects + branches, i'll set those up)", "workspace"), + ("handoff (reply to continue, terminal resume)", "handoff"), + ], + ), + ) + + +async def validate_topics_onboarding( + token: str, + chat_id: int, + scope: TopicScope, + project_chat_ids: tuple[int, ...], +) -> ConfigError | None: bot = TelegramClient(token) try: - res = await bot.send_message( + settings = TelegramTopicsSettings(enabled=True, scope=scope) + await _validate_topics_setup_for( + bot=bot, + topics=settings, chat_id=chat_id, - text="takopi is configured and ready.", + project_chat_ids=project_chat_ids, ) - return res is not None + return None + except ConfigError as exc: + return exc + except Exception as exc: # noqa: BLE001 + return ConfigError(f"topics validation failed: {exc}") finally: await bot.close() -def _render_engine_table(console: Console) -> list[tuple[str, bool, str | None]]: - backends = list_backends() - rows: list[tuple[str, bool, str | None]] = [] - table = Table(show_header=True, header_style="bold", box=box.SIMPLE) - table.add_column("agent") - table.add_column("status") - table.add_column("install command") - for backend in backends: - cmd = backend.cli_cmd or backend.id - installed = shutil.which(cmd) is not None - status = "[green]✓ installed[/]" if installed else "[dim]✗ not found[/]" - rows.append((backend.id, installed, backend.install_cmd)) - table.add_row( - backend.id, - status, - "" if installed else (backend.install_cmd or "-"), - ) - console.print(table) - return rows - - @contextmanager -def _suppress_logging(): +def suppress_logging(): with suppress_logs(): yield -def _confirm(message: str, *, default: bool = True) -> bool | None: +async def confirm_prompt(message: str, *, default: bool = True) -> bool | None: merged_style = merge_styles_default([None]) status = {"answer": None, "complete": False} @@ -292,214 +598,489 @@ def _confirm(message: str, *, default: bool = True) -> bool | None: question = Question( PromptSession(get_prompt_tokens, key_bindings=bindings, style=merged_style).app ) - return question.ask() + return await question.ask_async() -def _prompt_token(console: Console) -> tuple[str, User] | None: +class InteractiveUI: + def __init__(self, console: Console) -> None: + self._console = console + + def panel( + self, + title: str | None, + body: str, + *, + border_style: str = "yellow", + ) -> None: + panel = Panel( + body, + title=title, + border_style=border_style, + padding=(1, 2), + expand=False, + ) + self._console.print(panel) + + def step(self, title: str, *, number: int) -> None: + self._console.print("") + self._console.print(Text(f"step {number}: {title}", style="bold yellow")) + self._console.print("") + + def print(self, text: object = "", *, markup: bool | None = None) -> None: + if markup is None: + self._console.print(text) + return + self._console.print(text, markup=markup) + + async def confirm(self, prompt: str, default: bool = True) -> bool | None: + return await confirm_prompt(prompt, default=default) + + async def select(self, prompt: str, choices: list[tuple[str, Any]]) -> Any | None: + return await questionary.select( + prompt, + choices=[ + questionary.Choice(label, value=value) for label, value in choices + ], + instruction="(use arrow keys)", + ).ask_async() + + async def password(self, prompt: str) -> str | None: + return await questionary.password(prompt).ask_async() + + +class LiveServices: + async def get_bot_info(self, token: str) -> User | None: + return await get_bot_info(token) + + async def wait_for_chat(self, token: str) -> ChatInfo: + return await wait_for_chat(token) + + async def validate_topics( + self, token: str, chat_id: int, scope: TopicScope + ) -> ConfigError | None: + return await validate_topics_onboarding(token, chat_id, scope, ()) + + def list_engines(self) -> list[tuple[str, bool, str | None]]: + rows: list[tuple[str, bool, str | None]] = [] + for backend in list_backends(): + cmd = backend.cli_cmd or backend.id + installed = shutil.which(cmd) is not None + rows.append((backend.id, installed, backend.install_cmd)) + return rows + + def read_config(self, path: Path) -> dict[str, Any]: + return read_config(path) + + def write_config(self, path: Path, data: dict[str, Any]) -> None: + write_config(data, path) + + +async def prompt_token(ui: UI, svc: Services) -> tuple[str, User]: while True: - token = questionary.password("paste your bot token:").ask() - if token is None: - return None + ui.print("") + token = require_value(await ui.password("paste your bot token:")) token = token.strip() if not token: - console.print(" token cannot be empty") + ui.print(" token cannot be empty") continue - console.print(" validating...") - info = anyio.run(get_bot_info, token) + ui.print(" validating...") + info = await svc.get_bot_info(token) if info: if info.username: - console.print(f" connected to @{info.username}") + ui.print(f" connected to @{info.username}") else: name = info.first_name or "your bot" - console.print(f" connected to {name}") + ui.print(f" connected to {name}") return token, info - console.print(" failed to connect, check the token and try again") - retry = _confirm("try again?", default=True) + ui.print(" failed to connect, check the token and try again") + ui.print("") + retry = await ui.confirm("try again?", default=True) if not retry: - return None + raise OnboardingCancelled() -def capture_chat_id(*, token: str | None = None) -> ChatInfo | None: - console = Console() - with _suppress_logging(): - if token is not None: - token = token.strip() - if not token: - console.print(" token cannot be empty") - return None - console.print(" validating...") - info = anyio.run(get_bot_info, token) - if not info: - console.print(" failed to connect, check the token and try again") - return None - else: - token_info = _prompt_token(console) - if token_info is None: - return None - token, info = token_info +def build_transport_patch(state: OnboardingState, *, bot_token: str) -> dict[str, Any]: + if state.chat is None: + raise RuntimeError("onboarding state missing chat") + if state.session_mode is None: + raise RuntimeError("onboarding state missing session mode") + if state.show_resume_line is None: + raise RuntimeError("onboarding state missing resume choice") + return { + "bot_token": bot_token, + "chat_id": state.chat.chat_id, + "session_mode": state.session_mode, + "show_resume_line": state.show_resume_line, + "topics": { + "enabled": state.topics_enabled, + "scope": state.topics_scope, + }, + } - bot_ref = f"@{info.username}" - console.print("") - console.print(f" send /start to {bot_ref} (works in groups too)") - console.print(" waiting...") + +def build_config_patch(state: OnboardingState, *, bot_token: str) -> dict[str, Any]: + patch: dict[str, Any] = { + "transport": "telegram", + "transports": {"telegram": build_transport_patch(state, bot_token=bot_token)}, + } + if state.default_engine is not None: + patch["default_engine"] = state.default_engine + return patch + + +def merge_config( + existing: dict[str, Any], + patch: dict[str, Any], + *, + config_path: Path, +) -> dict[str, Any]: + merged = dict(existing) + if "default_engine" in patch: + merged["default_engine"] = patch["default_engine"] + merged["transport"] = patch["transport"] + transports = ensure_table(merged, "transports", config_path=config_path) + telegram = ensure_table( + transports, + "telegram", + config_path=config_path, + label="transports.telegram", + ) + telegram_patch = patch["transports"]["telegram"] + telegram["bot_token"] = telegram_patch["bot_token"] + telegram["chat_id"] = telegram_patch["chat_id"] + telegram["session_mode"] = telegram_patch["session_mode"] + telegram["show_resume_line"] = telegram_patch["show_resume_line"] + topics = ensure_table( + telegram, + "topics", + config_path=config_path, + label="transports.telegram.topics", + ) + topics_patch = telegram_patch["topics"] + topics["enabled"] = topics_patch["enabled"] + topics["scope"] = topics_patch["scope"] + merged.pop("bot_token", None) + merged.pop("chat_id", None) + return merged + + +async def capture_chat( + ui: UI, + svc: Services, + state: OnboardingState, + *, + prompt: Text | None = None, +) -> None: + if state.token is None: + raise RuntimeError("onboarding state missing token") + if prompt is not None: + ui.print(prompt, markup=False) + ui.print(" waiting for message...") + try: + chat = await svc.wait_for_chat(state.token) + except KeyboardInterrupt as exc: + ui.print(" cancelled") + raise OnboardingCancelled() from exc + if chat is None: + ui.print(" cancelled") + raise OnboardingCancelled() + if chat.is_group or chat.chat_type == "channel": + ui.print(f" got chat_id {chat.chat_id} for {chat.kind}") + else: + ui.print(f" got chat_id {chat.chat_id} for {chat.display} ({chat.kind})") + state.chat = chat + + +async def step_token_and_bot(ui: UI, svc: Services, state: OnboardingState) -> None: + have_token = require_value( + await ui.confirm("do you already have a bot token from @BotFather?") + ) + if not have_token: + ui.print(render_botfather_instructions(), markup=False) + else: + ui.print(" token looks like 123456789:ABCdef...") + token, info = await prompt_token(ui, svc) + state.token = token + state.bot_username = info.username + state.bot_name = info.first_name + + +async def step_persona(ui: UI, _svc: Services, state: OnboardingState) -> None: + persona = await prompt_persona(ui) + state.persona = require_value(persona) + if state.persona == "workspace": + state.session_mode = "chat" + state.topics_enabled = True + state.topics_scope = "auto" + state.show_resume_line = False + return + if state.persona == "assistant": + state.session_mode = "chat" + state.topics_enabled = False + state.topics_scope = "auto" + state.show_resume_line = False + return + state.session_mode = "stateless" + state.topics_enabled = False + state.topics_scope = "auto" + state.show_resume_line = True + + +async def step_capture_chat(ui: UI, svc: Services, state: OnboardingState) -> None: + if state.persona is None: + raise RuntimeError("onboarding state missing persona") + if state.persona == "workspace": + await capture_chat( + ui, + svc, + state, + prompt=render_topics_group_instructions(state.bot_ref), + ) + if state.token is None: + raise RuntimeError("onboarding state missing token") + if state.chat is None: + raise RuntimeError("onboarding state missing chat") + while True: + ui.print(" validating topics setup...") + issue = await svc.validate_topics( + state.token, + state.chat.chat_id, + state.topics_scope, + ) + if issue is None: + break + ui.print(render_topics_validation_warning(issue), markup=False) + ui.print("") + choice = await ui.select( + "how to proceed?", + choices=[ + ("retry validation", "retry"), + ("switch to assistant mode", "assistant"), + ], + ) + if choice is None: + raise OnboardingCancelled() + if choice == "assistant": + state.persona = "assistant" + state.topics_enabled = False + state.topics_scope = "auto" + break + return + await capture_chat( + ui, + svc, + state, + prompt=render_private_chat_instructions(state.bot_ref), + ) + + +async def step_default_engine(ui: UI, svc: Services, state: OnboardingState) -> None: + ui.print("takopi runs these agents on your computer. switch anytime with /agent.") + rows = svc.list_engines() + render_engine_table(ui, rows) + installed_ids = [engine_id for engine_id, installed, _ in rows if installed] + + if installed_ids: + ui.print("") + default_engine = await ui.select( + "choose default agent:", + choices=[(engine_id, engine_id) for engine_id in installed_ids], + ) + state.default_engine = require_value(default_engine) + return + + ui.print("no agents found. install one and rerun --onboard.") + ui.print("") + save_anyway = await ui.confirm("save config anyway?", default=False) + if not save_anyway: + raise OnboardingCancelled() + + +async def step_save_config(ui: UI, svc: Services, state: OnboardingState) -> None: + save = await ui.confirm( + f"save config to {display_path(state.config_path)}?", + default=True, + ) + if not save: + raise OnboardingCancelled() + + raw_config: dict[str, Any] = {} + if state.config_path.exists(): try: - chat = anyio.run(wait_for_chat, token) - except KeyboardInterrupt: - console.print(" cancelled") + raw_config = svc.read_config(state.config_path) + except ConfigError as exc: + ui.print(render_config_malformed_warning(exc), markup=False) + backup = state.config_path.with_suffix(".toml.bak") + try: + shutil.copyfile(state.config_path, backup) + except OSError as copy_exc: + ui.print(render_backup_failed_warning(copy_exc), markup=False) + else: + ui.print(f" backed up to {display_path(backup)}") + raw_config = {} + if state.token is None: + raise RuntimeError("onboarding state missing token") + patch = build_config_patch(state, bot_token=state.token) + merged = merge_config(raw_config, patch, config_path=state.config_path) + svc.write_config(state.config_path, merged) + ui.print("") + ui.print(Text("✓ setup complete. starting takopi...", style="green")) + + +def always_true(_state: OnboardingState) -> bool: + return True + + +@dataclass(frozen=True, slots=True) +class OnboardingStep: + title: str | None + number: int | None + run: Callable[[UI, Services, OnboardingState], Awaitable[None]] + applies: Callable[[OnboardingState], bool] = always_true + + +STEPS: list[OnboardingStep] = [ + OnboardingStep("bot token", 1, step_token_and_bot), + OnboardingStep("pick your workflow", 2, step_persona), + OnboardingStep("connect chat", 3, step_capture_chat), + OnboardingStep("default agent", 4, step_default_engine), + OnboardingStep("save config", 5, step_save_config), +] + + +async def run_onboarding(ui: UI, svc: Services, state: OnboardingState) -> bool: + try: + for step in STEPS: + if not step.applies(state): + continue + if step.title and step.number is not None: + ui.step(step.title, number=step.number) + await step.run(ui, svc, state) + except OnboardingCancelled: + return False + return True + + +async def capture_chat_id(*, token: str | None = None) -> ChatInfo | None: + ui = InteractiveUI(Console()) + svc = LiveServices() + state = OnboardingState(config_path=HOME_CONFIG_PATH, force=False) + with suppress_logging(): + try: + if token is not None: + token = token.strip() + if not token: + ui.print(" token cannot be empty") + return None + ui.print(" validating...") + info = await svc.get_bot_info(token) + if not info: + ui.print(" failed to connect, check the token and try again") + return None + state.token = token + state.bot_username = info.username + state.bot_name = info.first_name + else: + token, info = await prompt_token(ui, svc) + state.token = token + state.bot_username = info.username + state.bot_name = info.first_name + + await capture_chat( + ui, + svc, + state, + prompt=render_generic_capture_prompt(state.bot_ref), + ) + return state.chat + except OnboardingCancelled: return None - if chat is None: - console.print(" cancelled") - return None - console.print(f" got chat_id {chat.chat_id} from {chat.display}") - return chat -def interactive_setup(*, force: bool) -> bool: - console = Console() - config_path = HOME_CONFIG_PATH +async def interactive_setup(*, force: bool) -> bool: + ui = InteractiveUI(Console()) + svc = LiveServices() + state = OnboardingState(config_path=HOME_CONFIG_PATH, force=force) - if config_path.exists() and not force: - console.print( - f"config already exists at {_display_path(config_path)}. " + if state.config_path.exists() and not force: + ui.print( + f"config already exists at {display_path(state.config_path)}. " "use --onboard to reconfigure." ) return True - if config_path.exists() and force: - overwrite = _confirm( - f"update existing config at {_display_path(config_path)}?", + if state.config_path.exists() and force: + overwrite = await ui.confirm( + f"update existing config at {display_path(state.config_path)}?", default=False, ) if not overwrite: return False - with _suppress_logging(): - panel = Panel( - "let's set up your telegram bot.", - title="welcome to takopi!", - border_style="yellow", - padding=(1, 2), - expand=False, - ) - console.print(panel) + with suppress_logging(): + return await run_onboarding(ui, svc, state) - console.print("step 1: telegram bot setup\n") - have_token = _confirm("do you have a telegram bot token?") - if have_token is None: - return False - if not have_token: - console.print(" 1. open telegram and message @BotFather") - console.print(" 2. send /newbot and follow the prompts") - console.print(" 3. copy the token (looks like 123456789:ABCdef...)") - console.print("") - token_info = _prompt_token(console) - if token_info is None: - return False - token, info = token_info - bot_ref = f"@{info.username}" +def debug_onboarding_paths(console: Console | None = None) -> None: + console = console or Console() + table = Table(show_header=True, header_style="bold", box=box.SIMPLE) + table.add_column("#", justify="right", style="dim") + table.add_column("persona") + table.add_column("session") + table.add_column("topics") + table.add_column("resume footer") + table.add_column("topics check") + table.add_column("engines") + table.add_column("save anyway") + table.add_column("save config") + table.add_column("outcome") - console.print("") - console.print(f" send /start to {bot_ref} (works in groups too)") - console.print(" waiting...") - try: - chat = anyio.run(wait_for_chat, token) - except KeyboardInterrupt: - console.print(" cancelled") - return False - if chat is None: - console.print(" cancelled") - return False - console.print(f" got chat_id {chat.chat_id} from {chat.display}") + engine_paths: list[tuple[bool, bool | None, tuple[bool | None, ...]]] = [ + (True, None, (True, False)), + (False, False, (None,)), + (False, True, (True, False)), + ] - sent = anyio.run(_send_confirmation, token, chat.chat_id) - if sent: - console.print(" sent confirmation message") - else: - console.print(" could not send confirmation message") + path_count = 0 + personas = { + "workspace": ("chat", True, "hide"), + "assistant": ("chat", False, "hide"), + "handoff": ("stateless", False, "show (fixed)"), + } + for persona, (session_mode, topics_enabled, resume_label) in personas.items(): + topics_label = "on" if topics_enabled else "off" + topics_check = "run" if topics_enabled else "skip" + for agents_found, save_anyway, save_configs in engine_paths: + for save_config in save_configs: + path_count += 1 + agents_label = "found" if agents_found else "none" + save_anyway_label = format_bool(save_anyway) + save_config_label = format_bool(save_config) + outcome = "saved" if save_config else "exit" + table.add_row( + str(path_count), + persona, + session_mode, + topics_label, + resume_label, + topics_check, + agents_label, + save_anyway_label, + save_config_label, + outcome, + ) - console.print("\nstep 2: agent cli tools") - rows = _render_engine_table(console) - installed_ids = [engine_id for engine_id, installed, _ in rows if installed] + console.print(f"onboarding paths ({path_count})", markup=False) + console.print( + "assumes config is missing or --onboard was confirmed; " + "cancellations/timeouts are omitted.", + markup=False, + ) + console.print("") + console.print(table) - default_engine: str | None = None - if installed_ids: - default_engine = questionary.select( - "choose default agent:", - choices=installed_ids, - ).ask() - if default_engine is None: - return False - else: - console.print("no agents found on PATH. install one to continue.") - save_anyway = _confirm("save config anyway?", default=False) - if not save_anyway: - return False - preview_config: dict[str, Any] = {} - if default_engine is not None: - preview_config["default_engine"] = default_engine - preview_config["transport"] = "telegram" - preview_config["transports"] = { - "telegram": { - "bot_token": mask_token(token), - "chat_id": chat.chat_id, - } - } - config_preview = dump_toml(preview_config).rstrip() - console.print("\nstep 3: save configuration\n") - console.print(f" {_display_path(config_path)}\n") - for line in config_preview.splitlines(): - console.print(f" {line}") - console.print("") - - save = _confirm( - f"save this config to {_display_path(config_path)}?", - default=True, - ) - if not save: - return False - - raw_config: dict[str, Any] = {} - if config_path.exists(): - try: - raw_config = read_config(config_path) - except ConfigError as exc: - console.print(f"[yellow]warning:[/] config is malformed: {exc}") - backup = config_path.with_suffix(".toml.bak") - try: - shutil.copyfile(config_path, backup) - except OSError as copy_exc: - console.print( - f"[yellow]warning:[/] failed to back up config: {copy_exc}" - ) - else: - console.print(f" backed up to {_display_path(backup)}") - raw_config = {} - merged = dict(raw_config) - if default_engine is not None: - merged["default_engine"] = default_engine - merged["transport"] = "telegram" - transports = ensure_table(merged, "transports", config_path=config_path) - telegram = ensure_table( - transports, - "telegram", - config_path=config_path, - label="transports.telegram", - ) - telegram["bot_token"] = token - telegram["chat_id"] = chat.chat_id - merged.pop("bot_token", None) - merged.pop("chat_id", None) - write_config(merged, config_path) - console.print(f" config saved to {_display_path(config_path)}") - - done_panel = Panel( - "setup complete. starting takopi...", - border_style="green", - padding=(1, 2), - expand=False, - ) - console.print("\n") - console.print(done_panel) - return True +def format_bool(value: bool | None) -> str: + if value is None: + return "n/a" + return "yes" if value else "no" diff --git a/src/takopi/telegram/topics.py b/src/takopi/telegram/topics.py index 6ab102a..b745c0e 100644 --- a/src/takopi/telegram/topics.py +++ b/src/takopi/telegram/topics.py @@ -1,10 +1,13 @@ from __future__ import annotations +from collections.abc import Iterable from typing import TYPE_CHECKING from ..config import ConfigError from ..context import RunContext +from ..settings import TelegramTopicsSettings from ..transport_runtime import TransportRuntime +from .client import BotClient from .topic_state import TopicStateStore, TopicThreadSnapshot from .types import TelegramIncomingMessage @@ -28,18 +31,25 @@ __all__ = [ _TOPICS_COMMANDS = {"ctx", "new", "topic"} -def _resolve_topics_scope(cfg: TelegramBridgeConfig) -> tuple[str, frozenset[int]]: - scope = cfg.topics.scope - project_ids = set(cfg.runtime.project_chat_ids()) +def _resolve_topics_scope_raw( + scope: str, chat_id: int, project_chat_ids: Iterable[int] +) -> tuple[str, frozenset[int]]: + project_ids = set(project_chat_ids) if scope == "auto": scope = "projects" if project_ids else "main" if scope == "main": - return scope, frozenset({cfg.chat_id}) + return scope, frozenset({chat_id}) if scope == "projects": return scope, frozenset(project_ids) if scope == "all": - return scope, frozenset({cfg.chat_id, *project_ids}) - raise ValueError(f"Invalid topics.scope: {cfg.topics.scope!r}") + return scope, frozenset({chat_id, *project_ids}) + raise ValueError(f"Invalid topics.scope: {scope!r}") + + +def _resolve_topics_scope(cfg: TelegramBridgeConfig) -> tuple[str, frozenset[int]]: + return _resolve_topics_scope_raw( + cfg.topics.scope, cfg.chat_id, cfg.runtime.project_chat_ids() + ) def _topics_scope_label(cfg: TelegramBridgeConfig) -> str: @@ -182,13 +192,28 @@ async def _maybe_update_topic_context( async def _validate_topics_setup(cfg: TelegramBridgeConfig) -> None: - if not cfg.topics.enabled: + await _validate_topics_setup_for( + bot=cfg.bot, + topics=cfg.topics, + chat_id=cfg.chat_id, + project_chat_ids=cfg.runtime.project_chat_ids(), + ) + + +async def _validate_topics_setup_for( + *, + bot: BotClient, + topics: TelegramTopicsSettings, + chat_id: int, + project_chat_ids: Iterable[int], +) -> None: + if not topics.enabled: return - me = await cfg.bot.get_me() + me = await bot.get_me() if me is None: raise ConfigError("failed to fetch bot id for topics validation.") bot_id = me.id - scope, chat_ids = _resolve_topics_scope(cfg) + scope, chat_ids = _resolve_topics_scope_raw(topics.scope, chat_id, project_chat_ids) if scope == "projects" and not chat_ids: raise ConfigError( "topics enabled but no project chats are configured; " @@ -196,7 +221,7 @@ async def _validate_topics_setup(cfg: TelegramBridgeConfig) -> None: ) for chat_id in chat_ids: - chat = await cfg.bot.get_chat(chat_id) + chat = await bot.get_chat(chat_id) if chat is None: raise ConfigError( f"failed to fetch chat info for topics validation ({chat_id})." @@ -211,7 +236,7 @@ async def _validate_topics_setup(cfg: TelegramBridgeConfig) -> None: "topics enabled but chat does not have topics enabled " f"(chat_id={chat_id}); turn on topics in group settings." ) - member = await cfg.bot.get_chat_member(chat_id, bot_id) + member = await bot.get_chat_member(chat_id, bot_id) if member is None: raise ConfigError( "failed to fetch bot permissions " diff --git a/src/takopi/transports.py b/src/takopi/transports.py index 3c9268c..2e42704 100644 --- a/src/takopi/transports.py +++ b/src/takopi/transports.py @@ -32,7 +32,7 @@ class TransportBackend(Protocol): transport_override: str | None = None, ) -> SetupResult: ... - def interactive_setup(self, *, force: bool) -> bool: ... + async def interactive_setup(self, *, force: bool) -> bool: ... def lock_token( self, *, transport_config: object, _config_path: Path diff --git a/tests/test_cli_chat_id.py b/tests/test_cli_chat_id.py index 28725a7..11c9281 100644 --- a/tests/test_cli_chat_id.py +++ b/tests/test_cli_chat_id.py @@ -16,7 +16,7 @@ def test_chat_id_command_updates_project_chat_id(monkeypatch, tmp_path) -> None: monkeypatch.setattr("takopi.config.HOME_CONFIG_PATH", config_path) monkeypatch.setattr(cli, "_load_settings_optional", lambda: (None, None)) - def _capture(*, token: str | None = None): + async def _capture(*, token: str | None = None): assert token == "token" return onboarding.ChatInfo( chat_id=123, @@ -50,7 +50,7 @@ def test_chat_id_command_uses_config_token(monkeypatch) -> None: ) monkeypatch.setattr(cli, "_load_settings_optional", lambda: (settings, Path("x"))) - def _capture(*, token: str | None = None): + async def _capture(*, token: str | None = None): assert token == "config-token" return onboarding.ChatInfo( chat_id=321, diff --git a/tests/test_cli_doctor.py b/tests/test_cli_doctor.py new file mode 100644 index 0000000..17a8677 --- /dev/null +++ b/tests/test_cli_doctor.py @@ -0,0 +1,52 @@ +from pathlib import Path + +from typer.testing import CliRunner + +from takopi import cli +from takopi.settings import TakopiSettings + + +def _settings() -> TakopiSettings: + return TakopiSettings.model_validate( + { + "transport": "telegram", + "transports": {"telegram": {"bot_token": "token", "chat_id": 123}}, + } + ) + + +def test_doctor_ok(monkeypatch) -> None: + settings = _settings() + monkeypatch.setattr(cli, "load_settings", lambda: (settings, Path("x"))) + monkeypatch.setattr(cli, "resolve_plugins_allowlist", lambda _settings: None) + monkeypatch.setattr(cli, "list_backend_ids", lambda allowlist=None: ["codex"]) + + async def _fake_checks(*_args, **_kwargs): + return [cli.DoctorCheck("telegram token", "ok", "@bot")] + + monkeypatch.setattr(cli, "_doctor_telegram_checks", _fake_checks) + + runner = CliRunner() + result = runner.invoke(cli.create_app(), ["doctor"]) + + assert result.exit_code == 0 + assert "takopi doctor" in result.output + assert "telegram token: ok" in result.output + + +def test_doctor_errors_exit_nonzero(monkeypatch) -> None: + settings = _settings() + monkeypatch.setattr(cli, "load_settings", lambda: (settings, Path("x"))) + monkeypatch.setattr(cli, "resolve_plugins_allowlist", lambda _settings: None) + monkeypatch.setattr(cli, "list_backend_ids", lambda allowlist=None: ["codex"]) + + async def _fake_checks(*_args, **_kwargs): + return [cli.DoctorCheck("telegram token", "error", "bad token")] + + monkeypatch.setattr(cli, "_doctor_telegram_checks", _fake_checks) + + runner = CliRunner() + result = runner.invoke(cli.create_app(), ["doctor"]) + + assert result.exit_code == 1 + assert "telegram token: error" in result.output diff --git a/tests/test_onboarding_interactive.py b/tests/test_onboarding_interactive.py index b904e1d..639ee1c 100644 --- a/tests/test_onboarding_interactive.py +++ b/tests/test_onboarding_interactive.py @@ -1,5 +1,8 @@ from __future__ import annotations +import anyio +from functools import partial + from takopi.backends import EngineBackend from takopi.config import dump_toml from takopi.telegram import onboarding @@ -39,32 +42,56 @@ def test_render_config_escapes() -> None: assert config.endswith("\n") -class _FakeQuestion: +class FakeQuestion: def __init__(self, value): self._value = value def ask(self): return self._value + async def ask_async(self): + return self._value -def _queue(values): + +def queue_answers(values): it = iter(values) def _make(*_args, **_kwargs): - return _FakeQuestion(next(it)) + return FakeQuestion(next(it)) return _make -def _queue_values(values): +def queue_values(values): it = iter(values) - def _next(*_args, **_kwargs): + async def _next(*_args, **_kwargs): return next(it) return _next +def patch_live_services( + monkeypatch, + *, + bot: User, + chat: onboarding.ChatInfo, + topics_issue=None, +) -> None: + async def _get_bot_info(self, _token: str): + return bot + + async def _wait_for_chat(self, _token: str): + return chat + + async def _validate_topics(self, _token: str, _chat_id: int, _scope): + return topics_issue + + monkeypatch.setattr(onboarding.LiveServices, "get_bot_info", _get_bot_info) + monkeypatch.setattr(onboarding.LiveServices, "wait_for_chat", _wait_for_chat) + monkeypatch.setattr(onboarding.LiveServices, "validate_topics", _validate_topics) + + def test_interactive_setup_skips_when_config_exists(monkeypatch, tmp_path) -> None: config_path = tmp_path / "takopi.toml" config_path.write_text( @@ -73,7 +100,7 @@ def test_interactive_setup_skips_when_config_exists(monkeypatch, tmp_path) -> No encoding="utf-8", ) monkeypatch.setattr(onboarding, "HOME_CONFIG_PATH", config_path) - assert onboarding.interactive_setup(force=False) is True + assert anyio.run(partial(onboarding.interactive_setup, force=False)) is True def test_interactive_setup_writes_config(monkeypatch, tmp_path) -> None: @@ -84,36 +111,38 @@ def test_interactive_setup_writes_config(monkeypatch, tmp_path) -> None: monkeypatch.setattr(onboarding, "list_backends", lambda: [backend]) monkeypatch.setattr(onboarding.shutil, "which", lambda _cmd: "/usr/bin/codex") - monkeypatch.setattr(onboarding, "_confirm", _queue_values([True, True])) + monkeypatch.setattr(onboarding, "confirm_prompt", queue_values([True, True])) monkeypatch.setattr( - onboarding.questionary, "password", _queue(["123456789:ABCdef"]) + onboarding.questionary, "password", queue_answers(["123456789:ABCdef"]) + ) + monkeypatch.setattr( + onboarding.questionary, + "select", + queue_answers(["assistant", "codex"]), + ) + patch_live_services( + monkeypatch, + bot=User(id=1, username="my_bot"), + chat=onboarding.ChatInfo( + chat_id=123, + username="alice", + title=None, + first_name="Alice", + last_name=None, + chat_type="private", + ), ) - monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"])) - def _fake_run(func, *args, **kwargs): - if func is onboarding.get_bot_info: - return User(id=1, username="my_bot") - if func is onboarding.wait_for_chat: - return onboarding.ChatInfo( - chat_id=123, - username="alice", - title=None, - first_name="Alice", - last_name=None, - chat_type="private", - ) - if func is onboarding._send_confirmation: - return True - raise AssertionError(f"unexpected anyio.run target: {func}") - - monkeypatch.setattr(onboarding.anyio, "run", _fake_run) - - assert onboarding.interactive_setup(force=False) is True + assert anyio.run(partial(onboarding.interactive_setup, force=False)) is True saved = config_path.read_text(encoding="utf-8") assert 'transport = "telegram"' in saved assert "[transports.telegram]" in saved assert 'bot_token = "123456789:ABCdef"' in saved assert "chat_id = 123" in saved + assert 'session_mode = "chat"' in saved + assert "show_resume_line = false" in saved + assert "[transports.telegram.topics]" in saved + assert "enabled = false" in saved assert 'default_engine = "codex"' in saved @@ -129,31 +158,29 @@ def test_interactive_setup_preserves_projects(monkeypatch, tmp_path) -> None: monkeypatch.setattr(onboarding, "list_backends", lambda: [backend]) monkeypatch.setattr(onboarding.shutil, "which", lambda _cmd: "/usr/bin/codex") - monkeypatch.setattr(onboarding, "_confirm", _queue_values([True, True, True])) + monkeypatch.setattr(onboarding, "confirm_prompt", queue_values([True, True, True])) monkeypatch.setattr( - onboarding.questionary, "password", _queue(["123456789:ABCdef"]) + onboarding.questionary, "password", queue_answers(["123456789:ABCdef"]) + ) + monkeypatch.setattr( + onboarding.questionary, + "select", + queue_answers(["assistant", "codex"]), + ) + patch_live_services( + monkeypatch, + bot=User(id=1, username="my_bot"), + chat=onboarding.ChatInfo( + chat_id=123, + username="alice", + title=None, + first_name="Alice", + last_name=None, + chat_type="private", + ), ) - monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"])) - def _fake_run(func, *args, **kwargs): - if func is onboarding.get_bot_info: - return User(id=1, username="my_bot") - if func is onboarding.wait_for_chat: - return onboarding.ChatInfo( - chat_id=123, - username="alice", - title=None, - first_name="Alice", - last_name=None, - chat_type="private", - ) - if func is onboarding._send_confirmation: - return True - raise AssertionError(f"unexpected anyio.run target: {func}") - - monkeypatch.setattr(onboarding.anyio, "run", _fake_run) - - assert onboarding.interactive_setup(force=True) is True + assert anyio.run(partial(onboarding.interactive_setup, force=True)) is True saved = config_path.read_text(encoding="utf-8") assert "[projects.z80]" in saved assert 'path = "/tmp/repo"' in saved @@ -167,30 +194,29 @@ def test_interactive_setup_no_agents_aborts(monkeypatch, tmp_path) -> None: monkeypatch.setattr(onboarding, "list_backends", lambda: [backend]) monkeypatch.setattr(onboarding.shutil, "which", lambda _cmd: None) - monkeypatch.setattr(onboarding, "_confirm", _queue_values([True, False])) + monkeypatch.setattr(onboarding, "confirm_prompt", queue_values([True, False])) monkeypatch.setattr( - onboarding.questionary, "password", _queue(["123456789:ABCdef"]) + onboarding.questionary, "password", queue_answers(["123456789:ABCdef"]) + ) + monkeypatch.setattr( + onboarding.questionary, + "select", + queue_answers(["assistant"]), + ) + patch_live_services( + monkeypatch, + bot=User(id=1, username="my_bot"), + chat=onboarding.ChatInfo( + chat_id=123, + username="alice", + title=None, + first_name="Alice", + last_name=None, + chat_type="private", + ), ) - def _fake_run(func, *args, **kwargs): - if func is onboarding.get_bot_info: - return User(id=1, username="my_bot") - if func is onboarding.wait_for_chat: - return onboarding.ChatInfo( - chat_id=123, - username="alice", - title=None, - first_name="Alice", - last_name=None, - chat_type="private", - ) - if func is onboarding._send_confirmation: - return True - raise AssertionError(f"unexpected anyio.run target: {func}") - - monkeypatch.setattr(onboarding.anyio, "run", _fake_run) - - assert onboarding.interactive_setup(force=False) is False + assert anyio.run(partial(onboarding.interactive_setup, force=False)) is False assert not config_path.exists() @@ -204,31 +230,29 @@ def test_interactive_setup_recovers_from_malformed_toml(monkeypatch, tmp_path) - monkeypatch.setattr(onboarding, "list_backends", lambda: [backend]) monkeypatch.setattr(onboarding.shutil, "which", lambda _cmd: "/usr/bin/codex") - monkeypatch.setattr(onboarding, "_confirm", _queue_values([True, True, True])) + monkeypatch.setattr(onboarding, "confirm_prompt", queue_values([True, True, True])) monkeypatch.setattr( - onboarding.questionary, "password", _queue(["123456789:ABCdef"]) + onboarding.questionary, "password", queue_answers(["123456789:ABCdef"]) + ) + monkeypatch.setattr( + onboarding.questionary, + "select", + queue_answers(["assistant", "codex"]), + ) + patch_live_services( + monkeypatch, + bot=User(id=1, username="my_bot"), + chat=onboarding.ChatInfo( + chat_id=123, + username="alice", + title=None, + first_name="Alice", + last_name=None, + chat_type="private", + ), ) - monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"])) - def _fake_run(func, *args, **kwargs): - if func is onboarding.get_bot_info: - return User(id=1, username="my_bot") - if func is onboarding.wait_for_chat: - return onboarding.ChatInfo( - chat_id=123, - username="alice", - title=None, - first_name="Alice", - last_name=None, - chat_type="private", - ) - if func is onboarding._send_confirmation: - return True - raise AssertionError(f"unexpected anyio.run target: {func}") - - monkeypatch.setattr(onboarding.anyio, "run", _fake_run) - - assert onboarding.interactive_setup(force=True) is True + assert anyio.run(partial(onboarding.interactive_setup, force=True)) is True backup = config_path.with_suffix(".toml.bak") assert backup.exists() assert backup.read_text(encoding="utf-8") == bad_toml @@ -238,50 +262,44 @@ def test_interactive_setup_recovers_from_malformed_toml(monkeypatch, tmp_path) - def test_capture_chat_id_with_token(monkeypatch) -> None: - def _fake_run(func, *args, **kwargs): - if func is onboarding.get_bot_info: - return User(id=1, username="my_bot") - if func is onboarding.wait_for_chat: - return onboarding.ChatInfo( - chat_id=456, - username=None, - title="takopi", - first_name=None, - last_name=None, - chat_type="supergroup", - ) - raise AssertionError(f"unexpected anyio.run target: {func}") + patch_live_services( + monkeypatch, + bot=User(id=1, username="my_bot"), + chat=onboarding.ChatInfo( + chat_id=456, + username=None, + title="takopi", + first_name=None, + last_name=None, + chat_type="supergroup", + ), + ) - monkeypatch.setattr(onboarding.anyio, "run", _fake_run) - - chat = onboarding.capture_chat_id(token="123456789:ABCdef") + chat = anyio.run(partial(onboarding.capture_chat_id, token="123456789:ABCdef")) assert chat is not None assert chat.chat_id == 456 def test_capture_chat_id_prompts_for_token(monkeypatch) -> None: - monkeypatch.setattr( - onboarding, - "_prompt_token", - lambda _console: ("token", User(id=1, username="bot")), + async def _prompt_token(_ui, _svc): + return ("token", User(id=1, username="bot")) + + monkeypatch.setattr(onboarding, "prompt_token", _prompt_token) + patch_live_services( + monkeypatch, + bot=User(id=1, username="bot"), + chat=onboarding.ChatInfo( + chat_id=789, + username="alice", + title=None, + first_name="Alice", + last_name=None, + chat_type="private", + ), ) - def _fake_run(func, *args, **kwargs): - if func is onboarding.wait_for_chat: - return onboarding.ChatInfo( - chat_id=789, - username="alice", - title=None, - first_name="Alice", - last_name=None, - chat_type="private", - ) - raise AssertionError(f"unexpected anyio.run target: {func}") - - monkeypatch.setattr(onboarding.anyio, "run", _fake_run) - - chat = onboarding.capture_chat_id() + chat = anyio.run(onboarding.capture_chat_id) assert chat is not None assert chat.chat_id == 789 diff --git a/tests/test_projects_config.py b/tests/test_projects_config.py index 7550125..d708315 100644 --- a/tests/test_projects_config.py +++ b/tests/test_projects_config.py @@ -5,6 +5,7 @@ from typer.testing import CliRunner from takopi import cli from takopi.config import ConfigError, read_config +from takopi.ids import RESERVED_CHAT_COMMANDS from takopi.settings import TakopiSettings @@ -19,7 +20,7 @@ def test_parse_projects_rejects_engine_alias() -> None: settings.to_projects_config( config_path=Path("takopi.toml"), engine_ids=["codex"], - reserved=("cancel",), + reserved=RESERVED_CHAT_COMMANDS, ) @@ -30,7 +31,7 @@ def test_parse_projects_default_project_must_exist() -> None: settings.to_projects_config( config_path=Path("takopi.toml"), engine_ids=["codex"], - reserved=("cancel",), + reserved=RESERVED_CHAT_COMMANDS, ) @@ -94,7 +95,7 @@ def test_projects_default_engine_unknown() -> None: settings.to_projects_config( config_path=Path("takopi.toml"), engine_ids=["codex"], - reserved=("cancel",), + reserved=RESERVED_CHAT_COMMANDS, ) @@ -108,7 +109,7 @@ def test_projects_chat_id_cannot_match_transport_chat_id() -> None: settings.to_projects_config( config_path=Path("takopi.toml"), engine_ids=["codex"], - reserved=("cancel",), + reserved=RESERVED_CHAT_COMMANDS, ) @@ -125,7 +126,7 @@ def test_projects_chat_id_must_be_unique() -> None: settings.to_projects_config( config_path=Path("takopi.toml"), engine_ids=["codex"], - reserved=("cancel",), + reserved=RESERVED_CHAT_COMMANDS, ) @@ -137,6 +138,6 @@ def test_projects_relative_path_resolves(tmp_path: Path) -> None: projects = settings.to_projects_config( config_path=config_path, engine_ids=["codex"], - reserved=("cancel",), + reserved=RESERVED_CHAT_COMMANDS, ) assert projects.projects["z80"].path == config_path.parent / "repo" diff --git a/tests/test_telegram_backend.py b/tests/test_telegram_backend.py index b644a6e..b2638d1 100644 --- a/tests/test_telegram_backend.py +++ b/tests/test_telegram_backend.py @@ -41,7 +41,12 @@ def test_build_startup_message_includes_missing_engines(tmp_path: Path) -> None: ) message = telegram_backend._build_startup_message( - runtime, startup_pwd=str(tmp_path) + runtime, + startup_pwd=str(tmp_path), + chat_id=123, + session_mode="stateless", + show_resume_line=True, + topics=TelegramTopicsSettings(), ) assert "takopi is ready" in message @@ -79,7 +84,12 @@ def test_build_startup_message_surfaces_unavailable_engine_reasons( ) message = telegram_backend._build_startup_message( - runtime, startup_pwd=str(tmp_path) + runtime, + startup_pwd=str(tmp_path), + chat_id=123, + session_mode="stateless", + show_resume_line=True, + topics=TelegramTopicsSettings(), ) assert "agents: `codex" in message diff --git a/tests/test_telegram_bridge.py b/tests/test_telegram_bridge.py index 116bbc1..f3f5760 100644 --- a/tests/test_telegram_bridge.py +++ b/tests/test_telegram_bridge.py @@ -383,6 +383,8 @@ def test_build_bot_commands_includes_cancel_and_engine() -> None: assert {"command": "cancel", "description": "cancel run"} in commands assert {"command": "file", "description": "upload or fetch files"} in commands + assert {"command": "new", "description": "start a new thread"} in commands + assert {"command": "agent", "description": "set default agent"} in commands assert any(cmd["command"] == "codex" for cmd in commands) @@ -414,6 +416,21 @@ def test_build_bot_commands_includes_projects() -> None: assert not any(cmd["command"] == "bad-name" for cmd in commands) +def test_build_bot_commands_includes_topics_when_enabled() -> None: + runner = ScriptRunner( + [Return(answer="ok")], engine=CODEX_ENGINE, resume_value="sid" + ) + runtime = TransportRuntime( + router=_make_router(runner), + projects=_empty_projects(), + ) + + commands = build_bot_commands(runtime, include_topics=True) + + assert {"command": "topic", "description": "create or bind a topic"} in commands + assert {"command": "ctx", "description": "show or update topic context"} in commands + + def test_build_bot_commands_includes_command_plugins(monkeypatch) -> None: class _Command: id = "pingcmd" diff --git a/tests/test_transport_registry.py b/tests/test_transport_registry.py index 7569576..3303d5c 100644 --- a/tests/test_transport_registry.py +++ b/tests/test_transport_registry.py @@ -12,7 +12,7 @@ class DummyTransport: def check_setup(self, *args, **kwargs): raise NotImplementedError - def interactive_setup(self, *, force: bool) -> bool: + async def interactive_setup(self, *, force: bool) -> bool: raise NotImplementedError def lock_token(self, *, transport_config: object, _config_path): diff --git a/zensical.toml b/zensical.toml index a758182..c69c7fd 100644 --- a/zensical.toml +++ b/zensical.toml @@ -111,7 +111,6 @@ features = [ "content.code.copy", "content.action.edit", "content.action.view", - "search.highlight", ] [project.theme.icon] @@ -154,6 +153,9 @@ emoji_index = "zensical.extensions.emoji.twemoji" emoji_generator = "zensical.extensions.emoji.to_svg" options.custom_icons = ["docs/overrides/.icons"] +[project.markdown_extensions.pymdownx.tasklist] +custom_checkbox = true + [project.markdown_extensions.toc] permalink = true