feat: onboarding overhaul, persona-based setup (#132)

This commit is contained in:
banteg
2026-01-15 03:28:37 +04:00
committed by GitHub
parent a1a2714c01
commit ffae80dce7
32 changed files with 1870 additions and 445 deletions
+5 -1
View File
@@ -9,12 +9,15 @@ Takopi supports three ways to continue a thread:
1. **Reply-to-continue** (always available) 1. **Reply-to-continue** (always available)
- Reply to any bot message that contains a resume line in the footer. - Reply to any bot message that contains a resume line in the footer.
- Takopi extracts the resume token and resumes that engine thread. - 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) 2. **Forum topics** (optional)
- Topics can store resume tokens per topic and auto-resume new messages in that topic. - Topics can store resume tokens per topic and auto-resume new messages in that topic.
- Topic state is stored in `telegram_topics_state.json`. - Topic state is stored in `telegram_topics_state.json`.
- Reset with `/new`. - Reset with `/new`.
3. **Chat sessions** (optional) 3. **Chat sessions** (optional)
- Set `session_mode = "chat"` to store one resume token per chat (per sender in groups). - 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`. - State is stored in `telegram_chat_sessions_state.json`.
- Reset with `/new`. - Reset with `/new`.
@@ -39,6 +42,7 @@ The precise invariants are specified in the [Specification](../reference/specifi
## Related ## Related
- [Conversation modes](../tutorials/conversation-modes.md)
- [Chat sessions](../how-to/chat-sessions.md)
- [Commands & directives](../reference/commands-and-directives.md) - [Commands & directives](../reference/commands-and-directives.md)
- [Context resolution](../reference/context-resolution.md) - [Context resolution](../reference/context-resolution.md)
+43
View File
@@ -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)
+2 -2
View File
@@ -11,8 +11,8 @@ If you need exact options and defaults, use **[Reference](../reference/index.md)
- [Projects](projects.md) (register repos + run from anywhere) - [Projects](projects.md) (register repos + run from anywhere)
- [Worktrees](worktrees.md) (run work on `@branch` without switching your main checkout) - [Worktrees](worktrees.md) (run work on `@branch` without switching your main checkout)
- [Route by chat](route-by-chat.md) (dedicated chats per project) - [Route by chat](route-by-chat.md) (dedicated chats per project)
- [Topics](topics.md) (forum threads bound to repo/branch + auto-resume) - [Topics](topics.md) (forum threads bound to repo/branch + per-topic defaults)
- [Chat sessions](topics.md#chat-sessions) (auto-resume without replying) - [Chat sessions](chat-sessions.md) (auto-resume without replying)
## Messaging extras ## Messaging extras
+63 -29
View File
@@ -1,6 +1,19 @@
# Topics # 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.<alias>.chat_id`
## Enable topics ## Enable topics
@@ -10,44 +23,65 @@ enabled = true
scope = "auto" # auto | main | projects | all scope = "auto" # auto | main | projects | all
``` ```
Your bot needs **Manage Topics** permission in the group. ### Scope explained
If any `projects.<alias>.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.<alias>.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 <project> @branch
| `/topic <project> @branch` | Create a new topic bound to context |
| `/ctx` | Show the current binding |
| `/ctx set <project> @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
``` ```
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 <project> @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 ## State files
- Topic state: `telegram_topics_state.json` Topic bindings and sessions live in:
- Chat sessions state: `telegram_chat_sessions_state.json`
- Chat defaults (e.g. `/agent`): `telegram_chat_prefs_state.json` - `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 ## 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) - [Switch engines](switch-engines.md)
- [Commands & directives](../reference/commands-and-directives.md)
+7
View File
@@ -8,3 +8,10 @@ takopi --debug
Then check `debug.log` for errors and include it when reporting issues. 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.
+24
View File
@@ -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). 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
<div class="grid cards" markdown>
- :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)
</div>
## Choose your path ## Choose your path
<div class="grid cards" markdown> <div class="grid cards" markdown>
@@ -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). Start with [Tutorials](tutorials/index.md).
- [Install & onboard](tutorials/install-and-onboard.md) - [Install & onboard](tutorials/install-and-onboard.md)
- [Conversation modes](tutorials/conversation-modes.md)
- [First run](tutorials/first-run.md) - [First run](tutorials/first-run.md)
- :lucide-compass:{ .lg } **I know what I want to do** - :lucide-compass:{ .lg } **I know what I want to do**
+1 -1
View File
@@ -57,6 +57,7 @@ Takopis CLI is an auto-router by default; engine subcommands override the def
| `takopi init <alias>` | Register the current repo as a project. | | `takopi init <alias>` | Register the current repo as a project. |
| `takopi chat-id` | Capture the current chat id. | | `takopi chat-id` | Capture the current chat id. |
| `takopi chat-id --project <alias>` | Save the captured chat id to a project. | | `takopi chat-id --project <alias>` | 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` | List discovered plugins without loading them. |
| `takopi plugins --load` | Load each plugin to validate types and surface import errors. | | `takopi plugins --load` | Load each plugin to validate types and surface import errors. |
@@ -68,4 +69,3 @@ Takopis CLI is an auto-router by default; engine subcommands override the def
| `--transport <id>` | Override the configured transport backend id. | | `--transport <id>` | Override the configured transport backend id. |
| `--debug` | Write debug logs to `debug.log`. | | `--debug` | Write debug logs to `debug.log`. |
| `--final-notify/--no-final-notify` | Send the final response as a new message vs an edit. | | `--final-notify/--no-final-notify` | Send the final response as a new message vs an edit. |
+2 -1
View File
@@ -54,8 +54,9 @@ session_mode = "chat" # or "stateless"
Behavior: 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. - 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`. - Reset with `/new`.
State is stored in `telegram_chat_sessions_state.json` alongside the config file. State is stored in `telegram_chat_sessions_state.json` alongside the config file.
+88
View File
@@ -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)
+26 -21
View File
@@ -21,6 +21,9 @@ Takopi keeps running in your terminal. In Telegram, your bot will post a startup
default: codex<br> default: codex<br>
agents: codex, claude<br> agents: codex, claude<br>
projects: none<br> projects: none<br>
mode: chat<br>
topics: disabled<br>
resume lines: hidden<br>
working in: /Users/you/dev/your-project working in: /Users/you/dev/your-project
The engines/projects list reflects your setup. This tells you: 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 ## 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" !!! takopi "Takopi"
done · codex · 11s · step 5 done · codex · 11s · step 5
@@ -84,25 +96,14 @@ To follow up, **reply** to the bot's message:
!!! user "You" !!! user "You"
what command line arguments does it support? 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" !!! tip "Reply-to-continue still works in chat mode"
done · codex · 47s · step 11 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 !!! tip "Reset with /new"
`/new` clears stored sessions for the current chat or topic.
- Global/auto-router (when you run just takopi): --version, --final-notify/--no-final-notify, --onboard/--no-onboard, --transport <id>, --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 <bot_token>, --project <alias> 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.
## 6. Cancel a run ## 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`. 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 ## What just happened
Key points: Key points:
@@ -147,7 +151,7 @@ Key points:
- The agent streams JSONL events (tool calls, progress, answer) - The agent streams JSONL events (tool calls, progress, answer)
- Takopi renders these as an editable progress message - Takopi renders these as an editable progress message
- When done, the progress message is replaced with the final answer - 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 ## The core loop
@@ -156,7 +160,8 @@ You now know the three fundamental interactions:
| Action | How | | Action | How |
|--------|-----| |--------|-----|
| **Start** | Send a message to your bot | | **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 | | **Cancel** | Tap **cancel** on a progress message |
Everything else in Takopi builds on this loop. 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)** **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 ## Next
+13 -4
View File
@@ -31,7 +31,15 @@ Set up Takopi, create a Telegram bot, and generate your config.
[Start here →](install-and-onboard.md) [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. 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) [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. 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) [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. 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 ~/.takopi/takopi.toml
├── bot_token + chat_id configured ├── bot_token + chat_id configured
├── session_mode chosen
├── default_engine set ├── default_engine set
└── projects.your-repo registered └── projects.your-repo registered
``` ```
@@ -69,7 +78,7 @@ By the end of these tutorials, you'll have:
And you'll know how to: And you'll know how to:
- Send tasks from Telegram and watch progress stream - 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 - Cancel runs mid-flight
- Target specific repos and branches - Target specific repos and branches
- Switch between agents on the fly - Switch between agents on the fly
+59 -7
View File
@@ -2,7 +2,7 @@
This tutorial walks you through installing Takopi, creating a Telegram bot, and generating your config file. 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 ## 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" !!! 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. 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.<alias>.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: Takopi scans your PATH for installed agent CLIs:
``` ```
step 2: agent cli tools step 4: agent cli tools
agent status install command 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. 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: Takopi shows you a preview of what it will save:
``` ```
step 3: save configuration step 5: save configuration
~/.takopi/takopi.toml ~/.takopi/takopi.toml
@@ -186,6 +225,12 @@ step 3: save configuration
[transports.telegram] [transports.telegram]
bot_token = "123456789:ABC..." bot_token = "123456789:ABC..."
chat_id = 123456789 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) ? 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 config saved to ~/.takopi/takopi.toml
sent confirmation message
setup complete. starting takopi... setup complete. starting takopi...
``` ```
@@ -211,6 +257,12 @@ transport = "telegram" # how Takopi talks to you
[transports.telegram] [transports.telegram]
bot_token = "..." # your bot's API key bot_token = "..." # your bot's API key
chat_id = 123456789 # where to send messages 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. 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 ## 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)
+10
View File
@@ -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. 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 ## 7. Practical patterns
**Pattern: Quick questions vs. deep work** **Pattern: Quick questions vs. deep work**
+235
View File
@@ -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()
+157 -8
View File
@@ -2,10 +2,14 @@ from __future__ import annotations
import os import os
import sys import sys
from dataclasses import dataclass
from collections.abc import Callable from collections.abc import Callable
from importlib.metadata import EntryPoint from importlib.metadata import EntryPoint
from pathlib import Path from pathlib import Path
from typing import Literal
import anyio
from functools import partial
import typer import typer
from . import __version__ from . import __version__
@@ -14,12 +18,13 @@ from .config_migrations import migrate_config
from .commands import get_command from .commands import get_command
from .backends import EngineBackend from .backends import EngineBackend
from .engines import get_backend, list_backend_ids 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 .lockfile import LockError, LockHandle, acquire_lock, token_fingerprint
from .logging import get_logger, setup_logging from .logging import get_logger, setup_logging
from .runtime_loader import build_runtime_spec, resolve_plugins_allowlist from .runtime_loader import build_runtime_spec, resolve_plugins_allowlist
from .settings import ( from .settings import (
TakopiSettings, TakopiSettings,
TelegramTopicsSettings,
load_settings, load_settings,
load_settings_if_exists, load_settings_if_exists,
validate_settings_data, validate_settings_data,
@@ -37,6 +42,8 @@ from .plugins import (
from .transports import SetupResult, get_transport from .transports import SetupResult, get_transport
from .utils.git import resolve_default_base, resolve_main_worktree_root from .utils.git import resolve_default_base, resolve_main_worktree_root
from .telegram import onboarding from .telegram import onboarding
from .telegram.client import TelegramClient
from .telegram.topics import _validate_topics_setup_for
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -51,6 +58,21 @@ def _load_settings_optional() -> tuple[TakopiSettings | None, Path | None]:
return loaded 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: def _print_version_and_exit() -> None:
typer.echo(__version__) typer.echo(__version__)
raise typer.Exit() 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) 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( def _run_auto_router(
*, *,
default_engine_override: str | None, default_engine_override: str | None,
@@ -185,7 +273,7 @@ def _run_auto_router(
if not _should_run_interactive(): if not _should_run_interactive():
typer.echo("error: --onboard requires a TTY", err=True) typer.echo("error: --onboard requires a TTY", err=True)
raise typer.Exit(code=1) 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) raise typer.Exit(code=1)
( (
settings_hint, settings_hint,
@@ -207,7 +295,9 @@ def _run_auto_router(
f"{transport_backend.id}, run onboarding now?", f"{transport_backend.id}, run onboarding now?",
default=False, 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, settings_hint,
config_hint, config_hint,
@@ -219,7 +309,7 @@ def _run_auto_router(
engine_backend, engine_backend,
transport_override=transport_override, transport_override=transport_override,
) )
elif transport_backend.interactive_setup(force=False): elif anyio.run(partial(transport_backend.interactive_setup, force=False)):
( (
settings_hint, settings_hint,
config_hint, config_hint,
@@ -246,7 +336,7 @@ def _run_auto_router(
settings=settings, settings=settings,
config_path=config_path, config_path=config_path,
default_engine_override=default_engine_override, default_engine_override=default_engine_override,
reserved=("cancel",), reserved=RESERVED_CHAT_COMMANDS,
) )
if settings.transport == "telegram": if settings.transport == "telegram":
transport_config = settings.transports.telegram transport_config = settings.transports.telegram
@@ -335,7 +425,7 @@ def init(
projects_cfg = settings.to_projects_config( projects_cfg = settings.to_projects_config(
config_path=config_path, config_path=config_path,
engine_ids=engine_ids, engine_ids=engine_ids,
reserved=("cancel",), reserved=RESERVED_CHAT_COMMANDS,
) )
alias_key = alias.lower() alias_key = alias.lower()
@@ -343,7 +433,7 @@ def init(
raise ConfigError( raise ConfigError(
f"Invalid project alias {alias!r}; aliases must not match engine ids." 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( raise ConfigError(
f"Invalid project alias {alias!r}; aliases must not match reserved commands." f"Invalid project alias {alias!r}; aliases must not match reserved commands."
) )
@@ -399,7 +489,7 @@ def chat_id(
if settings is not None: if settings is not None:
tg = settings.transports.telegram tg = settings.transports.telegram
token = tg.bot_token or None 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: if chat is None:
raise typer.Exit(code=1) raise typer.Exit(code=1)
if project: if project:
@@ -438,6 +528,63 @@ def chat_id(
typer.echo(f"chat_id = {chat.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( def _print_entrypoints(
label: str, entrypoints: list[EntryPoint], *, allowlist: set[str] | None label: str, entrypoints: list[EntryPoint], *, allowlist: set[str] | None
) -> None: ) -> None:
@@ -629,6 +776,8 @@ def create_app() -> typer.Typer:
) )
app.command(name="init")(init) app.command(name="init")(init)
app.command(name="chat-id")(chat_id) 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.command(name="plugins")(plugins_cmd)
app.callback()(app_main) app.callback()(app_main)
for engine_id in _engine_ids_for_cli(): for engine_id in _engine_ids_for_cli():
+2 -1
View File
@@ -7,6 +7,7 @@ from collections.abc import Awaitable, Callable, Iterable
from watchfiles import awatch from watchfiles import awatch
from .config import ConfigError from .config import ConfigError
from .ids import RESERVED_CHAT_COMMANDS
from .logging import get_logger from .logging import get_logger
from .runtime_loader import RuntimeSpec, build_runtime_spec from .runtime_loader import RuntimeSpec, build_runtime_spec
from .settings import TakopiSettings, load_settings from .settings import TakopiSettings, load_settings
@@ -71,7 +72,7 @@ async def watch_config(
config_path: Path, config_path: Path,
runtime: TransportRuntime, runtime: TransportRuntime,
default_engine_override: str | None = None, default_engine_override: str | None = None,
reserved: Iterable[str] = ("cancel",), reserved: Iterable[str] = RESERVED_CHAT_COMMANDS,
on_reload: Callable[[ConfigReload], Awaitable[None]] | None = None, on_reload: Callable[[ConfigReload], Awaitable[None]] | None = None,
) -> None: ) -> None:
reserved_tuple = tuple(reserved) reserved_tuple = tuple(reserved)
+2 -2
View File
@@ -5,8 +5,8 @@ import re
ID_PATTERN = r"^[a-z0-9_]{1,32}$" ID_PATTERN = r"^[a-z0-9_]{1,32}$"
_ID_RE = re.compile(ID_PATTERN) _ID_RE = re.compile(ID_PATTERN)
RESERVED_CLI_COMMANDS = frozenset({"init", "plugins"}) RESERVED_CLI_COMMANDS = frozenset({"init", "plugins", "doctor"})
RESERVED_CHAT_COMMANDS = frozenset({"cancel", "file"}) RESERVED_CHAT_COMMANDS = frozenset({"cancel", "file", "new", "agent", "topic", "ctx"})
RESERVED_ENGINE_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS RESERVED_ENGINE_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS
RESERVED_COMMAND_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS RESERVED_COMMAND_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS
+2 -1
View File
@@ -9,6 +9,7 @@ from collections.abc import Iterable, Mapping
from .backends import EngineBackend from .backends import EngineBackend
from .config import ConfigError, ProjectsConfig from .config import ConfigError, ProjectsConfig
from .engines import get_backend, list_backend_ids from .engines import get_backend, list_backend_ids
from .ids import RESERVED_CHAT_COMMANDS
from .logging import get_logger from .logging import get_logger
from .router import AutoRouter, EngineStatus, RunnerEntry from .router import AutoRouter, EngineStatus, RunnerEntry
from .settings import TakopiSettings from .settings import TakopiSettings
@@ -171,7 +172,7 @@ def build_runtime_spec(
settings: TakopiSettings, settings: TakopiSettings,
config_path: Path, config_path: Path,
default_engine_override: str | None = None, default_engine_override: str | None = None,
reserved: Iterable[str] = ("cancel",), reserved: Iterable[str] = RESERVED_CHAT_COMMANDS,
) -> RuntimeSpec: ) -> RuntimeSpec:
allowlist = resolve_plugins_allowlist(settings) allowlist = resolve_plugins_allowlist(settings)
engine_ids = list_backend_ids(allowlist=allowlist) engine_ids = list_backend_ids(allowlist=allowlist)
+26 -3
View File
@@ -2,13 +2,14 @@ from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
from typing import Literal
import anyio import anyio
from ..backends import EngineBackend from ..backends import EngineBackend
from ..logging import get_logger from ..logging import get_logger
from ..runner_bridge import ExecBridgeConfig from ..runner_bridge import ExecBridgeConfig
from ..settings import TelegramTransportSettings from ..settings import TelegramTopicsSettings, TelegramTransportSettings
from ..transport_runtime import TransportRuntime from ..transport_runtime import TransportRuntime
from ..transports import SetupResult, TransportBackend from ..transports import SetupResult, TransportBackend
from .bridge import ( from .bridge import (
@@ -19,6 +20,7 @@ from .bridge import (
) )
from .client import TelegramClient from .client import TelegramClient
from .onboarding import check_setup, interactive_setup from .onboarding import check_setup, interactive_setup
from .topics import _resolve_topics_scope_raw
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -33,6 +35,10 @@ def _build_startup_message(
runtime: TransportRuntime, runtime: TransportRuntime,
*, *,
startup_pwd: str, startup_pwd: str,
chat_id: int,
session_mode: Literal["stateless", "chat"],
show_resume_line: bool,
topics: TelegramTopicsSettings,
) -> str: ) -> str:
available_engines = list(runtime.available_engine_ids()) available_engines = list(runtime.available_engine_ids())
missing_engines = list(runtime.missing_engine_ids()) missing_engines = list(runtime.missing_engine_ids())
@@ -52,11 +58,24 @@ def _build_startup_message(
engine_list = f"{engine_list} ({'; '.join(notes)})" engine_list = f"{engine_list} ({'; '.join(notes)})"
project_aliases = sorted(set(runtime.project_aliases()), key=str.lower) project_aliases = sorted(set(runtime.project_aliases()), key=str.lower)
project_list = ", ".join(project_aliases) if project_aliases else "none" 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 ( return (
f"\N{OCTOPUS} **takopi is ready**\n\n" f"\N{OCTOPUS} **takopi is ready**\n\n"
f"default: `{runtime.default_engine}` \n" f"default: `{runtime.default_engine}` \n"
f"agents: `{engine_list}` \n" f"agents: `{engine_list}` \n"
f"projects: `{project_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}`" f"working in: `{startup_pwd}`"
) )
@@ -73,8 +92,8 @@ class TelegramBackend(TransportBackend):
) -> SetupResult: ) -> SetupResult:
return check_setup(engine_backend, transport_override=transport_override) return check_setup(engine_backend, transport_override=transport_override)
def interactive_setup(self, *, force: bool) -> bool: async def interactive_setup(self, *, force: bool) -> bool:
return interactive_setup(force=force) return await interactive_setup(force=force)
def lock_token(self, *, transport_config: object, _config_path: Path) -> str | None: def lock_token(self, *, transport_config: object, _config_path: Path) -> str | None:
settings = _expect_transport_settings(transport_config) settings = _expect_transport_settings(transport_config)
@@ -95,6 +114,10 @@ class TelegramBackend(TransportBackend):
startup_msg = _build_startup_message( startup_msg = _build_startup_message(
runtime, runtime,
startup_pwd=os.getcwd(), 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) bot = TelegramClient(token)
transport = TelegramTransport(bot) transport = TelegramTransport(bot)
+11 -2
View File
@@ -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 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: def is_cancel_command(text: str) -> bool:
+26 -2
View File
@@ -18,7 +18,10 @@ _MAX_BOT_COMMANDS = 100
def build_bot_commands( def build_bot_commands(
runtime: TransportRuntime, *, include_file: bool = True runtime: TransportRuntime,
*,
include_file: bool = True,
include_topics: bool = False,
) -> list[dict[str, str]]: ) -> list[dict[str, str]]:
commands: list[dict[str, str]] = [] commands: list[dict[str, str]] = []
seen: set[str] = set() seen: set[str] = set()
@@ -67,6 +70,23 @@ def build_bot_commands(
description = backend.description or f"command: {cmd}" description = backend.description or f"command: {cmd}"
commands.append({"command": cmd, "description": description}) commands.append({"command": cmd, "description": description})
seen.add(cmd) 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: if include_file and "file" not in seen:
commands.append({"command": "file", "description": "upload or fetch files"}) commands.append({"command": "file", "description": "upload or fetch files"})
seen.add("file") seen.add("file")
@@ -93,7 +113,11 @@ def _reserved_commands(runtime: TransportRuntime) -> set[str]:
async def _set_command_menu(cfg: TelegramBridgeConfig) -> None: 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: if not commands:
return return
try: try:
File diff suppressed because it is too large Load Diff
+36 -11
View File
@@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ..config import ConfigError from ..config import ConfigError
from ..context import RunContext from ..context import RunContext
from ..settings import TelegramTopicsSettings
from ..transport_runtime import TransportRuntime from ..transport_runtime import TransportRuntime
from .client import BotClient
from .topic_state import TopicStateStore, TopicThreadSnapshot from .topic_state import TopicStateStore, TopicThreadSnapshot
from .types import TelegramIncomingMessage from .types import TelegramIncomingMessage
@@ -28,18 +31,25 @@ __all__ = [
_TOPICS_COMMANDS = {"ctx", "new", "topic"} _TOPICS_COMMANDS = {"ctx", "new", "topic"}
def _resolve_topics_scope(cfg: TelegramBridgeConfig) -> tuple[str, frozenset[int]]: def _resolve_topics_scope_raw(
scope = cfg.topics.scope scope: str, chat_id: int, project_chat_ids: Iterable[int]
project_ids = set(cfg.runtime.project_chat_ids()) ) -> tuple[str, frozenset[int]]:
project_ids = set(project_chat_ids)
if scope == "auto": if scope == "auto":
scope = "projects" if project_ids else "main" scope = "projects" if project_ids else "main"
if scope == "main": if scope == "main":
return scope, frozenset({cfg.chat_id}) return scope, frozenset({chat_id})
if scope == "projects": if scope == "projects":
return scope, frozenset(project_ids) return scope, frozenset(project_ids)
if scope == "all": if scope == "all":
return scope, frozenset({cfg.chat_id, *project_ids}) return scope, frozenset({chat_id, *project_ids})
raise ValueError(f"Invalid topics.scope: {cfg.topics.scope!r}") 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: 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: 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 return
me = await cfg.bot.get_me() me = await bot.get_me()
if me is None: if me is None:
raise ConfigError("failed to fetch bot id for topics validation.") raise ConfigError("failed to fetch bot id for topics validation.")
bot_id = me.id 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: if scope == "projects" and not chat_ids:
raise ConfigError( raise ConfigError(
"topics enabled but no project chats are configured; " "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: 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: if chat is None:
raise ConfigError( raise ConfigError(
f"failed to fetch chat info for topics validation ({chat_id})." 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 " "topics enabled but chat does not have topics enabled "
f"(chat_id={chat_id}); turn on topics in group settings." 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: if member is None:
raise ConfigError( raise ConfigError(
"failed to fetch bot permissions " "failed to fetch bot permissions "
+1 -1
View File
@@ -32,7 +32,7 @@ class TransportBackend(Protocol):
transport_override: str | None = None, transport_override: str | None = None,
) -> SetupResult: ... ) -> SetupResult: ...
def interactive_setup(self, *, force: bool) -> bool: ... async def interactive_setup(self, *, force: bool) -> bool: ...
def lock_token( def lock_token(
self, *, transport_config: object, _config_path: Path self, *, transport_config: object, _config_path: Path
+2 -2
View File
@@ -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("takopi.config.HOME_CONFIG_PATH", config_path)
monkeypatch.setattr(cli, "_load_settings_optional", lambda: (None, None)) 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" assert token == "token"
return onboarding.ChatInfo( return onboarding.ChatInfo(
chat_id=123, 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"))) 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" assert token == "config-token"
return onboarding.ChatInfo( return onboarding.ChatInfo(
chat_id=321, chat_id=321,
+52
View File
@@ -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
+146 -128
View File
@@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
import anyio
from functools import partial
from takopi.backends import EngineBackend from takopi.backends import EngineBackend
from takopi.config import dump_toml from takopi.config import dump_toml
from takopi.telegram import onboarding from takopi.telegram import onboarding
@@ -39,32 +42,56 @@ def test_render_config_escapes() -> None:
assert config.endswith("\n") assert config.endswith("\n")
class _FakeQuestion: class FakeQuestion:
def __init__(self, value): def __init__(self, value):
self._value = value self._value = value
def ask(self): def ask(self):
return self._value return self._value
async def ask_async(self):
return self._value
def _queue(values):
def queue_answers(values):
it = iter(values) it = iter(values)
def _make(*_args, **_kwargs): def _make(*_args, **_kwargs):
return _FakeQuestion(next(it)) return FakeQuestion(next(it))
return _make return _make
def _queue_values(values): def queue_values(values):
it = iter(values) it = iter(values)
def _next(*_args, **_kwargs): async def _next(*_args, **_kwargs):
return next(it) return next(it)
return _next 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: def test_interactive_setup_skips_when_config_exists(monkeypatch, tmp_path) -> None:
config_path = tmp_path / "takopi.toml" config_path = tmp_path / "takopi.toml"
config_path.write_text( config_path.write_text(
@@ -73,7 +100,7 @@ def test_interactive_setup_skips_when_config_exists(monkeypatch, tmp_path) -> No
encoding="utf-8", encoding="utf-8",
) )
monkeypatch.setattr(onboarding, "HOME_CONFIG_PATH", config_path) 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: 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, "list_backends", lambda: [backend])
monkeypatch.setattr(onboarding.shutil, "which", lambda _cmd: "/usr/bin/codex") 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( 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): assert anyio.run(partial(onboarding.interactive_setup, force=False)) is True
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
saved = config_path.read_text(encoding="utf-8") saved = config_path.read_text(encoding="utf-8")
assert 'transport = "telegram"' in saved assert 'transport = "telegram"' in saved
assert "[transports.telegram]" in saved assert "[transports.telegram]" in saved
assert 'bot_token = "123456789:ABCdef"' in saved assert 'bot_token = "123456789:ABCdef"' in saved
assert "chat_id = 123" 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 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, "list_backends", lambda: [backend])
monkeypatch.setattr(onboarding.shutil, "which", lambda _cmd: "/usr/bin/codex") 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( 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): assert anyio.run(partial(onboarding.interactive_setup, force=True)) is True
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
saved = config_path.read_text(encoding="utf-8") saved = config_path.read_text(encoding="utf-8")
assert "[projects.z80]" in saved assert "[projects.z80]" in saved
assert 'path = "/tmp/repo"' 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, "list_backends", lambda: [backend])
monkeypatch.setattr(onboarding.shutil, "which", lambda _cmd: None) 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( 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): assert anyio.run(partial(onboarding.interactive_setup, force=False)) is False
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 not config_path.exists() 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, "list_backends", lambda: [backend])
monkeypatch.setattr(onboarding.shutil, "which", lambda _cmd: "/usr/bin/codex") 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( 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): assert anyio.run(partial(onboarding.interactive_setup, force=True)) is True
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
backup = config_path.with_suffix(".toml.bak") backup = config_path.with_suffix(".toml.bak")
assert backup.exists() assert backup.exists()
assert backup.read_text(encoding="utf-8") == bad_toml 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 test_capture_chat_id_with_token(monkeypatch) -> None:
def _fake_run(func, *args, **kwargs): patch_live_services(
if func is onboarding.get_bot_info: monkeypatch,
return User(id=1, username="my_bot") bot=User(id=1, username="my_bot"),
if func is onboarding.wait_for_chat: chat=onboarding.ChatInfo(
return onboarding.ChatInfo( chat_id=456,
chat_id=456, username=None,
username=None, title="takopi",
title="takopi", first_name=None,
first_name=None, last_name=None,
last_name=None, chat_type="supergroup",
chat_type="supergroup", ),
) )
raise AssertionError(f"unexpected anyio.run target: {func}")
monkeypatch.setattr(onboarding.anyio, "run", _fake_run) chat = anyio.run(partial(onboarding.capture_chat_id, token="123456789:ABCdef"))
chat = onboarding.capture_chat_id(token="123456789:ABCdef")
assert chat is not None assert chat is not None
assert chat.chat_id == 456 assert chat.chat_id == 456
def test_capture_chat_id_prompts_for_token(monkeypatch) -> None: def test_capture_chat_id_prompts_for_token(monkeypatch) -> None:
monkeypatch.setattr( async def _prompt_token(_ui, _svc):
onboarding, return ("token", User(id=1, username="bot"))
"_prompt_token",
lambda _console: ("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): chat = anyio.run(onboarding.capture_chat_id)
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()
assert chat is not None assert chat is not None
assert chat.chat_id == 789 assert chat.chat_id == 789
+7 -6
View File
@@ -5,6 +5,7 @@ from typer.testing import CliRunner
from takopi import cli from takopi import cli
from takopi.config import ConfigError, read_config from takopi.config import ConfigError, read_config
from takopi.ids import RESERVED_CHAT_COMMANDS
from takopi.settings import TakopiSettings from takopi.settings import TakopiSettings
@@ -19,7 +20,7 @@ def test_parse_projects_rejects_engine_alias() -> None:
settings.to_projects_config( settings.to_projects_config(
config_path=Path("takopi.toml"), config_path=Path("takopi.toml"),
engine_ids=["codex"], 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( settings.to_projects_config(
config_path=Path("takopi.toml"), config_path=Path("takopi.toml"),
engine_ids=["codex"], engine_ids=["codex"],
reserved=("cancel",), reserved=RESERVED_CHAT_COMMANDS,
) )
@@ -94,7 +95,7 @@ def test_projects_default_engine_unknown() -> None:
settings.to_projects_config( settings.to_projects_config(
config_path=Path("takopi.toml"), config_path=Path("takopi.toml"),
engine_ids=["codex"], 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( settings.to_projects_config(
config_path=Path("takopi.toml"), config_path=Path("takopi.toml"),
engine_ids=["codex"], 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( settings.to_projects_config(
config_path=Path("takopi.toml"), config_path=Path("takopi.toml"),
engine_ids=["codex"], 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( projects = settings.to_projects_config(
config_path=config_path, config_path=config_path,
engine_ids=["codex"], engine_ids=["codex"],
reserved=("cancel",), reserved=RESERVED_CHAT_COMMANDS,
) )
assert projects.projects["z80"].path == config_path.parent / "repo" assert projects.projects["z80"].path == config_path.parent / "repo"
+12 -2
View File
@@ -41,7 +41,12 @@ def test_build_startup_message_includes_missing_engines(tmp_path: Path) -> None:
) )
message = telegram_backend._build_startup_message( 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 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( 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 assert "agents: `codex" in message
+17
View File
@@ -383,6 +383,8 @@ def test_build_bot_commands_includes_cancel_and_engine() -> None:
assert {"command": "cancel", "description": "cancel run"} in commands assert {"command": "cancel", "description": "cancel run"} in commands
assert {"command": "file", "description": "upload or fetch files"} in commands assert {"command": "file", "description": "upload or fetch files"} in commands
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) 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) 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: def test_build_bot_commands_includes_command_plugins(monkeypatch) -> None:
class _Command: class _Command:
id = "pingcmd" id = "pingcmd"
+1 -1
View File
@@ -12,7 +12,7 @@ class DummyTransport:
def check_setup(self, *args, **kwargs): def check_setup(self, *args, **kwargs):
raise NotImplementedError raise NotImplementedError
def interactive_setup(self, *, force: bool) -> bool: async def interactive_setup(self, *, force: bool) -> bool:
raise NotImplementedError raise NotImplementedError
def lock_token(self, *, transport_config: object, _config_path): def lock_token(self, *, transport_config: object, _config_path):
+3 -1
View File
@@ -111,7 +111,6 @@ features = [
"content.code.copy", "content.code.copy",
"content.action.edit", "content.action.edit",
"content.action.view", "content.action.view",
"search.highlight",
] ]
[project.theme.icon] [project.theme.icon]
@@ -154,6 +153,9 @@ emoji_index = "zensical.extensions.emoji.twemoji"
emoji_generator = "zensical.extensions.emoji.to_svg" emoji_generator = "zensical.extensions.emoji.to_svg"
options.custom_icons = ["docs/overrides/.icons"] options.custom_icons = ["docs/overrides/.icons"]
[project.markdown_extensions.pymdownx.tasklist]
custom_checkbox = true
[project.markdown_extensions.toc] [project.markdown_extensions.toc]
permalink = true permalink = true