feat: onboarding overhaul, persona-based setup (#132)
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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)
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ Takopi’s 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 @@ Takopi’s 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. |
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -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
@@ -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():
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+789
-208
File diff suppressed because it is too large
Load Diff
@@ -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 "
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user