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)
- 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)
+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)
- [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
+63 -29
View File
@@ -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.<alias>.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.<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` | 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
```
/topic <project> @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 <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
- 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)
+7
View File
@@ -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.
+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).
## 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
<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).
- [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**
+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 chat-id` | Capture the current chat id. |
| `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 --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. |
| `--debug` | Write debug logs to `debug.log`. |
| `--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:
- 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.
+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>
agents: codex, claude<br>
projects: none<br>
mode: chat<br>
topics: disabled<br>
resume lines: hidden<br>
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 <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.
!!! 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
+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)
### 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
+59 -7
View File
@@ -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.<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:
```
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)
+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.
!!! 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**
+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 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():
+2 -1
View File
@@ -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)
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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)
+26 -3
View File
@@ -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)
+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
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:
+26 -2
View File
@@ -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:
File diff suppressed because it is too large Load Diff
+36 -11
View File
@@ -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 "
+1 -1
View File
@@ -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
+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(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,
+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
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
+7 -6
View File
@@ -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"
+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(
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
+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": "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"
+1 -1
View File
@@ -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):
+3 -1
View File
@@ -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