Compare commits

..
29 Commits
Author SHA1 Message Date
Yuyao Huang 72ebf3b75d feat(question): implement AskUserQuestion tool support
- Add question card builder and answer handling in feishu.py
- Extend SDKSession with pending question state and answer method
- Update card callback handler to support question answers
- Add test cases for question flow and card responses
- Document usage with test_can_use_tool_ask.py example
2026-04-02 08:52:50 +08:00
Yuyao Huang 26746335c4 refactor(commands): rename status command to list and update related references
feat(feishu): update card schema to 2.0 and simplify approval card structure

docs(feishu): add documentation for card json schema 2.0 changes
2026-04-01 14:50:30 +08:00
Yuyao Huang 44bd1a4300 feat(handler): implement card callback response with toast and updated card
Add visual feedback for card actions by returning toast messages and updated cards per Feishu requirements. The handler now responds within 3 seconds with appropriate success/warning toasts and card updates showing approval/denial status. Also added operator tracking in logs and comprehensive tests.
2026-04-01 13:22:41 +08:00
Yuyao Huang eac90941ef feat: add SDK session implementation with approval flow and audit logging
- Implement SDK session with secretary model for tool approval flow
- Add audit logging for tool usage and permission decisions
- Support Feishu card interactions for approval requests
- Add new commands for task interruption and progress checking
- Remove old test files and update documentation
2026-04-01 12:51:00 +08:00
Yuyao Huang ba1b5b76c6 docs(claude): add SDK documentation and test examples
Add comprehensive documentation for Claude Agent SDK including usage patterns, test examples, and development guide. Includes:
- CLAUDE.md with project structure and quick reference
- SDK test examples for query, client, and hooks
- Shared test utilities for auth and tmpdir management
- Detailed SDK overview and capabilities documentation
2026-04-01 08:18:33 +08:00
Yuyao Huang 9c1fc7e5b8 docs: add claude agent sdk documentation
Add comprehensive documentation for the Claude Agent SDK, including:
- Overview and quickstart guide
- Installation and setup instructions
- Detailed feature descriptions (tools, hooks, subagents, etc.)
- Comparison with other Claude tools
- Branding guidelines and licensing information
2026-03-31 09:13:49 +08:00
Yuyao Huang (Sam)andClaude Sonnet 4.6 b707fa84f9 feat: add default permission mode + rename accept→edit
- Add "default" permission mode: no flags passed to CC, uses its own
  built-in default (asks about everything). Now the system default.
- Rename "accept" alias to "edit" (acceptEdits) for clarity
- Remove "skip" alias (duplicate of "bypass")
- Update all help text, error messages, tests, and LLM prompts

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 01:54:32 +08:00
Yuyao Huang (Sam) 9c04d47c8e docs: 更新命令前缀从/改为//
更新所有文档和代码中的命令前缀,从单斜杠`/`改为双斜杠`//`,以保持一致性
2026-03-30 01:40:53 +08:00
Yuyao Huang (Sam) 3ca492634d refactor(feishu): 将send_text改为send_markdown的别名
所有消息现在都通过markdown卡片发送,简化了消息发送逻辑
2026-03-30 01:36:52 +08:00
Yuyao Huang (Sam) f4249e5b0d fix(agent): 在passthrough模式下添加direct参数以绕过超时检查
当启用passthrough模式时,通过direct参数跳过60秒超时检查,确保消息直接发送而不受限制
2026-03-30 01:19:40 +08:00
Yuyao Huang (Sam)andClaude Sonnet 4.6 1b2bb8cdc2 refactor: rename pty_process to cc_runner
The module has long since moved away from PTY technology, using
claude -p with --output-format stream-json and --resume instead.
Rename to cc_runner to accurately reflect what it does.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 00:55:19 +08:00
Yuyao Huang (Sam) a278ece348 feat: 添加可配置的命令前缀以避免与Claude Code冲突
引入可配置的COMMAND_PREFIX参数,默认设置为"//"以避免与Claude Code的"/"命令冲突
修改相关文件以支持新的命令前缀,包括配置解析、路由逻辑和命令处理
更新帮助文档和提示信息以反映新的命令前缀
2026-03-30 00:11:41 +08:00
Yuyao Huang (Sam) ed70588d95 docs: 添加飞书 Bot 开发指南文档
添加详细的飞书 Bot 开发指南文档,涵盖 SDK 初始化、消息收发、文件上传、WebSocket 长连接等实战经验
2026-03-29 19:59:31 +08:00
Yuyao Huang (Sam) 5a9fe871fe feat(router): 在路由模式下仅处理特定命令
在路由模式下,本地仅处理路由相关命令(/nodes, /node, /help等),其他会话命令直接转发到节点。同时优化路由代理逻辑,对于会话命令直接转发到活跃节点,减少不必要的LLM调用。
2026-03-29 19:11:56 +08:00
Yuyao Huang (Sam) c6e38026ec feat(消息通知): 添加对markdown格式消息的支持
新增send_markdown函数用于发送富文本格式消息,替换原有send_text调用以支持代码块、标题等格式内容显示。自动分割长消息为多个卡片发送,提升消息展示效果。
2026-03-29 18:38:23 +08:00
Yuyao Huang (Sam) 28e0fe7c27 refactor(agent): 移除未使用的通用问题检测逻辑
清理不再使用的通用问题检测函数及相关正则表达式模式,简化代码结构
2026-03-29 18:29:18 +08:00
Yuyao Huang (Sam) 88b7eabe14 feat(节点跟踪): 改进用户跟踪功能并添加新用户通知
当新用户首次发送消息时,返回布尔值并通知可用节点。在handler.py中添加对新用户的处理逻辑,当检测到新用户时,向其发送当前在线的节点列表。这有助于新用户更快了解可用资源。
2026-03-29 18:14:46 +08:00
Yuyao Huang (Sam) 2a8f745b3d feat(router): 添加用户追踪和节点通知功能
在ROUTER_MODE启用时跟踪用户消息,并在节点注册/注销时通知相关用户。新增_known_users集合记录活跃用户,重构通知逻辑以支持所有已知用户或特定服务用户的通知。
2026-03-29 18:11:00 +08:00
Yuyao Huang (Sam) ed7bbb1497 feat(router): 添加获取服务于所有用户的节点功能
修改 NodeRegistry 的 get_nodes_for_user 方法,除了获取显式映射到用户的节点外,现在还会包含服务于所有用户的节点(serves_users 为空的节点)。这确保了系统能正确处理全局可用的服务节点。
2026-03-29 17:00:06 +08:00
Yuyao Huang (Sam) c0ecea9d3a refactor: 将应用实例创建移至模块级别并优化主程序
将应用实例创建从主程序移至模块级别,避免重复创建实例
主程序现在直接使用模块级别的应用实例,简化了启动逻辑
2026-03-29 16:50:55 +08:00
Yuyao Huang (Sam) 9173aa6094 refactor(router): 重构项目结构,将主入口移至router/main.py
删除旧的main.py文件,将FastAPI应用创建逻辑集中在router/main.py中
添加直接运行router/main.py的支持
更新README.md以反映新的项目结构和使用方式
添加websocket连接测试脚本test_websocket.py
2026-03-29 16:46:23 +08:00
Yuyao Huang (Sam) 71e3f14788 feat(config): 支持从host_config.yaml加载配置
当host_config.yaml存在时优先使用该文件,否则回退到keyring.yaml
2026-03-29 16:18:41 +08:00
Yuyao Huang (Sam) a7ea6307e7 build: 移除不再需要的pywinpty依赖 2026-03-29 16:11:17 +08:00
Yuyao Huang (Sam) b36acf65ca refactor: 将端口配置从硬编码改为从配置文件读取
将应用端口从硬编码的8000改为从config.py读取PORT配置,提高配置灵活性
更新了README.md和keyring.example.yaml以说明端口配置
同时在standalone.py中也使用配置的端口值
2026-03-29 15:39:45 +08:00
Yuyao Huang (Sam) 4ea15d4612 docs: 添加主机客户端配置示例文件并更新README
添加host_config.example.yaml作为配置模板
在README中补充配置说明,指导用户复制模板文件
2026-03-29 15:33:13 +08:00
Yuyao Huang (Sam) 8591467592 docs(README): 更新WORKING_DIR字段的使用说明
在独立模式和路由模式下,WORKING_DIR字段的使用方式不同。添加注释说明该字段仅在独立模式下生效,而在路由模式下会被忽略。
2026-03-29 15:04:25 +08:00
Yuyao Huang (Sam) d6183594d6 feat(handler): 添加消息去重功能防止飞书重复投递
实现基于(user_id, content)的消息去重机制,避免飞书在网络抖动时重复投递相同消息。使用10秒时间窗口判断重复消息,超过窗口的旧记录会被自动清理。
2026-03-29 07:10:58 +08:00
Yuyao Huang (Sam) cbeafa35a5 feat(perm): 添加会话权限模式管理功能
实现会话权限模式管理功能,包括:
1. 在 pty_process 中定义三种权限模式标志
2. 添加 /perm 命令用于修改会话权限模式
3. 新增 run_command 工具用于执行 bot 控制命令
4. 在会话管理中支持权限模式设置
5. 添加完整的测试用例和文档说明
2026-03-29 06:46:45 +08:00
Yuyao Huang (Sam) 8dab229aaf feat: 添加测试框架及功能测试用例
test: 实现BDD测试框架及功能测试
docs: 添加测试配置文件及文档
refactor: 重构命令处理逻辑以支持测试
2026-03-29 04:24:27 +08:00
42 changed files with 5322 additions and 464 deletions
+52
View File
@@ -0,0 +1,52 @@
# PhoneWork — Development Guide
## Quick Reference
| Need | Where to look |
|------|---------------|
| Project architecture, deployment, bot commands | `README.md` |
| Claude Agent SDK usage patterns and tested examples | `docs/claude/` |
| SDK migration plan (subprocess → ClaudeSDKClient) | `.claude/plans/toasty-pondering-nova.md` |
| SDK session implementation (secretary model) | `agent/sdk_session.py` |
| Feishu card / markdown formatting | `docs/feishu/` |
## Claude Agent SDK
When writing code that uses `claude-agent-sdk`, **first read `docs/claude/`**:
- `_sdk_test_common.py` — .env auth loading pattern (`setup_auth()`, `auth_env()`, `make_tmpdir()`)
- `test_query_read_edit.py``query()` one-shot: Read + Edit with `allowed_tools`
- `test_client_write_resume.py``ClaudeSDKClient`: Write + session resume via `resume=session_id`
- `test_hooks_audit_deny.py` — Hooks: `PostToolUse` audit + `PreToolUse` deny
- `test_can_use_tool_ask.py``can_use_tool`: intercept `AskUserQuestion`, pre-fill answers via `updated_input`
### Key rules (verified by testing)
- Use `allowed_tools` for permission auto-approval. **Do not pass custom lists to `tools=`** — it causes CLI exit code 1.
- Auth: set `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` in `.env`; pass via `ClaudeAgentOptions(env=auth_env())`. Clear `ANTHROPIC_API_KEY` to avoid auth conflicts.
- SDK does not strip ANSI escape codes from `ResultMessage.result` — handle in application layer if needed.
- On Windows, use manual `make_tmpdir()` / `remove_tmpdir()` instead of `tempfile.TemporaryDirectory()` context manager to avoid cleanup races with CLI subprocesses.
## Project Structure
```
bot/ Feishu event handling, commands, message sending
orchestrator/ LangChain agent + tools (session management, shell, files, web)
agent/ SDK session (secretary model), session manager, hooks, audit
router/ Multi-host routing (public VPS side)
host_client/ Host client (behind NAT, connects to router)
shared/ Wire protocol for router ↔ host communication
docs/claude/ Claude Agent SDK examples and reference tests
docs/feishu/ Feishu API reference
```
## Running Tests
```bash
cd docs/claude
../../.venv/Scripts/python test_query_read_edit.py
../../.venv/Scripts/python test_client_write_resume.py
../../.venv/Scripts/python test_hooks_audit_deny.py
```
Requires `.env` at project root with `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN`.
+44 -33
View File
@@ -41,9 +41,8 @@ PhoneWork uses a **Router + Host Client** architecture that supports both single
| Module | Purpose |
|--------|---------|
| `standalone.py` | Single-process entry point: runs router + host client together |
| `main.py` | FastAPI entry point for router-only mode |
| `router/main.py` | FastAPI app factory, mounts `//ws/node` endpoint, can be run directly |
| `shared/protocol.py` | Wire protocol for router-host communication |
| `router/main.py` | FastAPI app factory, mounts `/ws/node` endpoint |
| `router/nodes.py` | Node registry, connection management, user-to-node mapping |
| `router/ws.py` | WebSocket endpoint for host clients, heartbeat, message routing |
| `router/rpc.py` | Request correlation with asyncio.Future, timeout handling |
@@ -52,11 +51,11 @@ PhoneWork uses a **Router + Host Client** architecture that supports both single
| `host_client/config.py` | Host client configuration loader |
| `bot/handler.py` | Receives Feishu events via long-connection WebSocket |
| `bot/feishu.py` | Sends text/file replies back to Feishu |
| `bot/commands.py` | Slash command handler (`/new`, `/status`, `/shell`, `/remind`, `/tasks`, `/nodes`, `/node`) |
| `bot/commands.py` | Slash command handler (`//new`, `//status`, `//shell`, `//remind`, `//tasks`, `//nodes`, `//node`) |
| `orchestrator/agent.py` | LangChain agent with per-user history + direct/smart mode + direct Q&A |
| `orchestrator/tools.py` | Tools: session management, shell, file ops, web search, scheduler, task status |
| `agent/manager.py` | Session registry with persistence, idle timeout, and auto-background tasks |
| `agent/pty_process.py` | Runs `claude -p` headlessly, manages session continuity via `--resume` |
| `agent/cc_runner.py` | Runs `claude -p` headlessly, manages session continuity via `--resume` |
| `agent/task_runner.py` | Background task runner with Feishu notifications |
| `agent/scheduler.py` | Reminder scheduler with persistence |
| `agent/audit.py` | Audit log of all interactions |
@@ -139,7 +138,14 @@ OPENAI_BASE_URL: https://open.bigmodel.cn/api/paas/v4/
OPENAI_API_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
OPENAI_MODEL: glm-4.7
# Server configuration
# Only used in router mode (python -m router.main) or standalone mode (python standalone.py)
# Default: 8000
PORT: 8000
# Root directory for all project sessions (absolute path)
# Only used in standalone mode (python standalone.py)
# In router mode (python -m router.main), this field is ignored
WORKING_DIR: C:/Users/yourname/projects
# Allowlist of Feishu open_ids that may use the bot.
@@ -152,13 +158,18 @@ ALLOWED_OPEN_IDS:
METASO_API_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Optional: Multi-host mode configuration
# Set ROUTER_MODE to true to enable router mode (deploy on public VPS)
ROUTER_MODE: false
# Set ROUTER_SECRET for authentication between router and host clients
ROUTER_SECRET: your-shared-secret-for-router-host-auth
```
### Host Client Configuration (for multi-host mode)
Copy and fill in credentials:
```bash
cp host_config.example.yaml host_config.yaml
```
Create `host_config.yaml` on each host client machine:
```yaml
@@ -239,8 +250,8 @@ python standalone.py
**Router** (public VPS or any reachable machine — runs the Feishu bot):
```bash
# keyring.yaml must have ROUTER_MODE: true and ROUTER_SECRET set
python main.py
# keyring.yaml must have ROUTER_SECRET set
python -m router.main
```
**Host client** (your dev machine behind NAT — runs Claude Code):
@@ -270,30 +281,30 @@ GET /health
| Command | Description |
|---------|-------------|
| `/new <dir> [msg]` | Create a new Claude Code session in `<dir>` |
| `/new <dir> [msg] --timeout N` | Create with custom CC timeout (seconds) |
| `/new <dir> [msg] --idle N` | Create with custom idle timeout (seconds) |
| `/status` | Show your sessions and current mode |
| `/switch <n>` | Switch active session to number `<n>` from `/status` |
| `/close [n]` | Close active session (or session `<n>`) |
| `/direct` | Direct mode: messages go straight to Claude Code (no LLM overhead) |
| `/smart` | Smart mode: messages go through LLM for intelligent routing (default) |
| `/shell <cmd>` | Run a shell command directly (bypasses LLM) |
| `/remind <time> <msg>` | Set a reminder (e.g., `/remind 10m check build`) |
| `/tasks` | List background tasks with status |
| `/nodes` | List connected host nodes (multi-host mode) |
| `/node <name>` | Switch active node (multi-host mode) |
| `/help` | Show command reference |
| `//new <dir> [msg]` | Create a new Claude Code session in `<dir>` |
| `//new <dir> [msg] --timeout N` | Create with custom CC timeout (seconds) |
| `//new <dir> [msg] --idle N` | Create with custom idle timeout (seconds) |
| `//status` | Show your sessions and current mode |
| `//switch <n>` | Switch active session to number `<n>` from `//status` |
| `//close [n]` | Close active session (or session `<n>`) |
| `//direct` | Direct mode: messages go straight to Claude Code (no LLM overhead) |
| `//smart` | Smart mode: messages go through LLM for intelligent routing (default) |
| `//shell <cmd>` | Run a shell command directly (bypasses LLM) |
| `//remind <time> <msg>` | Set a reminder (e.g., `//remind 10m check build`) |
| `//tasks` | List background tasks with status |
| `//nodes` | List connected host nodes (multi-host mode) |
| `//node <name>` | Switch active node (multi-host mode) |
| `//help` | Show command reference |
### Message Routing Modes
**Smart mode (default):** Messages are analyzed by the LLM, which decides whether to create a new session, send to an existing one, or ask for clarification. Useful when you want the bot to understand natural language requests.
**Direct mode:** Messages go straight to the active Claude Code session, bypassing the LLM. Faster and more predictable, but requires an active session. Use `/direct` to enable.
**Direct mode:** Messages go straight to the active Claude Code session, bypassing the LLM. Faster and more predictable, but requires an active session. Use `//direct` to enable.
### Claude Code Commands
Claude Code slash commands (like `/help`, `/clear`, `/compact`, `/cost`) are passed through to Claude Code when you have an active session. Bot commands (`/new`, `/status`, `/switch`, etc.) are handled by the bot first.
Claude Code slash commands (like `//help`, `//clear`, `//compact`, `//cost`) are passed through to Claude Code when you have an active session. Bot commands (`//new`, `//status`, `//switch`, etc.) are handled by the bot first.
---
@@ -310,13 +321,13 @@ Claude Code slash commands (like `/help`, `/clear`, `/compact`, `/cost`) are pas
#### Better Interaction
- **Slash commands** - Direct control via `/new`, `/status`, `/switch`, `/close`, `/direct`, `/smart`
- **Slash commands** - Direct control via `//new`, `//status`, `//switch`, `//close`, `//direct`, `//smart`
- **Multi-session switching** - Multiple projects open simultaneously, switch between them
- **Interactive cards** - Session status displayed in Feishu message cards
#### Operational Quality
- **Health checks** - `/health` endpoint shows WebSocket status and can test Claude Code connectivity
- **Health checks** - `//health` endpoint shows WebSocket status and can test Claude Code connectivity
- **Auto-reconnection** - WebSocket automatically reconnects if the connection drops
- **Configurable timeouts** - Each session can have custom idle and execution timeout settings
- **Audit logging** - All conversations logged to files for debugging and accountability
@@ -335,7 +346,7 @@ Claude Code slash commands (like `/help`, `/clear`, `/compact`, `/cost`) are pas
- Automatic detection of question-like messages
#### Shell Access
- Execute shell commands remotely via `/shell` or through the LLM
- Execute shell commands remotely via `//shell` or through the LLM
- Safety guards block destructive commands (`rm -rf /`, `sudo rm`, `mkfs`, etc.)
- Configurable timeout (max 120 seconds)
@@ -350,7 +361,7 @@ Claude Code slash commands (like `/help`, `/clear`, `/compact`, `/cost`) are pas
- Long-running tasks (timeout > 60s) automatically run in background
- Immediate acknowledgment with task ID
- Feishu notification on completion
- Track task status with `/tasks` command
- Track task status with `//tasks` command
#### Web Search
- Search the web via 秘塔AI Search (requires `METASO_API_KEY`)
@@ -359,7 +370,7 @@ Claude Code slash commands (like `/help`, `/clear`, `/compact`, `/cost`) are pas
- Supports multiple scopes: webpage, paper, document, video, podcast
#### Scheduling & Reminders
- Set one-time reminders: `/remind 10m check the build`
- Set one-time reminders: `//remind 10m check the build`
- Schedule recurring reminders
- Notifications delivered to Feishu
- Persistent across server restarts
@@ -376,8 +387,8 @@ Runs both router and host client in one process. Identical UX to pre-M3 setup.
**Router Mode (Public VPS):**
```bash
# Set ROUTER_MODE: true in keyring.yaml
python main.py
# Set ROUTER_SECRET in keyring.yaml
python -m router.main
```
Runs only the router: Feishu handler + routing LLM + node registry.
@@ -389,8 +400,8 @@ python -m host_client.main
Connects to router via WebSocket, runs full mailboy stack locally.
#### Node Management
- `/nodes` — View all connected host nodes with status
- `/node <name>` — Switch active node for your user
- `//nodes` — View all connected host nodes with status
- `//node <name>` — Switch active node for your user
- Automatic routing: LLM decides which node handles each message
- Health monitoring: Router tracks node heartbeats
- Reconnection: Host clients auto-reconnect on disconnect
+18 -18
View File
@@ -40,7 +40,7 @@ returns: {stdout, stderr, exit_code}
approved by the user (raise a confirmation request)
- Timeout hard cap: 120 s; for longer tasks see M2.4
**New slash command:** `/shell <command>` (bypasses LLM; runs directly)
**New slash command:** `//shell <command>` (bypasses LLM; runs directly)
---
@@ -86,7 +86,7 @@ fire-and-forget with completion notification.
**New tool:** `run_background` — explicitly submits any shell command or CC prompt as a
background task and returns `task_id` immediately.
**New slash command:** `/tasks` — list running/completed background tasks with status.
**New slash command:** `//tasks` — list running/completed background tasks with status.
**New tool:** `task_status` — check status of a specific `task_id`, optionally get output so far.
@@ -145,7 +145,7 @@ args: action ("remind" | "repeat"), delay_seconds (int), interval_seconds (int),
message (str), conv_id (str, optional — if set, forward to that CC session)
```
**New slash command:** `/remind <N>m|h|s <message>` — set a reminder without LLM
**New slash command:** `//remind <N>m|h|s <message>` — set a reminder without LLM
---
@@ -168,7 +168,7 @@ args: action ("remind" | "repeat"), delay_seconds (int), interval_seconds (int),
| `orchestrator/tools.py` | Add `ShellTool`, `FileOpsTool`, `WebTool`, `TaskStatusTool`, `SchedulerTool` |
| `agent/task_runner.py` | New — `TaskRunner` singleton, `BackgroundTask` dataclass |
| `agent/scheduler.py` | New — `schedule_once`, `schedule_recurring` |
| `bot/commands.py` | Add `/shell`, `/tasks`, `/remind` commands |
| `bot/commands.py` | Add `//shell`, `//tasks`, `//remind` commands |
| `bot/feishu.py` | Add `chat_id` context var for file send from tool |
| `bot/handler.py` | Pass `chat_id` into context var alongside `user_id` |
| `requirements.txt` | Add `httpx` (if not already present as transitive dep) |
@@ -183,10 +183,10 @@ args: action ("remind" | "repeat"), delay_seconds (int), interval_seconds (int),
- [x] M2.3: Send "show me the last 50 lines of audit/abc123.jsonl" — file content returned
- [x] M2.3: Send "send me the sessions.json file" — file arrives in Feishu chat
- [x] M2.4: Start a long CC task (e.g. `--timeout 120`) — bot replies immediately, notifies on finish
- [x] M2.4: `/tasks` — lists running task with elapsed time
- [x] M2.4: `//tasks` — lists running task with elapsed time
- [x] M2.5: "Python 3.13 有哪些新特性?" — `web ask` returns RAG answer from metaso
- [x] M2.5: "帮我读取这个URL: https://example.com" — page content extracted as markdown
- [x] M2.6: `/remind 10m deploy check` — 10 min later, message arrives in Feishu
- [x] M2.6: `//remind 10m deploy check` — 10 min later, message arrives in Feishu
---
---
@@ -315,7 +315,7 @@ SERVES_USERS:
**What the host client runs:**
- Full `orchestrator/agent.py` (mailboy LLM, tool loop, per-user history, active session)
- Full `orchestrator/tools.py` (CC, shell, file ops, web, scheduler — all local)
- `agent/manager.py`, `agent/pty_process.py`, `agent/task_runner.py` — unchanged
- `agent/manager.py`, `agent/cc_runner.py`, `agent/task_runner.py` — unchanged
Task completion flow:
- Background task finishes → host client pushes `TaskComplete` to router
@@ -342,7 +342,7 @@ node to forward each message to.
`display_name`, `connected_at`, `last_heartbeat`
- `get_nodes_for_user(open_id) -> list[NodeConnection]` — may return multiple
- `get_active_node(user_id) -> NodeConnection | None` — per-user active node preference
- `set_active_node(user_id, node_id)` — updated by router LLM or `/node` command
- `set_active_node(user_id, node_id)` — updated by router LLM or `//node` command
**Router LLM** (`router/routing_agent.py`):
@@ -383,7 +383,7 @@ Authorization: Bearer <ROUTER_SECRET>
- On `ForwardResponse`, resolves Future with `reply` or raises on `error`
**Modified files:**
- `main.py` → mounts `/ws/node`, starts `NodeRegistry`
- `main.py` → mounts `//ws/node`, starts `NodeRegistry`
- `bot/handler.py` → after allowlist check, calls `routing_agent.route(user_id, chat_id, text)`
instead of `agent.run(user_id, text)` directly
- `config.py` → adds `ROUTER_SECRET`, `ROUTER_LLM_*` (can be same or different model)
@@ -437,7 +437,7 @@ all LLM/CC config from it. User only maintains one config file.
### M3.5 — Node Health + User-Facing Status
**`/nodes` slash command** (handled at router, before forwarding):
**`//nodes` slash command** (handled at router, before forwarding):
```
Connected Nodes:
→ home-pc [ACTIVE] sessions=2 online 3h
@@ -446,9 +446,9 @@ Connected Nodes:
Use "/node <name>" to switch active node.
```
**`/node <name>` slash command** — sets active node for user.
**`//node <name>` slash command** — sets active node for user.
**Router `/health` updates:**
**Router `//health` updates:**
```json
{
"nodes": [
@@ -495,7 +495,7 @@ PhoneWork/
├── agent/ # Part of host client (local execution)
│ ├── manager.py # Session registry
│ ├── pty_process.py # Claude Code runner
│ ├── cc_runner.py # Claude Code runner
│ ├── task_runner.py # Background tasks
│ ├── scheduler.py # Reminders
│ └── audit.py # Audit log
@@ -513,7 +513,7 @@ PhoneWork/
2. **M3.2** — Host client daemon (wrap existing mailboy + agent stack)
3. **M3.3** — Router (node registry, WS, routing LLM, refactor handler)
4. **M3.4** — Standalone script
5. **M3.5** — Node health, `/nodes`, `/node` commands
5. **M3.5** — Node health, `//nodes`, `//node` commands
---
@@ -522,8 +522,8 @@ PhoneWork/
- [x] `python standalone.py` — works identically to current `python main.py`
- [x] Router starts, host client connects, registration logged
- [x] Feishu message → routing LLM selects node → forwarded → reply returned
- [x] `/nodes` shows all connected nodes with active marker
- [x] `/node work-server` — switches active node, confirmed in next message
- [x] `//nodes` shows all connected nodes with active marker
- [x] `//node work-server` — switches active node, confirmed in next message
- [x] Two nodes serving same user — message routed to active node
- [x] Kill host client → router marks offline, user sees "Node home-pc is offline"
- [x] Host client reconnects → re-registered, messages flow again
@@ -543,8 +543,8 @@ The current `commands.py` calls `agent._active_conv`, `manager.list_sessions()`,
`task_runner.list_tasks()`, `scheduler` — all of which move to the host client in M3.
**Resolution:** At the router, `bot/commands.py` is reduced to two commands:
`/nodes` and `/node <name>`. All other slash commands (`/new`, `/status`, `/close`,
`/switch`, `/direct`, `/smart`, `/shell`, `/tasks`, `/remind`) are forwarded to the
`//nodes` and `//node <name>`. All other slash commands (`//new`, `//status`, `//close`,
`//switch`, `//direct`, `//smart`, `//shell`, `//tasks`, `//remind`) are forwarded to the
active node as-is — the node's mailboy handles them using its local `commands.py`.
The node's command handler remains unchanged from M2.
+55
View File
@@ -58,6 +58,61 @@ def log_interaction(
logger.exception("Failed to log audit entry for session %s", conv_id)
def log_tool_use(
session_id: str,
tool_name: str,
tool_input: dict,
tool_response: Optional[object] = None,
) -> None:
"""Log a tool call to the audit JSONL file."""
try:
_ensure_audit_dir()
log_file = AUDIT_DIR / f"{session_id}.jsonl"
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"type": "tool_use",
"session_id": session_id,
"tool_name": tool_name,
"tool_input": str(tool_input)[:500],
}
if tool_response is not None:
entry["tool_response"] = str(tool_response)[:500]
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
logger.exception("Failed to log tool use for session %s", session_id)
def log_permission_decision(
conv_id: str,
tool_name: str,
tool_input: dict,
approved: bool,
) -> None:
"""Log a permission approval/denial decision."""
try:
_ensure_audit_dir()
log_file = AUDIT_DIR / f"{conv_id}.jsonl"
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"type": "permission_decision",
"conv_id": conv_id,
"tool_name": tool_name,
"tool_input": str(tool_input)[:300],
"approved": approved,
}
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
logger.exception("Failed to log permission decision for session %s", conv_id)
def get_audit_log(conv_id: str, limit: int = 100) -> list[dict]:
"""Read the audit log for a session."""
log_file = AUDIT_DIR / f"{conv_id}.jsonl"
+14 -4
View File
@@ -17,12 +17,23 @@ def strip_ansi(text: str) -> str:
return ANSI_ESCAPE.sub("", text)
PERMISSION_MODE_FLAGS: dict[str, list[str]] = {
"default": [], # CC's own default: asks about everything
"acceptEdits": ["--permission-mode", "acceptEdits"],
"plan": ["--permission-mode", "plan"],
"bypassPermissions": ["--dangerously-skip-permissions"],
}
VALID_PERMISSION_MODES = list(PERMISSION_MODE_FLAGS)
DEFAULT_PERMISSION_MODE = "default"
async def run_claude(
prompt: str,
cwd: str,
cc_session_id: str | None = None,
resume: bool = False,
timeout: float = 300.0,
permission_mode: str = DEFAULT_PERMISSION_MODE,
) -> str:
"""
Run `claude -p <prompt>` in the given directory and return the output.
@@ -35,11 +46,10 @@ async def run_claude(
- Subsequent calls: passed as --resume so CC has full history.
resume: If True, use --resume instead of --session-id.
timeout: Maximum seconds to wait before giving up.
permission_mode: One of 'bypassPermissions', 'acceptEdits', 'plan'.
"""
base_args = [
"--dangerously-skip-permissions",
"-p", prompt,
]
perm_flags = PERMISSION_MODE_FLAGS.get(permission_mode, PERMISSION_MODE_FLAGS[DEFAULT_PERMISSION_MODE])
base_args = perm_flags + ["-p", prompt]
if cc_session_id:
if resume:
+129 -90
View File
@@ -5,18 +5,20 @@ from __future__ import annotations
import asyncio
import json
import logging
import uuid
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Dict, List, Optional
from typing import Optional
from agent.pty_process import run_claude
from agent.audit import log_interaction
from agent.sdk_session import (
SDKSession,
SessionProgress,
DEFAULT_PERMISSION_MODE,
VALID_PERMISSION_MODES,
)
logger = logging.getLogger(__name__)
DEFAULT_IDLE_TIMEOUT = 30 * 60
DEFAULT_CC_TIMEOUT = 300.0
PERSISTENCE_FILE = Path(__file__).parent.parent / "data" / "sessions.json"
@@ -25,20 +27,27 @@ class Session:
conv_id: str
cwd: str
owner_id: str = ""
cc_session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
last_activity: float = 0.0
started: bool = False
idle_timeout: int = DEFAULT_IDLE_TIMEOUT
cc_timeout: float = DEFAULT_CC_TIMEOUT
permission_mode: str = field(default_factory=lambda: DEFAULT_PERMISSION_MODE)
chat_id: str | None = None
# Runtime only — not serialized
sdk_session: SDKSession | None = field(default=None, repr=False)
def touch(self) -> None:
self.last_activity = asyncio.get_event_loop().time()
def to_dict(self) -> dict:
return asdict(self)
d = asdict(self)
d.pop("sdk_session", None)
return d
@classmethod
def from_dict(cls, data: dict) -> "Session":
data.pop("sdk_session", None)
# Migration: remove old cc_runner fields if present in persisted data
for old_key in ("cc_session_id", "started", "cc_timeout"):
data.pop(old_key, None)
return cls(**data)
@@ -59,6 +68,9 @@ class SessionManager:
if self._reaper_task:
self._reaper_task.cancel()
async with self._lock:
for session in self._sessions.values():
if session.sdk_session:
await session.sdk_session.close()
self._sessions.clear()
if PERSISTENCE_FILE.exists():
PERSISTENCE_FILE.unlink()
@@ -69,7 +81,8 @@ class SessionManager:
working_dir: str,
owner_id: str = "",
idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
cc_timeout: float = DEFAULT_CC_TIMEOUT,
permission_mode: str = DEFAULT_PERMISSION_MODE,
chat_id: str | None = None,
) -> Session:
async with self._lock:
session = Session(
@@ -77,98 +90,85 @@ class SessionManager:
cwd=working_dir,
owner_id=owner_id,
idle_timeout=idle_timeout,
cc_timeout=cc_timeout,
permission_mode=permission_mode,
chat_id=chat_id,
)
self._sessions[conv_id] = session
self._save()
logger.info(
"Created session %s (owner=...%s) in %s (idle=%ds, cc=%.0fs)",
conv_id, owner_id[-8:] if owner_id else "-", working_dir, idle_timeout, cc_timeout,
"Created session %s (owner=...%s) in %s (idle=%ds, perm=%s)",
conv_id, owner_id[-8:] if owner_id else "-", working_dir,
idle_timeout, permission_mode,
)
return session
async def send(self, conv_id: str, message: str, user_id: Optional[str] = None) -> str:
async with self._lock:
session = self._sessions.get(conv_id)
if session is None:
raise KeyError(f"No session for conv_id={conv_id!r}")
if session.owner_id and user_id and session.owner_id != user_id:
raise PermissionError(f"Session {conv_id} belongs to another user")
session.touch()
cwd = session.cwd
cc_session_id = session.cc_session_id
cc_timeout = session.cc_timeout
first_message = not session.started
if first_message:
session.started = True
self._save()
# --- Secretary model: async send (returns immediately) ---
if cc_timeout > 60:
from agent.task_runner import task_runner
from orchestrator.tools import get_current_chat, set_current_chat, set_current_user
async def send_message(
self, conv_id: str, message: str,
user_id: Optional[str] = None, chat_id: Optional[str] = None,
) -> str:
"""Send a message to the session. Returns immediately; result pushed to Feishu on completion."""
session = self._get_session(conv_id, user_id)
session.touch()
self._ensure_sdk_session(session, chat_id)
return await session.sdk_session.send(message, chat_id)
chat_id = get_current_chat()
async def send_and_wait(
self, conv_id: str, message: str,
user_id: Optional[str] = None, chat_id: Optional[str] = None,
) -> str:
"""Send and wait for completion. For LLM agent tool calls that need the result."""
session = self._get_session(conv_id, user_id)
session.touch()
self._ensure_sdk_session(session, chat_id)
return await session.sdk_session.send_and_wait(message, chat_id)
async def run_task():
output = await run_claude(
message,
cwd=cwd,
cc_session_id=cc_session_id,
resume=not first_message,
timeout=cc_timeout,
)
log_interaction(
conv_id=conv_id,
prompt=message,
response=output,
cwd=cwd,
user_id=user_id,
)
return output
# --- Kept for backward compatibility (used by bot/commands.py _cmd_new) ---
async def on_task_complete(task) -> None:
if not chat_id or not user_id or not task.result:
return
set_current_user(user_id)
set_current_chat(chat_id)
from orchestrator.agent import agent
follow_up = (
f"CC task completed. Output:\n{task.result}\n\n"
f"Original request was: {message}\n\n"
"If the user asked you to send a file, use send_file now. "
"Otherwise just acknowledge completion."
)
reply = await agent.run(user_id, follow_up)
if reply:
from bot.feishu import send_text
await send_text(chat_id, "chat_id", reply)
async def send(
self, conv_id: str, message: str,
user_id: Optional[str] = None, direct: bool = False,
) -> str:
"""Backward-compatible send. Maps to send_message (async, secretary model)."""
from orchestrator.tools import get_current_chat
chat_id = get_current_chat()
return await self.send_message(conv_id, message, user_id=user_id, chat_id=chat_id)
task_id = await task_runner.submit(
run_task(),
description=f"CC session {conv_id}: {message[:50]}",
notify_chat_id=chat_id,
user_id=user_id,
on_complete=on_task_complete,
)
return f"⏳ Task #{task_id} started (timeout: {int(cc_timeout)}s). I'll notify you when it's done."
# --- Progress, interrupt, approve ---
output = await run_claude(
message,
cwd=cwd,
cc_session_id=cc_session_id,
resume=not first_message,
timeout=cc_timeout,
)
def get_progress(self, conv_id: str, user_id: Optional[str] = None) -> SessionProgress | None:
"""Query session progress. Primary interface for the secretary AI."""
session = self._sessions.get(conv_id)
if not session:
return None
if session.owner_id and user_id and session.owner_id != user_id:
return None
if session.sdk_session:
return session.sdk_session.get_progress()
return SessionProgress()
log_interaction(
conv_id=conv_id,
prompt=message,
response=output,
cwd=cwd,
user_id=user_id,
)
async def interrupt(self, conv_id: str, user_id: Optional[str] = None) -> bool:
"""Interrupt the currently running task in a session."""
session = self._get_session(conv_id, user_id)
if session.sdk_session:
await session.sdk_session.interrupt()
return True
return False
return output
async def approve(self, conv_id: str, approved: bool) -> None:
"""Resolve a pending tool approval for a session."""
session = self._sessions.get(conv_id)
if session and session.sdk_session:
await session.sdk_session.approve(approved)
async def answer_question(self, conv_id: str, answers: dict[str, str]) -> None:
"""Resolve a pending AskUserQuestion with user's answers."""
session = self._sessions.get(conv_id)
if session and session.sdk_session:
await session.sdk_session.answer_question(answers)
# --- Close, list, permission ---
async def close(self, conv_id: str, user_id: Optional[str] = None) -> bool:
async with self._lock:
@@ -177,6 +177,8 @@ class SessionManager:
return False
if session.owner_id and user_id and session.owner_id != user_id:
raise PermissionError(f"Session {conv_id} belongs to another user")
if session.sdk_session:
await session.sdk_session.close()
del self._sessions[conv_id]
self._save()
logger.info("Closed session %s", conv_id)
@@ -191,14 +193,48 @@ class SessionManager:
"conv_id": s.conv_id,
"cwd": s.cwd,
"owner_id": s.owner_id[-8:] if s.owner_id else None,
"cc_session_id": s.cc_session_id,
"started": s.started,
"busy": s.sdk_session._busy if s.sdk_session else False,
"idle_timeout": s.idle_timeout,
"cc_timeout": s.cc_timeout,
"permission_mode": s.permission_mode,
}
for s in sessions
]
def set_permission_mode(self, conv_id: str, mode: str, user_id: Optional[str] = None) -> None:
"""Change the permission mode for an existing session."""
session = self._sessions.get(conv_id)
if session is None:
raise KeyError(f"No session for conv_id={conv_id!r}")
if session.owner_id and user_id and session.owner_id != user_id:
raise PermissionError(f"Session {conv_id} belongs to another user")
if mode not in VALID_PERMISSION_MODES:
raise ValueError(f"Invalid permission mode {mode!r}. Valid: {VALID_PERMISSION_MODES}")
session.permission_mode = mode
if session.sdk_session:
asyncio.create_task(session.sdk_session.set_permission_mode(mode))
self._save()
logger.info("Set permission_mode=%s for session %s", mode, conv_id)
# --- Internal ---
def _get_session(self, conv_id: str, user_id: Optional[str] = None) -> Session:
session = self._sessions.get(conv_id)
if session is None:
raise KeyError(f"No session for conv_id={conv_id!r}")
if session.owner_id and user_id and session.owner_id != user_id:
raise PermissionError(f"Session {conv_id} belongs to another user")
return session
def _ensure_sdk_session(self, session: Session, chat_id: str | None = None) -> None:
if session.sdk_session is None:
session.sdk_session = SDKSession(
conv_id=session.conv_id,
cwd=session.cwd,
owner_id=session.owner_id,
permission_mode=session.permission_mode,
chat_id=chat_id or session.chat_id,
)
def _save(self) -> None:
try:
data = {cid: s.to_dict() for cid, s in self._sessions.items()}
@@ -234,6 +270,9 @@ class SessionManager:
if s.last_activity > 0 and (now - s.last_activity) > s.idle_timeout:
to_close.append(cid)
for cid in to_close:
session = self._sessions[cid]
if session.sdk_session:
await session.sdk_session.close()
del self._sessions[cid]
logger.info("Reaped idle session %s", cid)
if to_close:
+72
View File
@@ -0,0 +1,72 @@
"""SDK hooks for audit logging and dangerous command blocking."""
from __future__ import annotations
import re
from claude_agent_sdk import HookContext, HookInput, HookJSONOutput, HookMatcher
BLOCKED_PATTERNS = [
r"\brm\s+-rf\s+/",
r"\brm\s+-rf\s+~",
r"\bformat\s+",
r"\bmkfs\b",
r"\bshutdown\b",
r"\breboot\b",
r"\bdd\s+if=",
r":\(\)\{:\|:&\};:",
r"\bchmod\s+777\s+/",
r"\bchown\s+.*\s+/",
r"\bsudo\s+rm\b",
r"\bsudo\s+chmod\b",
r"\bsudo\s+chown\b",
r"\bsudo\s+dd\b",
r"\bkill\s+-9\s+1\b",
]
async def audit_hook(
input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
"""PostToolUse hook — log tool calls to audit JSONL."""
from agent.audit import log_tool_use
log_tool_use(
session_id=input_data.get("session_id", ""),
tool_name=input_data.get("tool_name", ""),
tool_input=input_data.get("tool_input", {}),
tool_response=input_data.get("tool_response"),
)
return {}
async def deny_dangerous_hook(
input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
"""PreToolUse hook — block dangerous Bash commands."""
if input_data.get("tool_name") != "Bash":
return {}
command = input_data.get("tool_input", {}).get("command", "")
for pattern in BLOCKED_PATTERNS:
if re.search(pattern, command, re.IGNORECASE):
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"Blocked by policy: matches {pattern}",
}
}
return {}
def build_hooks(conv_id: str) -> dict[str, list[HookMatcher]]:
"""Build hooks configuration for a session."""
return {
"PostToolUse": [
HookMatcher(matcher="Bash|Edit|Write|MultiEdit", hooks=[audit_hook]),
],
"PreToolUse": [
HookMatcher(matcher="Bash", hooks=[deny_dangerous_hook]),
],
}
+414
View File
@@ -0,0 +1,414 @@
"""SDK-based Claude Code session — secretary model.
Messages are buffered in memory, not pushed to Feishu in real-time.
Only key events (completion, error, approval) trigger notifications.
The secretary AI queries get_progress() to answer user questions.
"""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Any
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
PermissionMode,
PermissionResult,
PermissionResultAllow,
PermissionResultDeny,
ResultMessage,
SystemMessage,
TextBlock,
ToolPermissionContext,
ToolUseBlock,
)
logger = logging.getLogger(__name__)
VALID_PERMISSION_MODES = ["default", "acceptEdits", "plan", "bypassPermissions", "dontAsk"]
DEFAULT_PERMISSION_MODE = "default"
APPROVAL_TIMEOUT = 120 # seconds
@dataclass
class SessionProgress:
"""Session progress snapshot for the secretary AI to inspect."""
busy: bool = False
current_prompt: str = ""
started_at: float = 0.0
elapsed_seconds: float = 0.0
text_messages: list[str] = field(default_factory=list)
tool_calls: list[str] = field(default_factory=list)
last_result: str = ""
error: str = ""
pending_approval: str = "" # non-empty → waiting for approval, value is tool description
pending_question: dict | None = None # non-None → waiting for user answer to AskUserQuestion
class SDKSession:
"""One session = one long-lived ClaudeSDKClient + background message buffer loop.
Secretary model design:
- _message_loop buffers all messages to memory, does NOT push to Feishu
- Only pushes on key events: completion (ResultMessage), error, approval needed
- get_progress() returns a snapshot for the secretary AI to inspect
"""
MAX_BUFFER_TEXTS = 20
MAX_BUFFER_TOOLS = 50
def __init__(
self,
conv_id: str,
cwd: str,
owner_id: str,
permission_mode: str = DEFAULT_PERMISSION_MODE,
chat_id: str | None = None,
):
self.conv_id = conv_id
self.cwd = cwd
self.owner_id = owner_id
self.permission_mode = permission_mode
self.chat_id = chat_id
self.client: ClaudeSDKClient | None = None
self.session_id: str | None = None
# Message buffers
self._text_buffer: list[str] = []
self._tool_buffer: list[str] = []
self._last_result: str = ""
self._error: str = ""
self._current_prompt: str = ""
self._started_at: float = 0.0
# Task state
self._message_loop_task: asyncio.Task | None = None
self._busy = False
self._busy_event = asyncio.Event()
self._busy_event.set() # initially idle
# Approval mechanism
self._pending_approval: asyncio.Future | None = None
self._pending_approval_desc: str = ""
# AskUserQuestion mechanism
self._pending_question: asyncio.Future | None = None
self._pending_question_data: dict | None = None # {questions: [...], conv_id: ...}
async def start(self) -> None:
"""Create and connect the ClaudeSDKClient, start the message loop."""
from agent.sdk_hooks import build_hooks
env = self._build_env()
hooks = build_hooks(self.conv_id)
options = ClaudeAgentOptions(
cwd=self.cwd,
permission_mode=self.permission_mode,
allowed_tools=[
"Read", "Glob", "Grep", "Bash", "Edit", "Write",
"MultiEdit", "WebFetch", "WebSearch",
],
can_use_tool=self._permission_callback,
hooks=hooks,
env=env,
)
self.client = ClaudeSDKClient(options)
await self.client.connect()
self._message_loop_task = asyncio.create_task(
self._message_loop(), name=f"sdk-loop-{self.conv_id}"
)
logger.info("SDKSession %s started in %s", self.conv_id, self.cwd)
async def send(self, prompt: str, chat_id: str | None = None) -> str:
"""Send a message. Returns immediately; execution happens in background."""
if not self.client:
await self.start()
if chat_id:
self.chat_id = chat_id
# If busy, interrupt the current task first
if self._busy:
await self.interrupt()
try:
await asyncio.wait_for(self._busy_event.wait(), timeout=10)
except asyncio.TimeoutError:
pass
self._busy = True
self._busy_event.clear()
self._current_prompt = prompt
self._started_at = time.time()
self._last_result = ""
self._error = ""
self._text_buffer.clear()
self._tool_buffer.clear()
await self.client.query(prompt)
return "⏳ 已开始执行"
async def send_and_wait(self, prompt: str, chat_id: str | None = None) -> str:
"""Send and wait for completion. For LLM agent tool calls."""
await self.send(prompt, chat_id)
await self._busy_event.wait()
return self._last_result or self._error or "(no output)"
def get_progress(self) -> SessionProgress:
"""Return a progress snapshot. Primary query interface for the secretary AI."""
return SessionProgress(
busy=self._busy,
current_prompt=self._current_prompt,
started_at=self._started_at,
elapsed_seconds=time.time() - self._started_at if self._busy else 0,
text_messages=list(self._text_buffer[-5:]),
tool_calls=list(self._tool_buffer[-10:]),
last_result=self._last_result[:1000],
error=self._error,
pending_approval=self._pending_approval_desc,
pending_question=self._pending_question_data,
)
async def interrupt(self) -> None:
"""Interrupt the currently running task."""
if self.client and self._busy:
await self.client.interrupt()
logger.info("SDKSession %s interrupted", self.conv_id)
async def set_permission_mode(self, mode: PermissionMode) -> None:
"""Dynamically change the permission mode."""
if self.client:
await self.client.set_permission_mode(mode)
self.permission_mode = mode
logger.info("SDKSession %s permission_mode → %s", self.conv_id, mode)
async def approve(self, approved: bool) -> None:
"""Resolve a pending tool approval."""
if self._pending_approval and not self._pending_approval.done():
self._pending_approval.set_result(approved)
async def answer_question(self, answers: dict[str, str]) -> None:
"""Resolve a pending AskUserQuestion with user's selected answers.
Args:
answers: maps question text → selected option label.
"""
if self._pending_question and not self._pending_question.done():
self._pending_question.set_result(answers)
async def close(self) -> None:
"""Disconnect and clean up."""
if self._message_loop_task and not self._message_loop_task.done():
self._message_loop_task.cancel()
try:
await self._message_loop_task
except asyncio.CancelledError:
pass
if self.client:
await self.client.disconnect()
self.client = None
logger.info("SDKSession %s closed", self.conv_id)
# --- Internal ---
async def _message_loop(self) -> None:
"""Background message consumption loop. Buffers messages, notifies on key events."""
from agent.audit import log_interaction
try:
async for msg in self.client.receive_messages():
if isinstance(msg, SystemMessage) and msg.subtype == "init":
self.session_id = msg.data.get("session_id")
elif isinstance(msg, AssistantMessage):
for block in msg.content:
if isinstance(block, TextBlock):
self._text_buffer.append(block.text)
if len(self._text_buffer) > self.MAX_BUFFER_TEXTS:
self._text_buffer.pop(0)
elif isinstance(block, ToolUseBlock):
summary = f"{block.name}({self._summarize_input(block.input)})"
self._tool_buffer.append(summary)
if len(self._tool_buffer) > self.MAX_BUFFER_TOOLS:
self._tool_buffer.pop(0)
elif isinstance(msg, ResultMessage):
self._last_result = msg.result or ""
self._busy = False
self._busy_event.set()
# Key event: task completed → notify Feishu
if self.chat_id:
await self._notify_completion()
log_interaction(
conv_id=self.conv_id,
prompt=self._current_prompt,
response=self._last_result[:2000],
cwd=self.cwd,
user_id=self.owner_id,
)
except asyncio.CancelledError:
logger.debug("Message loop cancelled for %s", self.conv_id)
except Exception as exc:
logger.exception("Message loop error for %s", self.conv_id)
self._error = str(exc)
self._busy = False
self._busy_event.set()
if self.chat_id:
await self._notify_error(str(exc))
async def _notify_completion(self) -> None:
from bot.feishu import send_markdown
result_preview = self._last_result[:800]
if len(self._last_result) > 800:
result_preview += "\n...[truncated]"
elapsed = int(time.time() - self._started_at)
tools_used = len(self._tool_buffer)
msg = f"✅ **任务完成** ({elapsed}s, {tools_used} tool calls)\n\n{result_preview}"
try:
await send_markdown(self.chat_id, "chat_id", msg)
except Exception:
logger.exception("Failed to notify completion")
async def _notify_error(self, error: str) -> None:
from bot.feishu import send_markdown
try:
await send_markdown(
self.chat_id, "chat_id",
f"❌ **任务出错**\n\n```\n{error[:500]}\n```",
)
except Exception:
logger.exception("Failed to notify error")
async def _permission_callback(
self, tool_name: str, input_data: dict, context: ToolPermissionContext
) -> PermissionResult:
"""can_use_tool — route to question card or approval card based on tool type."""
# Auto-allow read-only tools
if tool_name in ("Read", "Glob", "Grep", "WebSearch", "WebFetch"):
return PermissionResultAllow()
# AskUserQuestion: show options to user, collect answer, return via updated_input
if tool_name == "AskUserQuestion":
return await self._handle_ask_user_question(input_data)
if not self.chat_id:
return PermissionResultAllow()
# Regular tools: approval flow
return await self._handle_tool_approval(tool_name, input_data)
async def _handle_ask_user_question(self, input_data: dict) -> PermissionResult:
"""Handle AskUserQuestion: send question card, wait for answer, return updated_input."""
if not self.chat_id:
return PermissionResultAllow()
questions = input_data.get("questions", [])
if not questions:
return PermissionResultAllow()
from bot.feishu import send_card, build_question_card
# Build and send question card
self._pending_question_data = {"questions": questions, "conv_id": self.conv_id}
card = build_question_card(
conv_id=self.conv_id,
questions=questions,
)
await send_card(self.chat_id, "chat_id", card)
# Wait for user's answer (via card callback or text reply)
loop = asyncio.get_running_loop()
self._pending_question = loop.create_future()
try:
answers = await asyncio.wait_for(
self._pending_question, timeout=APPROVAL_TIMEOUT
)
except asyncio.TimeoutError:
answers = {}
from bot.feishu import send_markdown
await send_markdown(self.chat_id, "chat_id", "⏰ 问题超时,已跳过。")
finally:
self._pending_question_data = None
# Pre-fill answers in the tool input
modified_input = dict(input_data)
if "answers" not in modified_input or not isinstance(modified_input.get("answers"), dict):
modified_input["answers"] = {}
modified_input["answers"].update(answers)
return PermissionResultAllow(updated_input=modified_input)
async def _handle_tool_approval(self, tool_name: str, input_data: dict) -> PermissionResult:
"""Handle regular tool approval: send approval card, wait for approve/deny."""
from bot.feishu import send_card, build_approval_card
summary = self._format_tool_summary(tool_name, input_data)
self._pending_approval_desc = f"{tool_name}: {summary}"
card = build_approval_card(
conv_id=self.conv_id,
tool_name=tool_name,
summary=summary,
timeout=APPROVAL_TIMEOUT,
)
await send_card(self.chat_id, "chat_id", card)
loop = asyncio.get_running_loop()
self._pending_approval = loop.create_future()
try:
approved = await asyncio.wait_for(
self._pending_approval, timeout=APPROVAL_TIMEOUT
)
except asyncio.TimeoutError:
approved = False
from bot.feishu import send_markdown
await send_markdown(self.chat_id, "chat_id", "⏰ 审批超时,已自动拒绝。")
finally:
self._pending_approval_desc = ""
from agent.audit import log_permission_decision
log_permission_decision(
conv_id=self.conv_id, tool_name=tool_name,
tool_input=input_data, approved=approved,
)
if approved:
return PermissionResultAllow()
return PermissionResultDeny(message="用户拒绝了此操作")
def _format_tool_summary(self, tool_name: str, input_data: dict) -> str:
if tool_name == "Bash":
return f"`{input_data.get('command', '')[:200]}`"
if tool_name in ("Edit", "Write", "MultiEdit"):
return f"file: `{input_data.get('file_path', input_data.get('path', ''))}`"
return str(input_data)[:200]
@staticmethod
def _summarize_input(input_data: dict) -> str:
if "command" in input_data:
return input_data["command"][:80]
if "file_path" in input_data:
return input_data["file_path"]
return str(input_data)[:60]
def _build_env(self) -> dict[str, str]:
import os
env = {}
for key in ("ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"):
val = os.environ.get(key, "")
if val:
env[key] = val
return env
+222 -72
View File
@@ -12,22 +12,53 @@ from typing import Optional, Tuple
from agent.manager import manager
from agent.scheduler import scheduler
from agent.task_runner import task_runner
from agent.sdk_session import VALID_PERMISSION_MODES, DEFAULT_PERMISSION_MODE
from orchestrator.agent import agent
from orchestrator.tools import set_current_user, get_current_chat
logger = logging.getLogger(__name__)
# Permission mode aliases (user-facing shorthand → internal CC mode)
_PERM_ALIASES: dict[str, str] = {
"bypass": "bypassPermissions",
"default": "default",
"edit": "acceptEdits",
"plan": "plan",
"auto": "dontAsk",
}
_PERM_LABELS: dict[str, str] = {
"default": "default",
"bypassPermissions": "bypass",
"acceptEdits": "edit",
"plan": "plan",
"dontAsk": "auto",
}
def _resolve_perm(alias: str) -> str | None:
"""Map user-facing alias to internal permission mode, or None if invalid."""
return _PERM_ALIASES.get(alias.lower().strip())
def _perm_label(mode: str) -> str:
"""Return short human-readable label for a permission mode."""
return _PERM_LABELS.get(mode, mode)
def parse_command(text: str) -> Optional[Tuple[str, str]]:
"""
Parse a slash command from text.
Parse a bot command from text.
Returns (command, args) or None if not a command.
Commands must start with COMMAND_PREFIX (default "//").
"""
from config import COMMAND_PREFIX
text = text.strip()
if not text.startswith("/"):
if not text.startswith(COMMAND_PREFIX):
return None
parts = text.split(None, 1)
cmd = parts[0].lower()
# Strip the prefix, then split into command word and args
body = text[len(COMMAND_PREFIX):]
parts = body.split(None, 1)
cmd = COMMAND_PREFIX + parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
return (cmd, args)
@@ -43,31 +74,44 @@ async def handle_command(user_id: str, text: str) -> Optional[str]:
cmd, args = parsed
logger.info("Command: %s args=%r user=...%s", cmd, args[:50], user_id[-8:])
# In ROUTER_MODE, only handle router-specific commands locally.
# Session commands (//status, //new, //close, etc.) fall through to node forwarding.
from config import ROUTER_MODE, COMMAND_PREFIX
P = COMMAND_PREFIX
if ROUTER_MODE and cmd not in (P+"nodes", P+"node", P+"help", P+"h", P+"?"):
return None
set_current_user(user_id)
if cmd in ("/new", "/n"):
if cmd in (P+"new", P+"n"):
return await _cmd_new(user_id, args)
elif cmd in ("/status", "/list", "/ls", "/l"):
elif cmd in (P+"list", P+"ls", P+"l", P+"status"):
return await _cmd_status(user_id)
elif cmd in ("/close", "/c"):
elif cmd in (P+"close", P+"c"):
return await _cmd_close(user_id, args)
elif cmd in ("/switch", "/s"):
elif cmd in (P+"switch", P+"s"):
return await _cmd_switch(user_id, args)
elif cmd == "/retry":
elif cmd == P+"retry":
return await _cmd_retry(user_id)
elif cmd in ("/help", "/h", "/?"):
elif cmd in (P+"help", P+"h", P+"?"):
return _cmd_help()
elif cmd == "/direct":
elif cmd == P+"direct":
return _cmd_direct(user_id)
elif cmd == "/smart":
elif cmd == P+"smart":
return _cmd_smart(user_id)
elif cmd == "/tasks":
elif cmd == P+"tasks":
return _cmd_tasks()
elif cmd == "/shell":
elif cmd == P+"shell":
return await _cmd_shell(args)
elif cmd == "/remind":
elif cmd == P+"remind":
return await _cmd_remind(args)
elif cmd in ("/nodes", "/node"):
elif cmd == P+"perm":
return await _cmd_perm(user_id, args)
elif cmd in (P+"stop", P+"interrupt"):
return await _cmd_stop(user_id)
elif cmd in (P+"progress", P+"prog", P+"p"):
return await _cmd_progress(user_id)
elif cmd in (P+"nodes", P+"node"):
return await _cmd_nodes(user_id, args)
else:
return None
@@ -76,61 +120,68 @@ async def handle_command(user_id: str, text: str) -> Optional[str]:
async def _cmd_new(user_id: str, args: str) -> str:
"""Create a new session."""
if not args:
return "Usage: /new <project_dir> [initial_message] [--timeout N]\nExample: /new todo_app fix the bug --timeout 600"
return "Usage: /new <project_dir> [initial_message] [--perm MODE]\nModes: default, edit, plan, bypass, auto"
parser = argparse.ArgumentParser()
parser.add_argument("working_dir", nargs="?", help="Project directory")
parser.add_argument("rest", nargs="*", help="Initial message")
parser.add_argument("--timeout", type=int, default=None, help="CC timeout in seconds")
parser.add_argument("--idle", type=int, default=None, help="Idle timeout in seconds")
parser.add_argument("--perm", default=None, help="Permission mode: default, edit, plan, bypass, auto")
try:
parsed = parser.parse_args(args.split())
except SystemExit:
return "Usage: /new <project_dir> [initial_message] [--timeout N] [--idle N]"
return "Usage: /new <project_dir> [initial_message] [--idle N] [--perm MODE]"
if not parsed.working_dir:
return "Error: project_dir is required"
permission_mode = _resolve_perm(parsed.perm) if parsed.perm else DEFAULT_PERMISSION_MODE
if permission_mode is None:
return f"Invalid --perm. Valid modes: default, edit, plan, bypass, auto"
working_dir = parsed.working_dir
initial_msg = " ".join(parsed.rest) if parsed.rest else None
from orchestrator.tools import CreateConversationTool
from orchestrator.tools import _resolve_dir
tool = CreateConversationTool()
result = await tool._arun(
working_dir=working_dir,
initial_message=initial_msg,
cc_timeout=parsed.timeout,
idle_timeout=parsed.idle,
)
try:
data = json.loads(result)
if "error" in data:
return f"Error: {data['error']}"
conv_id = data.get("conv_id", "")
agent._active_conv[user_id] = conv_id
cwd = data.get("working_dir", working_dir)
resolved = _resolve_dir(working_dir)
except ValueError as e:
return f"Error: {e}"
chat_id = get_current_chat()
if chat_id:
from bot.feishu import send_card, send_text, build_sessions_card
sessions = manager.list_sessions(user_id=user_id)
mode = "Direct 🟢" if agent.get_passthrough(user_id) else "Smart ⚪"
card = build_sessions_card(sessions, conv_id, mode)
await send_card(chat_id, "chat_id", card)
if initial_msg and data.get("response"):
await send_text(chat_id, "chat_id", data["response"])
return ""
import uuid as _uuid
conv_id = str(_uuid.uuid4())[:8]
chat_id = get_current_chat()
await manager.create(
conv_id,
str(resolved),
owner_id=user_id,
idle_timeout=parsed.idle or 1800,
permission_mode=permission_mode,
chat_id=chat_id,
)
agent._active_conv[user_id] = conv_id
reply = f"✓ Created session `{conv_id}` in `{cwd}`"
if parsed.timeout:
reply += f" (timeout: {parsed.timeout}s)"
if initial_msg:
reply += f"\n\nSent: {initial_msg[:100]}..."
return reply
except Exception:
return result
response = None
if initial_msg:
response = await manager.send(conv_id, initial_msg, user_id=user_id)
if chat_id:
from bot.feishu import send_card, send_text, build_sessions_card
sessions = manager.list_sessions(user_id=user_id)
routing_mode = "Direct 🟢" if agent.get_passthrough(user_id) else "Smart ⚪"
card = build_sessions_card(sessions, conv_id, routing_mode)
await send_card(chat_id, "chat_id", card)
if initial_msg and response:
await send_text(chat_id, "chat_id", response)
return ""
perm_label = _perm_label(permission_mode)
reply = f"✓ Created session `{conv_id}` in `{resolved}` [{perm_label}]"
if initial_msg and response:
reply += f"\n\n{response}"
return reply
async def _cmd_status(user_id: str) -> str:
@@ -152,15 +203,38 @@ async def _cmd_status(user_id: str) -> str:
lines = ["**Your Sessions:**\n"]
for i, s in enumerate(sessions, 1):
marker = "" if s["conv_id"] == active else " "
lines.append(f"{marker}{i}. `{s['conv_id']}` - `{s['cwd']}`")
perm = _perm_label(s.get("permission_mode", DEFAULT_PERMISSION_MODE))
lines.append(f"{marker}{i}. `{s['conv_id']}` - `{s['cwd']}` [{perm}]")
lines.append(f"\n**Mode:** {mode}")
lines.append("Use `/switch <n>` to activate a session.")
lines.append("Use `/direct` or `/smart` to change mode.")
lines.append("Use `//switch <n>` to activate a session.")
lines.append("Use `//direct` or `//smart` to change mode.")
return "\n".join(lines)
async def _cmd_close(user_id: str, args: str) -> str:
"""Close a session."""
# If a specific conv_id is given by name (not a number), resolve it directly.
if args:
try:
int(args)
by_number = True
except ValueError:
by_number = False
if not by_number:
# Explicit conv_id given — look it up directly (may belong to another user).
conv_id = args.strip()
try:
success = await manager.close(conv_id, user_id=user_id)
if success:
if agent.get_active_conv(user_id) == conv_id:
agent._active_conv[user_id] = None
return f"✓ Closed session `{conv_id}`"
else:
return f"Session `{conv_id}` not found."
except PermissionError as e:
return str(e)
sessions = manager.list_sessions(user_id=user_id)
if not sessions:
return "No sessions to close."
@@ -177,7 +251,7 @@ async def _cmd_close(user_id: str, args: str) -> str:
else:
conv_id = agent.get_active_conv(user_id)
if not conv_id:
return "No active session. Use `/close <conv_id>` or `/close <n>`."
return "No active session. Use `//close <conv_id>` or `//close <n>`."
try:
success = await manager.close(conv_id, user_id=user_id)
@@ -212,6 +286,40 @@ async def _cmd_switch(user_id: str, args: str) -> str:
return f"Invalid number: {args}"
async def _cmd_perm(user_id: str, args: str) -> str:
"""Change the permission mode of the active (or specified) session."""
parts = args.split()
if not parts:
return (
"Usage: /perm <mode> [conv_id]\n"
"Modes: default, edit, plan, bypass, auto\n"
" default — default mode\n"
" edit — auto-accept file edits, confirm shell commands\n"
" plan — plan only, no writes\n"
" bypass — skip all permission checks\n"
" auto — allow all tools, don't ask"
)
alias = parts[0]
conv_id = parts[1] if len(parts) > 1 else agent.get_active_conv(user_id)
permission_mode = _resolve_perm(alias)
if permission_mode is None:
return f"Unknown mode '{alias}'. Valid: default, edit, plan, bypass"
if not conv_id:
return "No active session. Use `//perm <mode> <conv_id>` or activate a session first."
try:
manager.set_permission_mode(conv_id, permission_mode, user_id=user_id)
except KeyError:
return f"Session `{conv_id}` not found."
except PermissionError as e:
return str(e)
return f"✓ Session `{conv_id}` permission mode set to **{_perm_label(permission_mode)}**"
async def _cmd_retry(user_id: str) -> str:
"""Retry the last message (placeholder - needs history tracking)."""
return "Retry not yet implemented. Just send your message again."
@@ -221,7 +329,7 @@ def _cmd_direct(user_id: str) -> str:
"""Enable direct mode - messages go straight to Claude Code."""
conv = agent.get_active_conv(user_id)
if not conv:
return "No active session. Use `/new` or `/switch` first."
return "No active session. Use `//new` or `//switch` first."
agent.set_passthrough(user_id, True)
return f"✓ Direct mode ON. Messages go directly to session `{conv}`."
@@ -299,11 +407,43 @@ async def _cmd_remind(args: str) -> str:
return f"⏰ Reminder #{job_id} set for {value}{unit} from now"
async def _cmd_stop(user_id: str) -> str:
"""Interrupt the current task in the active session."""
conv_id = agent.get_active_conv(user_id)
if not conv_id:
return "No active session."
try:
success = await manager.interrupt(conv_id, user_id)
return "✓ Interrupted" if success else "No active task."
except Exception as e:
return f"Error: {e}"
async def _cmd_progress(user_id: str) -> str:
"""Show progress of the active session."""
conv_id = agent.get_active_conv(user_id)
if not conv_id:
return "No active session."
progress = manager.get_progress(conv_id, user_id)
if not progress:
return "Session not found."
if not progress.busy:
if progress.last_result:
return f"✅ 已完成\n\n{progress.last_result[:500]}"
return "空闲中,无正在执行的任务。"
elapsed = int(progress.elapsed_seconds)
tools = ", ".join(progress.tool_calls[-3:]) if progress.tool_calls else "none"
pending = ""
if progress.pending_approval:
pending = f"\n⚠️ 等待审批: {progress.pending_approval}"
return f"⏳ 执行中 ({elapsed}s)\n最近工具: {tools}{pending}"
async def _cmd_nodes(user_id: str, args: str) -> str:
"""List nodes or switch active node."""
from config import ROUTER_MODE
if not ROUTER_MODE:
return "Not in router mode. Run standalone.py for multi-host support."
return "Not in router mode."
from router.nodes import get_node_registry
registry = get_node_registry()
@@ -328,23 +468,33 @@ async def _cmd_nodes(user_id: str, args: str) -> str:
marker = "" if n["node_id"] == active_node_id else " "
status = "🟢" if n["status"] == "online" else "🔴"
lines.append(f"{marker}{n['display_name']} {status} sessions={n['sessions']}")
lines.append("\nUse `/node <name>` to switch active node.")
lines.append("\nUse `//node <name>` to switch active node.")
return "\n".join(lines)
def _cmd_help() -> str:
"""Show help."""
return """**Commands:**
/new <dir> [msg] [--timeout N] [--idle N] - Create session
/status - Show sessions and current mode
/close [n] - Close session (active or by number)
/switch <n> - Switch to session by number
/direct - Direct mode: messages → Claude Code (no LLM overhead)
/smart - Smart mode: messages → LLM routing (default)
/shell <cmd> - Run shell command (bypasses LLM)
/remind <time> <msg> - Set reminder (e.g. /remind 10m check build)
/tasks - List background tasks
/nodes - List connected host nodes
/node <name> - Switch active node
/retry - Retry last message
/help - Show this help"""
from config import COMMAND_PREFIX as P
return f"""**Commands:** (prefix: `{P}`)
{P}new <dir> [msg] [--idle N] [--perm MODE] - Create session (alias: {P}n)
{P}list - Show sessions and current mode (alias: {P}ls, {P}l, {P}status)
{P}close [n] - Close session (active or by number) (alias: {P}c)
{P}switch <n> - Switch to session by number (alias: {P}s)
{P}perm <mode> [conv_id] - Set permission mode (alias: {P}perm)
{P}stop - Interrupt the current task (alias: {P}interrupt)
{P}progress - Show task progress (alias: {P}p)
{P}direct - Direct mode: messages → Claude Code
{P}smart - Smart mode: messages → LLM routing (default)
{P}shell <cmd> - Run shell command
{P}remind <time> <msg> - Set reminder (e.g. {P}remind 10m check build)
{P}tasks - List background tasks
{P}nodes - List connected host nodes
{P}node <name> - Switch active node
{P}help - Show this help (alias: {P}h, {P}?)
**Permission modes** (used by {P}perm and {P}new --perm):
default — 默认模式,需审批工具调用
edit — 自动接受文件编辑,shell 命令仍需确认
plan — 只规划、不执行任何写操作
bypass — 跳过所有权限确认
auto — 允许所有工具,不询问"""
+124 -44
View File
@@ -57,52 +57,32 @@ def _split_message(text: str) -> list[str]:
async def send_text(receive_id: str, receive_id_type: str, text: str) -> None:
"""
Send a plain-text message to a Feishu chat or user.
Automatically splits long messages into multiple parts with [1/N] headers.
"""Alias for send_markdown. All messages are sent as markdown cards."""
await send_markdown(receive_id, receive_id_type, text)
Args:
receive_id: chat_id or open_id depending on receive_id_type.
receive_id_type: "chat_id" | "open_id" | "user_id" | "union_id".
text: message content.
async def send_markdown(receive_id: str, receive_id_type: str, content: str) -> None:
"""
parts = _split_message(text)
loop = asyncio.get_running_loop()
Send a markdown card message. Used for LLM/agent replies that contain
formatted text (code blocks, headings, bold, lists, etc.).
Automatically splits long content into multiple cards.
"""
parts = _split_message(content)
for i, part in enumerate(parts):
logger.debug(
"[feishu] send_text to=%s type=%s part=%d/%d len=%d",
receive_id, receive_id_type, i + 1, len(parts), len(part),
)
content = json.dumps({"text": part}, ensure_ascii=False)
request = (
CreateMessageRequest.builder()
.receive_id_type(receive_id_type)
.request_body(
CreateMessageRequestBody.builder()
.receive_id(receive_id)
.msg_type("text")
.content(content)
.build()
)
.build()
)
response = await loop.run_in_executor(
None,
lambda: _client.im.v1.message.create(request),
)
if not response.success():
logger.error(
"Feishu send_text failed: code=%s msg=%s",
response.code,
response.msg,
)
return
else:
logger.debug("Sent message part %d/%d to %s (%s)", i + 1, len(parts), receive_id, receive_id_type)
card = {
"schema": "2.0",
"body": {
"elements": [
{
"tag": "markdown",
"content": part,
}
]
}
}
await send_card(receive_id, receive_id_type, card)
if len(parts) > 1 and i < len(parts) - 1:
await asyncio.sleep(0.3)
@@ -148,8 +128,8 @@ def build_sessions_card(sessions: list[dict], active_conv_id: str | None, mode:
lines = []
for i, s in enumerate(sessions, 1):
marker = "" if s["conv_id"] == active_conv_id else " "
started = "🟢" if s["started"] else "🟡"
lines.append(f"{marker} {i}. {started} `{s['conv_id']}` — `{s['cwd']}`")
status = "🔵" if s.get("busy") else ""
lines.append(f"{marker} {i}. {status} `{s['conv_id']}` — `{s['cwd']}`")
sessions_md = "\n".join(lines)
else:
sessions_md = "_No active sessions_"
@@ -168,6 +148,106 @@ def build_sessions_card(sessions: list[dict], active_conv_id: str | None, mode:
}
def build_approval_card(conv_id: str, tool_name: str, summary: str, timeout: int = 120) -> dict:
"""Build an approval card for a tool call (schema 2.0, with approve/deny buttons).
Note: JSON 2.0 does NOT support "action" wrapper or "note" components.
Buttons go directly in elements; use div with small text for the note.
"""
return {
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": "🔐 权限审批"},
"template": "orange",
},
"body": {
"elements": [
{
"tag": "markdown",
"content": f"**工具:** `{tool_name}`\n**参数:** {summary}",
},
{
"tag": "button",
"text": {"tag": "plain_text", "content": "✅ 批准"},
"type": "primary",
"value": {"action": "approve", "conv_id": conv_id},
},
{
"tag": "button",
"text": {"tag": "plain_text", "content": "❌ 拒绝"},
"type": "danger",
"value": {"action": "deny", "conv_id": conv_id},
},
{
"tag": "div",
"text": {
"tag": "plain_text",
"content": f"超时 {timeout}s 自动拒绝 | 也可回复 y/n",
},
},
],
},
}
def build_question_card(conv_id: str, questions: list[dict]) -> dict:
"""Build a question card for AskUserQuestion (schema 2.0).
Each question's options become buttons. The first question is shown
prominently; multi-question support shows them sequentially.
"""
elements: list[dict] = []
for i, q in enumerate(questions):
question_text = q.get("question", "")
header = q.get("header", "")
options = q.get("options", [])
if header:
elements.append({
"tag": "markdown",
"content": f"**{header}**\n{question_text}",
})
else:
elements.append({
"tag": "markdown",
"content": f"**{question_text}**",
})
for opt in options:
label = opt.get("label", "")
desc = opt.get("description", "")
button_text = f"{label}{desc}" if desc else label
# Truncate long button text
if len(button_text) > 60:
button_text = button_text[:57] + "..."
elements.append({
"tag": "button",
"text": {"tag": "plain_text", "content": button_text},
"type": "default",
"value": {
"action": "answer_question",
"conv_id": conv_id,
"question": question_text,
"answer": label,
},
})
elements.append({
"tag": "div",
"text": {"tag": "plain_text", "content": "也可直接输入文字回复"},
})
return {
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": "❓ Claude Code 提问"},
"template": "blue",
},
"body": {"elements": elements},
}
async def send_file(receive_id: str, receive_id_type: str, file_path: str, file_type: str = "stream") -> None:
"""
Upload a local file to Feishu and send it as a file message.
+198 -5
View File
@@ -13,7 +13,7 @@ import lark_oapi as lark
from lark_oapi.api.im.v1 import P2ImMessageReceiveV1
from bot.commands import handle_command
from bot.feishu import send_text
from bot.feishu import send_text, send_markdown
from config import FEISHU_APP_ID, FEISHU_APP_SECRET, is_user_allowed
from orchestrator.agent import agent
from orchestrator.tools import set_current_chat
@@ -25,6 +25,26 @@ _ws_connected: bool = False
_last_message_time: float = 0.0
_reconnect_count: int = 0
# Deduplication: drop Feishu re-deliveries by (user_id, content) within a short window.
# Feishu retries on network hiccups within ~60s using the same payload.
# We use a 10s window: identical content from the same user within 10s is a re-delivery,
# not a deliberate repeat (user intentional repeats arrive after the bot has already replied).
_recent_messages: dict[tuple[str, str], float] = {} # key: (user_id, content) → timestamp
_DEDUP_WINDOW = 10.0 # seconds
def _is_duplicate(user_id: str, content: str) -> bool:
"""Return True if this (user, content) pair arrived within the dedup window."""
now = time.time()
expired = [k for k, ts in _recent_messages.items() if now - ts > _DEDUP_WINDOW]
for k in expired:
del _recent_messages[k]
key = (user_id, content)
if key in _recent_messages:
return True
_recent_messages[key] = now
return False
def get_ws_status() -> dict[str, Any]:
"""Return WebSocket connection status."""
@@ -79,10 +99,14 @@ def _handle_message(data: P2ImMessageReceiveV1) -> None:
logger.info("Empty text after stripping, ignoring")
return
logger.info("✉ ...%s%r", open_id[-8:], text[:80])
user_id = open_id or chat_id
if _is_duplicate(user_id, text):
logger.info("Dropping duplicate delivery: user=...%s text=%r", user_id[-8:], text[:60])
return
logger.info("✉ ...%s%r", open_id[-8:], text[:80])
if _main_loop is None:
logger.error("Main event loop not set; cannot process message")
return
@@ -105,6 +129,58 @@ async def _process_message(user_id: str, chat_id: str, text: str) -> None:
await send_text(chat_id, "chat_id", "Sorry, you are not authorized to use this bot.")
return
# Text approval fallback: user replies y/n to a pending tool approval
if text.strip().lower() in ("y", "n", "yes", "no"):
approved = text.strip().lower() in ("y", "yes")
from orchestrator.agent import agent as _agent
from agent.manager import manager as _manager
conv_id = _agent.get_active_conv(user_id)
if conv_id:
session = _manager._sessions.get(conv_id)
if (
session
and session.sdk_session
and session.sdk_session._pending_approval
and not session.sdk_session._pending_approval.done()
):
await _manager.approve(conv_id, approved)
label = "✅ 已批准" if approved else "❌ 已拒绝"
await send_text(chat_id, "chat_id", label)
return
# Text answer fallback: any text reply when a question is pending
from orchestrator.agent import agent as _agent
from agent.manager import manager as _manager
conv_id = _agent.get_active_conv(user_id)
if conv_id:
session = _manager._sessions.get(conv_id)
if (
session
and session.sdk_session
and session.sdk_session._pending_question
and not session.sdk_session._pending_question.done()
and session.sdk_session._pending_question_data
):
# Use text as answer to the first pending question
questions = session.sdk_session._pending_question_data.get("questions", [])
if questions:
q_text = questions[0].get("question", "")
await _manager.answer_question(conv_id, {q_text: text.strip()})
await send_text(chat_id, "chat_id", f"✅ 已回答: {text.strip()}")
return
from config import ROUTER_MODE
if ROUTER_MODE:
from router.nodes import get_node_registry
registry = get_node_registry()
is_new = registry.track_user(user_id)
if is_new:
nodes = registry.get_nodes_for_user(user_id)
online = [n for n in nodes if n.is_online]
if online:
names = ", ".join(n.display_name for n in online)
await send_text(chat_id, "chat_id", f"Available nodes: {names}")
reply = await handle_command(user_id, text)
if reply is not None:
if reply:
@@ -141,14 +217,14 @@ async def _process_message(user_id: str, chat_id: str, text: str) -> None:
try:
reply = await forward(node_id, user_id, chat_id, text)
if reply:
await send_text(chat_id, "chat_id", reply)
await send_markdown(chat_id, "chat_id", reply)
except Exception as e:
logger.exception("Failed to forward to node %s", node_id)
await send_text(chat_id, "chat_id", f"Error communicating with node: {e}")
else:
reply = await agent.run(user_id, text)
if reply:
await send_text(chat_id, "chat_id", reply)
await send_markdown(chat_id, "chat_id", reply)
except Exception:
logger.exception("Error processing message for user %s", user_id)
@@ -160,12 +236,129 @@ def _handle_any(data: lark.CustomizedEvent) -> None:
logger.info("RAW CustomizedEvent: %s", marshaled[:500])
def _handle_card_action(data: "P2CardActionTrigger") -> "P2CardActionTriggerResponse":
"""Handle Feishu card button clicks via register_p2_card_action_trigger.
Per docs/feishu/card_callback_communication.md:
- Must respond within 3 seconds
- Return P2CardActionTriggerResponse with toast + updated card
"""
from lark_oapi.event.callback.model.p2_card_action_trigger import (
CallBackCard, CallBackToast, P2CardActionTriggerResponse,
)
def _response(toast_type: str, toast_text: str, card_data: dict) -> P2CardActionTriggerResponse:
resp = P2CardActionTriggerResponse()
toast = CallBackToast()
toast.type = toast_type
toast.content = toast_text
resp.toast = toast
card = CallBackCard()
card.type = "raw"
card.data = card_data
resp.card = card
return resp
def _empty_response() -> P2CardActionTriggerResponse:
return P2CardActionTriggerResponse()
try:
event = data.event
if not event:
return _empty_response()
action = event.action
if not action:
return _empty_response()
value: dict = action.value or {}
action_type = value.get("action")
conv_id = value.get("conv_id")
if not action_type or not conv_id:
logger.debug("Card action without action/conv_id: %s", value)
return _empty_response()
operator_open_id = (event.operator.open_id or "") if event.operator else ""
logger.info(
"Card action: %s for session %s by ...%s",
action_type, conv_id, operator_open_id[-8:],
)
# --- AskUserQuestion answer ---
if action_type == "answer_question":
question = value.get("question", "")
answer = value.get("answer", "")
if _main_loop:
asyncio.run_coroutine_threadsafe(
_handle_question_answer_async(conv_id, question, answer), _main_loop
)
return _response(
"success", f"已选择: {answer}",
{
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": "❓ Claude Code 提问"},
"template": "green",
},
"body": {
"elements": [
{"tag": "markdown", "content": f"**{question}**\n\n✅ 已选择: **{answer}**"},
],
},
},
)
# --- Tool approval ---
approved = action_type == "approve"
if _main_loop:
asyncio.run_coroutine_threadsafe(
_handle_approval_async(conv_id, approved), _main_loop
)
if approved:
toast_type, toast_text = "success", "✅ 已批准"
card_status, template = "✅ **已批准**", "green"
else:
toast_type, toast_text = "warning", "❌ 已拒绝"
card_status, template = "❌ **已拒绝**", "red"
return _response(
toast_type, toast_text,
{
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": "🔐 权限审批"},
"template": template,
},
"body": {"elements": [{"tag": "markdown", "content": card_status}]},
},
)
except Exception:
logger.exception("Error handling card action")
return P2CardActionTriggerResponse()
async def _handle_approval_async(conv_id: str, approved: bool) -> None:
"""Process a card approval action."""
from agent.manager import manager
await manager.approve(conv_id, approved)
async def _handle_question_answer_async(conv_id: str, question: str, answer: str) -> None:
"""Process a question answer from card callback."""
from agent.manager import manager
await manager.answer_question(conv_id, {question: answer})
def build_event_handler() -> lark.EventDispatcherHandler:
"""Construct the EventDispatcherHandler with all registered callbacks."""
handler = (
lark.EventDispatcherHandler.builder("", "")
.register_p2_im_message_receive_v1(_handle_message)
.register_p1_customized_event("im.message.receive_v1", _handle_any)
.register_p2_card_action_trigger(_handle_card_action)
.build()
)
return handler
+14 -1
View File
@@ -3,10 +3,12 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
_CONFIG_PATH = Path(__file__).parent / "keyring.yaml"
_HOST_CONFIG_PATH = Path(__file__).parent / "host_config.yaml"
def _load() -> dict[str, Any]:
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
config_path = _HOST_CONFIG_PATH if _HOST_CONFIG_PATH.exists() else _CONFIG_PATH
with open(config_path, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
@@ -19,10 +21,21 @@ OPENAI_API_KEY: str = _cfg["OPENAI_API_KEY"]
OPENAI_MODEL: str = _cfg.get("OPENAI_MODEL", "glm-4.7")
WORKING_DIR: Path = Path(_cfg.get("WORKING_DIR", Path.home())).expanduser().resolve()
METASO_API_KEY: str = _cfg.get("METASO_API_KEY", "")
ANTHROPIC_API_KEY: str = _cfg.get("ANTHROPIC_API_KEY", "")
# SDK approval timeout (seconds) for can_use_tool callback
SDK_APPROVAL_TIMEOUT: int = _cfg.get("SDK_APPROVAL_TIMEOUT", 120)
ROUTER_MODE: bool = _cfg.get("ROUTER_MODE", False)
ROUTER_SECRET: str = _cfg.get("ROUTER_SECRET", "")
# Command prefix — the leader string that identifies bot commands.
# Default is "//" to avoid conflicts with Claude Code's own "/" commands.
COMMAND_PREFIX: str = _cfg.get("COMMAND_PREFIX", "//")
# Server configuration
PORT: int = _cfg.get("PORT", 8000)
_allowed_open_ids_raw = _cfg.get("ALLOWED_OPEN_IDS", [])
ALLOWED_OPEN_IDS: list[str] = _allowed_open_ids_raw if isinstance(_allowed_open_ids_raw, list) else [str(_allowed_open_ids_raw)]
+18
View File
@@ -0,0 +1,18 @@
"""
Root conftest — runs before pytest collects any test files or imports any
production modules. Creates a temporary keyring.yaml from the test keyring
so that `import config` works without the real keyring.yaml.
Must live at the repo root (not inside tests/) to fire before collection.
"""
import shutil
from pathlib import Path
_REPO_ROOT = Path(__file__).parent
_TEST_KEYRING = _REPO_ROOT / "tests" / "keyring_test.yaml"
_KEYRING = _REPO_ROOT / "keyring.yaml"
# If the real keyring.yaml doesn't exist, copy the test version so config.py
# can load at module import time. This file is gitignored.
if not _KEYRING.exists() and _TEST_KEYRING.exists():
shutil.copy2(_TEST_KEYRING, _KEYRING)
+75
View File
@@ -0,0 +1,75 @@
"""Shared helpers for Claude Agent SDK tests.
Loads ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN from the project root .env
and injects them into the process environment and ClaudeAgentOptions.env.
"""
from __future__ import annotations
import os
import shutil
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DOTENV_PATH = PROJECT_ROOT / ".env"
def load_dotenv(dotenv_path: Path = DOTENV_PATH) -> dict[str, str]:
"""Parse KEY=VALUE lines from a .env file (no third-party deps)."""
values: dict[str, str] = {}
if not dotenv_path.exists():
return values
for raw_line in dotenv_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip('"').strip("'")
return values
def setup_auth() -> tuple[bool, str]:
"""Inject .env auth vars into os.environ; clear ANTHROPIC_API_KEY to avoid conflicts."""
values = load_dotenv()
base_url = values.get("ANTHROPIC_BASE_URL")
auth_token = values.get("ANTHROPIC_AUTH_TOKEN")
oauth_token = values.get("CLAUDE_CODE_OAUTH_TOKEN")
if not base_url:
return False, "Missing ANTHROPIC_BASE_URL in .env"
if not auth_token and not oauth_token:
return False, "Missing ANTHROPIC_AUTH_TOKEN or CLAUDE_CODE_OAUTH_TOKEN in .env"
os.environ["ANTHROPIC_BASE_URL"] = base_url
if auth_token:
os.environ["ANTHROPIC_AUTH_TOKEN"] = auth_token
if oauth_token:
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
os.environ.pop("ANTHROPIC_API_KEY", None)
return True, f"Auth loaded from {DOTENV_PATH}"
def auth_env() -> dict[str, str]:
"""Return env dict suitable for ClaudeAgentOptions(env=...)."""
return {
"ANTHROPIC_BASE_URL": os.environ["ANTHROPIC_BASE_URL"],
"ANTHROPIC_AUTH_TOKEN": os.environ.get("ANTHROPIC_AUTH_TOKEN", ""),
"CLAUDE_CODE_OAUTH_TOKEN": os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", ""),
}
def make_tmpdir(prefix: str = "sdk-test-") -> Path:
"""Create a temporary directory manually (caller is responsible for cleanup)."""
return Path(tempfile.mkdtemp(prefix=prefix))
def remove_tmpdir(path: Path) -> None:
"""Remove a temporary directory, retrying once on Windows lock errors."""
try:
shutil.rmtree(path)
except OSError:
import time
time.sleep(1)
shutil.rmtree(path, ignore_errors=True)
+394
View File
@@ -0,0 +1,394 @@
# Agent SDK overview
Build production AI agents with Claude Code as a library
---
<Note>
The Claude Code SDK has been renamed to the Claude Agent SDK. If you're migrating from the old SDK, see the [Migration Guide](/docs/en/agent-sdk/migration-guide).
</Note>
Build AI agents that autonomously read files, run commands, search the web, edit code, and more. The Agent SDK gives you the same tools, agent loop, and context management that power Claude Code, programmable in Python and TypeScript.
```python
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions
async def main():
async for message in query(
prompt="Find and fix the bug in auth.py",
options=ClaudeAgentOptions(allowed_tools=["Read", "Edit", "Bash"]),
):
print(message) # Claude reads the file, finds the bug, edits it
asyncio.run(main())
```
The Agent SDK includes built-in tools for reading files, running commands, and editing code, so your agent can start working immediately without you implementing tool execution. Dive into the quickstart or explore real agents built with the SDK:
<CardGroup cols={2}>
<Card title="Quickstart" icon="play" href="/docs/en/agent-sdk/quickstart">
Build a bug-fixing agent in minutes
</Card>
<Card title="Example agents" icon="star" href="https://github.com/anthropics/claude-agent-sdk-demos">
Email assistant, research agent, and more
</Card>
</CardGroup>
## Get started
<Steps>
<Step title="Install the SDK">
```bash
pip install claude-agent-sdk
```
</Step>
<Step title="Set your API key">
Get an API key from the [Console](https://platform.claude.com/), then set it as an environment variable:
```bash
export ANTHROPIC_API_KEY=your-api-key
```
The SDK also supports authentication via third-party API providers:
- **Amazon Bedrock**: set `CLAUDE_CODE_USE_BEDROCK=1` environment variable and configure AWS credentials
- **Google Vertex AI**: set `CLAUDE_CODE_USE_VERTEX=1` environment variable and configure Google Cloud credentials
- **Microsoft Azure**: set `CLAUDE_CODE_USE_FOUNDRY=1` environment variable and configure Azure credentials
See the setup guides for [Bedrock](https://code.claude.com/docs/en/amazon-bedrock), [Vertex AI](https://code.claude.com/docs/en/google-vertex-ai), or [Azure AI Foundry](https://code.claude.com/docs/en/azure-ai-foundry) for details.
<Note>
Unless previously approved, Anthropic does not allow third party developers to offer claude.ai login or rate limits for their products, including agents built on the Claude Agent SDK. Please use the API key authentication methods described in this document instead.
</Note>
</Step>
<Step title="Run your first agent">
This example creates an agent that lists files in your current directory using built-in tools.
```python
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions
async def main():
async for message in query(
prompt="What files are in this directory?",
options=ClaudeAgentOptions(allowed_tools=["Bash", "Glob"]),
):
if hasattr(message, "result"):
print(message.result)
asyncio.run(main())
```
</Step>
</Steps>
**Ready to build?** Follow the [Quickstart](/docs/en/agent-sdk/quickstart) to create an agent that finds and fixes bugs in minutes.
## Capabilities
Everything that makes Claude Code powerful is available in the SDK:
<Tabs>
<Tab title="Built-in tools">
Your agent can read files, run commands, and search codebases out of the box. Key tools include:
| Tool | What it does |
|------|--------------|
| **Read** | Read any file in the working directory |
| **Write** | Create new files |
| **Edit** | Make precise edits to existing files |
| **Bash** | Run terminal commands, scripts, git operations |
| **Glob** | Find files by pattern (`**/*.ts`, `src/**/*.py`) |
| **Grep** | Search file contents with regex |
| **WebSearch** | Search the web for current information |
| **WebFetch** | Fetch and parse web page content |
| **[AskUserQuestion](/docs/en/agent-sdk/user-input#handle-clarifying-questions)** | Ask the user clarifying questions with multiple choice options |
This example creates an agent that searches your codebase for TODO comments:
```python
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions
async def main():
async for message in query(
prompt="Find all TODO comments and create a summary",
options=ClaudeAgentOptions(allowed_tools=["Read", "Glob", "Grep"]),
):
if hasattr(message, "result"):
print(message.result)
asyncio.run(main())
```
</Tab>
<Tab title="Hooks">
Run custom code at key points in the agent lifecycle. SDK hooks use callback functions to validate, log, block, or transform agent behavior.
**Available hooks:** `PreToolUse`, `PostToolUse`, `Stop`, `SessionStart`, `SessionEnd`, `UserPromptSubmit`, and more.
This example logs all file changes to an audit file:
```python
import asyncio
from datetime import datetime
from claude_agent_sdk import query, ClaudeAgentOptions, HookMatcher
async def log_file_change(input_data, tool_use_id, context):
file_path = input_data.get("tool_input", {}).get("file_path", "unknown")
with open("./audit.log", "a") as f:
f.write(f"{datetime.now()}: modified {file_path}\n")
return {}
async def main():
async for message in query(
prompt="Refactor utils.py to improve readability",
options=ClaudeAgentOptions(
permission_mode="acceptEdits",
hooks={
"PostToolUse": [
HookMatcher(matcher="Edit|Write", hooks=[log_file_change])
]
},
),
):
if hasattr(message, "result"):
print(message.result)
asyncio.run(main())
```
[Learn more about hooks →](/docs/en/agent-sdk/hooks)
</Tab>
<Tab title="Subagents">
Spawn specialized agents to handle focused subtasks. Your main agent delegates work, and subagents report back with results.
Define custom agents with specialized instructions. Include `Agent` in `allowedTools` since subagents are invoked via the Agent tool:
```python
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, AgentDefinition
async def main():
async for message in query(
prompt="Use the code-reviewer agent to review this codebase",
options=ClaudeAgentOptions(
allowed_tools=["Read", "Glob", "Grep", "Agent"],
agents={
"code-reviewer": AgentDefinition(
description="Expert code reviewer for quality and security reviews.",
prompt="Analyze code quality and suggest improvements.",
tools=["Read", "Glob", "Grep"],
)
},
),
):
if hasattr(message, "result"):
print(message.result)
asyncio.run(main())
```
Messages from within a subagent's context include a `parent_tool_use_id` field, letting you track which messages belong to which subagent execution.
[Learn more about subagents →](/docs/en/agent-sdk/subagents)
</Tab>
<Tab title="MCP">
Connect to external systems via the Model Context Protocol: databases, browsers, APIs, and [hundreds more](https://github.com/modelcontextprotocol/servers).
This example connects the [Playwright MCP server](https://github.com/microsoft/playwright-mcp) to give your agent browser automation capabilities:
```python
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions
async def main():
async for message in query(
prompt="Open example.com and describe what you see",
options=ClaudeAgentOptions(
mcp_servers={
"playwright": {"command": "npx", "args": ["@playwright/mcp@latest"]}
}
),
):
if hasattr(message, "result"):
print(message.result)
asyncio.run(main())
```
[Learn more about MCP →](/docs/en/agent-sdk/mcp)
</Tab>
<Tab title="Permissions">
Control exactly which tools your agent can use. Allow safe operations, block dangerous ones, or require approval for sensitive actions.
<Note>
For interactive approval prompts and the `AskUserQuestion` tool, see [Handle approvals and user input](/docs/en/agent-sdk/user-input).
</Note>
This example creates a read-only agent that can analyze but not modify code. `allowed_tools` pre-approves `Read`, `Glob`, and `Grep`.
```python
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions
async def main():
async for message in query(
prompt="Review this code for best practices",
options=ClaudeAgentOptions(
allowed_tools=["Read", "Glob", "Grep"],
),
):
if hasattr(message, "result"):
print(message.result)
asyncio.run(main())
```
[Learn more about permissions →](/docs/en/agent-sdk/permissions)
</Tab>
<Tab title="Sessions">
Maintain context across multiple exchanges. Claude remembers files read, analysis done, and conversation history. Resume sessions later, or fork them to explore different approaches.
This example captures the session ID from the first query, then resumes to continue with full context:
```python
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions
async def main():
session_id = None
# First query: capture the session ID
async for message in query(
prompt="Read the authentication module",
options=ClaudeAgentOptions(allowed_tools=["Read", "Glob"]),
):
if hasattr(message, "subtype") and message.subtype == "init":
session_id = message.session_id
# Resume with full context from the first query
async for message in query(
prompt="Now find all places that call it", # "it" = auth module
options=ClaudeAgentOptions(resume=session_id),
):
if hasattr(message, "result"):
print(message.result)
asyncio.run(main())
```
[Learn more about sessions →](/docs/en/agent-sdk/sessions)
</Tab>
</Tabs>
### Claude Code features
The SDK also supports Claude Code's filesystem-based configuration. To use these features, set `setting_sources=["project"]` in your options.
| Feature | Description | Location |
|---------|-------------|----------|
| [Skills](/docs/en/agent-sdk/skills) | Specialized capabilities defined in Markdown | `.claude/skills/*/SKILL.md` |
| [Slash commands](/docs/en/agent-sdk/slash-commands) | Custom commands for common tasks | `.claude/commands/*.md` |
| [Memory](/docs/en/agent-sdk/modifying-system-prompts) | Project context and instructions | `CLAUDE.md` or `.claude/CLAUDE.md` |
| [Plugins](/docs/en/agent-sdk/plugins) | Extend with custom commands, agents, and MCP servers | Programmatic via `plugins` option |
## Compare the Agent SDK to other Claude tools
The Claude Platform offers multiple ways to build with Claude. Here's how the Agent SDK fits in:
<Tabs>
<Tab title="Agent SDK vs Client SDK">
The [Anthropic Client SDK](/docs/en/api/client-sdks) gives you direct API access: you send prompts and implement tool execution yourself. The **Agent SDK** gives you Claude with built-in tool execution.
With the Client SDK, you implement a tool loop. With the Agent SDK, Claude handles it:
```python
# Client SDK: You implement the tool loop
response = client.messages.create(...)
while response.stop_reason == "tool_use":
result = your_tool_executor(response.tool_use)
response = client.messages.create(tool_result=result, **params)
# Agent SDK: Claude handles tools autonomously
async for message in query(prompt="Fix the bug in auth.py"):
print(message)
```
</Tab>
<Tab title="Agent SDK vs Claude Code CLI">
Same capabilities, different interface:
| Use case | Best choice |
|----------|-------------|
| Interactive development | CLI |
| CI/CD pipelines | SDK |
| Custom applications | SDK |
| One-off tasks | CLI |
| Production automation | SDK |
Many teams use both: CLI for daily development, SDK for production. Workflows translate directly between them.
</Tab>
</Tabs>
## Changelog
View the full changelog for SDK updates, bug fixes, and new features:
- **Python SDK**: [view CHANGELOG.md](https://github.com/anthropics/claude-agent-sdk-python/blob/main/CHANGELOG.md)
## Reporting bugs
If you encounter bugs or issues with the Agent SDK:
- **Python SDK**: [report issues on GitHub](https://github.com/anthropics/claude-agent-sdk-python/issues)
## Branding guidelines
For partners integrating the Claude Agent SDK, use of Claude branding is optional. When referencing Claude in your product:
**Allowed:**
- "Claude Agent" (preferred for dropdown menus)
- "Claude" (when within a menu already labeled "Agents")
- "{YourAgentName} Powered by Claude" (if you have an existing agent name)
**Not permitted:**
- "Claude Code" or "Claude Code Agent"
- Claude Code-branded ASCII art or visual elements that mimic Claude Code
Your product should maintain its own branding and not appear to be Claude Code or any Anthropic product. For questions about branding compliance, contact the Anthropic [sales team](https://www.anthropic.com/contact-sales).
## License and terms
Use of the Claude Agent SDK is governed by [Anthropic's Commercial Terms of Service](https://www.anthropic.com/legal/commercial-terms), including when you use it to power products and services that you make available to your own customers and end users, except to the extent a specific component or dependency is covered by a different license as indicated in that component's LICENSE file.
## Next steps
<CardGroup cols={2}>
<Card title="Quickstart" icon="play" href="/docs/en/agent-sdk/quickstart">
Build an agent that finds and fixes bugs in minutes
</Card>
<Card title="Example agents" icon="star" href="https://github.com/anthropics/claude-agent-sdk-demos">
Email assistant, research agent, and more
</Card>
<Card title="Python SDK" icon="code" href="/docs/en/agent-sdk/python">
Full Python API reference and examples
</Card>
</CardGroup>
+160
View File
@@ -0,0 +1,160 @@
"""Example: can_use_tool callback — intercept AskUserQuestion and provide answers.
Demonstrates:
- can_use_tool callback for tool permission control
- Detecting AskUserQuestion tool calls
- Using PermissionResultAllow(updated_input=...) to pre-fill user answers
- Auto-allowing read-only tools
This pattern is used by the secretary model to forward CC questions
to Feishu users and relay their responses back.
Usage:
cd docs/claude
../../.venv/Scripts/python test_can_use_tool_ask.py
"""
from __future__ import annotations
import asyncio
import json
from _sdk_test_common import auth_env, make_tmpdir, remove_tmpdir, setup_auth
# Simulated user responses (in production, these come from Feishu card callbacks)
SIMULATED_ANSWERS: dict[str, str] = {}
permission_log: list[dict] = []
async def permission_callback(tool_name, input_data, context):
"""can_use_tool callback that intercepts AskUserQuestion.
For AskUserQuestion:
- Extracts questions and options from input_data
- Provides pre-filled answers via updated_input
- Returns PermissionResultAllow with the modified input
For other tools:
- Read-only tools: auto-allow
- Write tools: auto-allow (for testing)
"""
from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
permission_log.append({
"tool": tool_name,
"input_keys": list(input_data.keys()),
})
if tool_name == "AskUserQuestion":
questions = input_data.get("questions", [])
print(f"\n 🔔 AskUserQuestion intercepted! ({len(questions)} questions)")
# Build answers dict: {question_text: selected_option_label}
answers = {}
for q in questions:
question_text = q.get("question", "")
options = q.get("options", [])
multi = q.get("multiSelect", False)
print(f" Q: {question_text}")
for opt in options:
print(f" - {opt['label']}: {opt.get('description', '')[:60]}")
# In production: send card to Feishu, wait for user selection
# Here: use first option as simulated answer
if options:
selected = SIMULATED_ANSWERS.get(question_text, options[0]["label"])
answers[question_text] = selected
print(f" → Selected: {selected}")
# Pre-fill answers in the tool input via updated_input
modified_input = dict(input_data)
if "answers" not in modified_input:
modified_input["answers"] = {}
if isinstance(modified_input["answers"], dict):
modified_input["answers"].update(answers)
print(f" → updated_input.answers = {answers}")
return PermissionResultAllow(updated_input=modified_input)
# Auto-allow everything else for this test
return PermissionResultAllow()
async def main() -> int:
ok, msg = setup_auth()
print(msg)
if not ok:
return 1
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
ResultMessage,
SystemMessage,
TextBlock,
ToolUseBlock,
)
tmpdir = make_tmpdir("ask-test-")
try:
print("--- Test: can_use_tool intercepts AskUserQuestion ---")
print(f" tmpdir: {tmpdir}")
opts = ClaudeAgentOptions(
cwd=str(tmpdir),
permission_mode="default",
can_use_tool=permission_callback,
max_turns=5,
env=auth_env(),
)
# Ask Claude to ask the user a question — this will trigger AskUserQuestion
prompt = (
"I need you to ask the user a question using the AskUserQuestion tool. "
"Ask them: 'Which programming language do you prefer?' "
"with options: 'Python' (great for AI), 'TypeScript' (great for web). "
"Then tell me what they chose."
)
print(f"\n Prompt: {prompt[:100]}...")
async with ClaudeSDKClient(opts) as client:
await client.query(prompt)
ask_seen = False
result_text = ""
async for msg in client.receive_response():
if isinstance(msg, AssistantMessage):
for block in msg.content:
if isinstance(block, TextBlock):
print(f" Claude: {block.text[:200]}")
elif isinstance(block, ToolUseBlock):
if block.name == "AskUserQuestion":
ask_seen = True
print(f" [ToolUse] AskUserQuestion called!")
else:
print(f" [ToolUse] {block.name}")
elif isinstance(msg, ResultMessage):
result_text = msg.result or ""
print(f"\n Result: {result_text[:300]}")
print(f"\n Permission log ({len(permission_log)} entries):")
for entry in permission_log:
print(f" {entry['tool']}: keys={entry['input_keys']}")
if ask_seen:
print("\n ✅ PASS: AskUserQuestion was intercepted by can_use_tool")
else:
print("\n ⚠️ AskUserQuestion was not called (Claude may have answered directly)")
print(" This is OK — it means Claude didn't need to ask.")
return 0
finally:
remove_tmpdir(tmpdir)
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
+116
View File
@@ -0,0 +1,116 @@
"""Example: ClaudeSDKClient — Write file + resume session.
Demonstrates:
- ClaudeSDKClient (stateful, interactive mode)
- Creating a file via Write tool
- Capturing session_id from SystemMessage
- Resuming a session with options.resume
- receive_response() for streaming messages
- Manual tmpdir management
Usage:
cd docs/claude
../../.venv/Scripts/python test_client_write_resume.py
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from _sdk_test_common import auth_env, make_tmpdir, remove_tmpdir, setup_auth
async def drain(client):
"""Collect text from receive_response(), return (session_id, joined_text)."""
from claude_agent_sdk import AssistantMessage, ResultMessage, SystemMessage, TextBlock
texts: list[str] = []
session_id = None
async for msg in client.receive_response():
if isinstance(msg, SystemMessage) and msg.subtype == "init":
session_id = msg.data.get("session_id")
elif isinstance(msg, AssistantMessage):
for block in msg.content:
if isinstance(block, TextBlock):
texts.append(block.text)
elif isinstance(msg, ResultMessage):
session_id = session_id or msg.session_id
if msg.result:
texts.append(msg.result)
return session_id, "\n".join(texts)
async def main() -> int:
ok, msg = setup_auth()
print(msg)
if not ok:
return 1
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
tmpdir = make_tmpdir("write-resume-")
try:
target = tmpdir / "session_test.txt"
# --- Turn 1: create file ---
print("--- Turn 1: Write ---")
async with ClaudeSDKClient(
ClaudeAgentOptions(
cwd=str(tmpdir),
allowed_tools=["Write", "Read"],
permission_mode="acceptEdits",
max_turns=3,
env=auth_env(),
)
) as client:
await client.query(
f"Use the Write tool to create session_test.txt "
f"in {tmpdir} with exactly the content 'Session 1'."
)
session_id, response = await drain(client)
print(f" Response: {response[:200]}")
if not target.exists():
print("FAIL: file not created")
return 2
print(f" File content: {target.read_text(encoding='utf-8')!r}")
print(f" Session ID: {session_id}")
if not session_id:
print("FAIL: no session_id captured")
return 3
# --- Turn 2: resume and verify context ---
print("\n--- Turn 2: Resume ---")
async with ClaudeSDKClient(
ClaudeAgentOptions(
cwd=str(tmpdir),
resume=session_id,
allowed_tools=["Read"],
permission_mode="acceptEdits",
max_turns=2,
env=auth_env(),
)
) as resumed:
await resumed.query(
"Without modifying files, tell me the exact filename and content you created."
)
_, resume_response = await drain(resumed)
print(f" Response: {resume_response[:300]}")
content_ok = target.read_text(encoding="utf-8").strip() == "Session 1"
resume_ok = "session_test.txt" in resume_response and "Session 1" in resume_response
if content_ok and resume_ok:
print("\nPASS")
return 0
print(f"\nFAIL: content_ok={content_ok} resume_ok={resume_ok}")
return 4
finally:
remove_tmpdir(tmpdir)
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
+111
View File
@@ -0,0 +1,111 @@
"""Example: Hooks — audit log + PreToolUse deny.
Demonstrates:
- PostToolUse hook for audit logging
- PreToolUse hook to deny dangerous commands
- HookMatcher pattern matching by tool name
- Hook callback signature and return format
Usage:
cd docs/claude
../../.venv/Scripts/python test_hooks_audit_deny.py
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from _sdk_test_common import auth_env, make_tmpdir, remove_tmpdir, setup_auth
audit_log: list[dict] = []
async def audit_hook(input_data, tool_use_id, context):
"""PostToolUse: record every tool invocation."""
audit_log.append({
"tool": input_data.get("tool_name"),
"input": input_data.get("tool_input"),
})
return {}
async def deny_rm_hook(input_data, tool_use_id, context):
"""PreToolUse: block 'rm' commands."""
if input_data.get("tool_name") != "Bash":
return {}
command = input_data.get("tool_input", {}).get("command", "")
if "rm " in command:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "rm commands are blocked by policy",
}
}
return {}
async def main() -> int:
ok, msg = setup_auth()
print(msg)
if not ok:
return 1
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
HookMatcher,
ResultMessage,
TextBlock,
query,
)
tmpdir = make_tmpdir("hooks-")
try:
(tmpdir / "data.txt").write_text("important data\n", encoding="utf-8")
print("--- Query with hooks ---")
opts = ClaudeAgentOptions(
cwd=str(tmpdir),
allowed_tools=["Bash", "Read"],
permission_mode="acceptEdits",
max_turns=4,
env=auth_env(),
hooks={
"PostToolUse": [HookMatcher(matcher="Bash|Read", hooks=[audit_hook])],
"PreToolUse": [HookMatcher(matcher="Bash", hooks=[deny_rm_hook])],
},
)
async for msg in query(
prompt=(
"Do these steps in order:\n"
"1. Read data.txt\n"
"2. Run 'echo hello'\n"
"3. Run 'rm data.txt'\n"
"Report what happened for each step."
),
options=opts,
):
if isinstance(msg, AssistantMessage):
for block in msg.content:
if isinstance(block, TextBlock):
print(f" Claude: {block.text[:300]}")
elif isinstance(msg, ResultMessage):
print(f" Result: {msg.result!r:.300}")
print(f"\nAudit log ({len(audit_log)} entries):")
for entry in audit_log:
print(f" {entry['tool']}: {entry['input']}")
file_exists = (tmpdir / "data.txt").exists()
print(f"\ndata.txt still exists: {file_exists}")
print("PASS" if file_exists else "FAIL: rm was not blocked")
return 0 if file_exists else 2
finally:
remove_tmpdir(tmpdir)
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
+96
View File
@@ -0,0 +1,96 @@
"""Example: query() one-shot — Read and Edit files.
Demonstrates:
- .env-based auth (ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN)
- query() async iterator for simple one-shot tasks
- allowed_tools for auto-approval
- permission_mode="acceptEdits"
- Reading and editing a file in cwd
- Manual tmpdir management (avoids Windows cleanup races)
Usage:
cd docs/claude
../../.venv/Scripts/python test_query_read_edit.py
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from _sdk_test_common import auth_env, make_tmpdir, remove_tmpdir, setup_auth
async def main() -> int:
ok, msg = setup_auth()
print(msg)
if not ok:
return 1
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ResultMessage,
TextBlock,
ToolUseBlock,
query,
)
tmpdir = make_tmpdir("read-edit-")
try:
test_file = tmpdir / "hello.txt"
test_file.write_text("hello world\n", encoding="utf-8")
print(f"cwd: {tmpdir}")
# --- Step 1: Read ---
print("\n--- Read ---")
opts = ClaudeAgentOptions(
cwd=str(tmpdir),
allowed_tools=["Read"],
permission_mode="acceptEdits",
max_turns=2,
env=auth_env(),
)
async for msg in query(prompt="Read hello.txt and show its content.", options=opts):
if isinstance(msg, AssistantMessage):
for block in msg.content:
if isinstance(block, ToolUseBlock):
print(f" ToolUse: {block.name}({block.input})")
elif isinstance(block, TextBlock):
print(f" Claude: {block.text[:200]}")
elif isinstance(msg, ResultMessage):
print(f" Result: {msg.result!r:.200}")
# --- Step 2: Edit ---
print("\n--- Edit ---")
opts = ClaudeAgentOptions(
cwd=str(tmpdir),
allowed_tools=["Read", "Edit"],
permission_mode="acceptEdits",
max_turns=3,
env=auth_env(),
)
async for msg in query(
prompt=f"Edit hello.txt in {tmpdir}. Add '# edited' as the first line.",
options=opts,
):
if isinstance(msg, AssistantMessage):
for block in msg.content:
if isinstance(block, ToolUseBlock):
print(f" ToolUse: {block.name}({block.input})")
elif isinstance(block, TextBlock):
print(f" Claude: {block.text[:200]}")
elif isinstance(msg, ResultMessage):
print(f" Result: {msg.result!r:.200}")
content = test_file.read_text(encoding="utf-8")
print(f"\nFile after edit:\n{content}")
ok = "# edited" in content
print("PASS" if ok else "FAIL")
return 0 if ok else 2
finally:
remove_tmpdir(tmpdir)
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
+72
View File
@@ -0,0 +1,72 @@
# 回调概述
回调适用于需要对用户行为进行同步响应的业务场景,即当用户在飞书中触发某些操作时,前端加载等待服务端返回响应数据。待服务端返回响应结果时,前端加载完成,并向用户展示返回的响应结果。
在飞书业务中,回调功能的典型使用场景如下:
- **卡片交互场景**:用户点击卡片上的交互组件(比如审批卡片上的同意/拒绝按钮),开发者的服务端将收到按钮的点击回调,并且需要立即响应更新后的卡片内容,给予用户操作反馈(比如把审批状态流转为已审批)。
- **链接预览场景**:用户在聊天中查看某个应用链接,该链接支持返回应用配置的[预览数据](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/development-link-preview/link-preview-development-guide),此时该应用的服务端会收到[拉取链接预览数据](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/development-link-preview/pull-link-preview-data-callback-structure)的回调,并且需要立即响应返回链接预览内容,从而使终端用户看到链接预览效果。
## 回调与事件的区别
![](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/88c9a80327f80bc5ddce17babe63f64d_bWeBkMFagD.png?height=440&lazyload=true&maxWidth=600&width=1600)
回调与[事件](https://open.feishu.cn/document/ukTMukTMukTM/uUTNz4SN1MjL1UzM)相似但又有不同:
- **相似点:**
- 都是飞书服务器主动向开发者服务器推送数据。
- 回调与事件有相似的数据结构,可以复用同一套加密解密策略,开发者在解析飞书返回的内容时,可以采取同一套策略。
- **差异点:**
- 订阅回调后,开发者服务器需要**立即返回**响应内容,以反馈用户操作,而事件则不要求返回。
- 回调是同步操作,不提供补推机制,超时未响应即认为这次回调失败,前端会展示报错等平台提供的兜底响应策略。
- 事件是异步操作,开发者只需简单响应飞书服务器是否收到事件即可,如开发者未响应,则平台会补推送事件。
## 订阅流程
步骤 | 说明
---|---
1. 选择回调订阅方式 | 回调订阅方式分为 **使用长连接接收回调****将回调发送至开发者服务器** 两种,你可以根据需要自行选择任一订阅方式。<br>**注意事项**- **使用长连接接收回调** 方式是飞书 SDK 内提供的能力,你可以通过集成飞书 SDK 与开放平台建立一条 WebSocket 全双工通道(你的服务器需要能够访问公网)。后续当应用订阅的回调发生时,开放平台会通过该通道向你的服务器发送消息。详细配置说明参见[使用长连接接收回调](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/callback-subscription/configure-callback-request-address)。<br>- **将回调发送至开发者服务器** 方式是传统的 Webhook 模式,该方式需要你提供用于接收回调消息的服务器公网地址。后续当应用订阅的回调发生时,开放平台会向服务器的公网地址发送 HTTP POST 请求,请求内包含回调数据。详细配置说明参见[将回调发送至开发者服务器](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/callback-subscription/step-1-choose-a-subscription-mode/send-callbacks-to-developers-server)。
2. 添加所需回调 | 完成回调订阅方式配置后,即可为应用添加所需订阅的回调,并发布应用使配置生效。具体操作参见[添加回调](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/callback-subscription/add-callback)。
3. 接收回调 | 根据不同的回调订阅方式接收回调:<br>- **使用长连接接收回调** 方式已经封装了鉴权逻辑,无需进行数据解密与验签操作,直接接收来自开放平台的回调请求即可。<br>- **将回调发送至开发者服务器** 方式需要你根据应用的加密策略进行安全校验,如果是加密回调,需要先解密回调,再解析回调详情。具体操作参见[接收回调](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/callback-subscription/receive-and-handle-callbacks)。
## 回调结构
回调返回的数据结构与事件类似。以“拉取链接预览数据”(`url.preview.get`)为例,回调的结构示意如下:
```json
{
"schema": "2.0", //表示回调的版本,2.0表示这个回调结构与事件的2.0版本在形式上一致
"header": { //回调的通用参数
"token": "vi57noNQoGbhxxxxxWmmWdlsSn3FTzk1", //对应 Verification Token
"create_time": "170134xxxxx18480", //回调发送的时间戳,近似于回调触发的时间
"event_type": "url.preview.get", //回调类型
"tenant_key": "736xxxxx260f175d", //回调所属应用的租户id
"app_id": "cli_a40xxxxxe57e100c" //回调所属应用的应用id
},
"event": { //记录不同回调类型返回的详细的上下文信息
"operator": {
"tenant_key": "736588cxxxx175d",
"user_id": "c3xxxxd1",
"open_id": "ou_xxxxx54182ea7b8319f4d39823b79d2"
},
"host": "im_message", //链接所在的宿主场景。枚举包括1.im_message 聊天消息 2.im_top_notice 群置顶
"context": { //这个场景下具体的上下文参数
"url": "https://feishu-url.bytedance.net/smartcard/test/111", //匹配URL规则的原链接
"preview_token": "e28r7df2-xxxx-477d-a8d0-2e1eb99796c2", //用于标识链接预览的凭证,在返回链接预览数据时要用
"open_message_id": "om_191d914xxxxx81c97a609c663452dfdf", //触发链接预览的消息ID
"open_chat_id": "oc_20443194b65f9c8cf2935818dae39999" //触发链接预览的群ID
}
}
}
```
## 回调列表
目前支持的回调列表如下:
功能模块 | 回调名称 | 描述
---|---|---
卡片 | [卡片回传交互](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-callback-communication) | 用户点击卡片上配置回传交互的组件时,触发此回调。<br>可通过返回 toast、更新后的卡片内容等反馈用户的交互。
链接预览 | [拉取链接预览数据](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/development-link-preview/pull-link-preview-data-callback-structure) | 用户在聊天中查看匹配应用注册的URL规则的链接时,触发此回调。<br>可通过返回文字链、卡片等链接预览内容,为裸链扩展链接预览效果。
卡片 | [消息卡片回传交互(旧)](https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/configuring-card-callbacks/card-callback-structure) | 当用户点击卡片上添加了回传交互的组件时,开发者注册的服务端回调地址将收到此回调。<br>开发者可声明通过弹出 toast、更新卡片、保持原内容不变等方式来响应用户交互。<br><md-alert type="tip" icon="none">该回调使用旧版的协议,兼容历史的机器人[回调配置](https://open.feishu.cn/document/ukTMukTMukTM/uYzMxEjL2MTMx4iNzETM)。
q
+253
View File
@@ -0,0 +1,253 @@
# 卡片回传交互回调
**卡片回传交互**作用于飞书卡片的 **请求回调** 交互组件。当终端用户点击飞书卡片上的回传交互组件后,你在开发者后台应用内注册的回调请求地址将会收到 **卡片回传交互** 回调。该回调包含了用户与卡片之间的交互信息。
你的业务服务器接收到回调请求后,需要在 3 秒内响应回调请求,声明通过弹出 Toast 提示、更新卡片、保持原内容不变等方式响应用户交互。了解详细的操作步骤,参考[处理卡片回调](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/handle-card-callbacks)。
卡片回调和服务端响应回调的结构体参考下文。
**注意事项**:- 本文档提供新版本的卡片回调结构和响应示例。开放平台 SDK 已全量支持新版卡片回调。
- 了解旧版回调的 SDK 调用,参考[消息卡片回传交互(旧)](https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/configuring-card-callbacks/card-callback-structure)。
## 回调
基本信息 | &nbsp;
---|---
回调类型 | card.action.trigger
支持的应用类型 | Custom App、Store App
权限要求<br>**订阅该事件所需的权限,开启其中任意一项权限即可订阅**<br>开启任一权限即可 | 暂无
字段权限要求 | **注意事项**:事件结构体中存在 `user_id` 敏感字段,仅当应用开启“获取用户 user ID”权限后才会返回。<br>获取用户 user ID(contact:user.employee_id:readonly)
推送方式 | [Webhook](https://open.feishu.cn/document/ukTMukTMukTM/uUTNz4SN1MjL1UzM)
## 回调结构体
字段 | 数据类型 | 描述
---|---|---
schema | string | 回调的版本。固定取值为 `2.0`,为最新版本回调。了解旧版本回调,参考[消息卡片回传交互(旧)](https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/configuring-card-callbacks/card-callback-structure)。
header | object | 回调基本信息。
event_id | string | 回调的唯一标识。
token | string | 应用的 Verification Token。
create_time | string | 回调发送的时间,接近回调发生的时间。微秒级时间戳。
event_type | string | 回调类型。卡片交互场景中,固定为 `"card.action.trigger"`
tenant_key | string | 应用归属的 tenant key,即租户唯一标识。
app_id | string | 应用的 App ID。
event | object | 回调的详细信息。
operator | object | 回调触发者信息。
tenant_key | string | 回调触发者的 tenant key,即租户唯一标识。
user_id | string | 回调触发者的 user_id。了解不同的用户 ID,参见[用户身份概述](https://open.feishu.cn/document/home/user-identity-introduction/introduction)。
union_id | string | 回调触发者的 union_id。
open_id | string | 回调触发者的 open_id。
token | string | [更新卡片](https://open.feishu.cn/document/ukTMukTMukTM/uMDO1YjLzgTN24yM4UjN)用的凭证,有效期为 30 分钟,最多可更新 2 次。
action | object | 交互信息。
value | object/ string | 交互组件绑定的开发者自定义回传数据,对应组件中的 value 属性。类型为 string 或 object,可由开发者指定。
tag | string | 交互组件的标签。
timezone | string | 用户当前所在地区的时区。当用户操作日期选择器、时间选择器、或日期时间选择器时返回。
name | string | 组件的自定义唯一标识,用于识别内嵌在表单容器中的某个组件。
form_value | object | 表单容器内用户提交的数据。示例值:<br>```JSON<br>{<br>"field name 1": [ // 表单容器内某多选组件的 name 和 value<br>"selectDemo1",<br>"selectDemo2"<br>], <br>"field name 2": "value 2", // 表单容器内某交互组件的 name 和 value<br>"field name 3": "value 3", // 表单容器内某交互组件的 name 和 value<br>}<br>```
input_value | string | 当输入框组件未内嵌在表单容器中时,用户在输入框中提交的数据。
option | string | 当折叠按钮组、下拉选择-单选、人员选择-单选、日期选择器、时间选择器、日期时间选择器组件未内嵌在表单容器中时,用户选择该类组件某个选项时,组件返回的选项回调值。
options | string[] | 当下拉选择-多选组件和人员选择-多选组件未内嵌在表单容器中时,用户选择该类组件某个选项时,组件返回的选项回调值。
checked | bool | 当勾选器组件未内嵌在表单容器中时,勾选器组件的回调数据。
host | string | 卡片展示场景。
delivery_type | string | 卡片分发类型,固定取值为 `url_preview`,表示链接预览卡片。仅链接预览卡片有此字段。
context | object | 展示场景上下文。
url | string | 链接地址(适用于链接预览场景)。
preview_token | string | 链接预览的 token(适用于链接预览场景)。
open_message_id | string | 消息 ID。
open_chat_id | string | 会话 ID。
## 回调结构体示例
```json
{
"schema": "2.0", // 回调的版本
"header": { // 回调基本信息
"event_id": "f7984f25108f8137722bb63c*****", // 回调的唯一标识
"token": "066zT6pS4QCbgj5Do145GfDbbag*****", // 应用的 Verification Token
"create_time": "1603977298000000", // 回调发送的时间,接近回调发生的时间。微秒级时间戳
"event_type": "card.action.trigger", // 回调类型卡片交互场景中,固定为 "card.action.trigger"
"tenant_key": "2df73991750*****", // 应用归属的 tenant key,即租户唯一标识
"app_id": "cli_a5fb0ae6a4******" // 应用的 App ID
},
"event": { // 回调的详细信息
"operator": { // 回调触发者信息
"tenant_key": "2df73991750*****", // 回调触发者的 tenant key,即租户唯一标识
"user_id": "867*****", // 回调触发者的 user ID。当应用开启“获取用户 user ID”权限后,该参数返回
"open_id": "ou_3c14f3a59eaf2825dbe25359f15*****", // 回调触发者的 Open ID
"union_id": "on_cad4860e7af114fb4ff6c5d496d*****" // 回调触发者的 Union ID
},
"token": "c-295ee57216a5dc9de90fefd0aadb4b1d7d******", // 更新卡片用的凭证,有效期为 30 分钟,最多可更新 2 次
"action": { // 用户操作交互组件回传的数据
"value": { // 交互组件绑定的开发者自定义回传数据,对应组件中的 value 属性。类型为 string 或 object,可由开发者指定。
"key": "value"
},
"tag": "button", // 交互组件的标签
"timezone": "Asia/Shanghai", // 用户当前所在地区的时区。当用户操作日期选择器、时间选择器、或日期时间选择器时返回
"form_value": { // 表单容器内用户提交的数据
"field name1": [ // 表单容器内某多选组件的 name 和 value
"selectDemo1",
"selectDemo2"
],
"field name2": "value2", // 表单容器内某交互组件的 name 和 value
"DatePicker_bpqdq5puvn4": "2024-04-01 +0800", // 表单容器内日期选择器组件的 name 和 value
"DateTimePicker_ihz2d7a74i": "2024-04-29 07:07 +0800", // 表单容器内日期时间选择器组件的 name 和 value
"Input_lf4fmxwfrd9": "1234", // 表单容器内输入框组件的 name 和 value
"PersonSelect_2ejys7ype7m": "ou_3c14f3a59eaf2825dbe25359f15*****", // 表单容器内人员选择-单选组件的 name 和 value
"Select_a2d5b7l3zd": "1", // 表单容器内下拉选择-单选组件的 name 和 value
"TimePicker_7ecsf6xkqsq": "00:00 +0800" // 表单容器内时间选择器组件的 name 和 value
},
"name": "Button_lvkepfu3" // 用户操作交互组件的名称,由开发者自定义
},
"host": "im_message", // 卡片展示场景
"delivery_type": "url_preview", // 卡片分发类型,固定取值为 url_preview,表示链接预览卡片仅链接预览卡片有此字段
"context": { // 卡片展示场景相关信息
"url": "xxx", // 链接地址(适用于链接预览场景)
"preview_token": "xxx", // 链接预览的 token(适用于链接预览场景)
"open_message_id": "om_574d639e4a44e4dd646eaf628e2*****", // 卡片所在的消息 ID
"open_chat_id": "oc_e4d2605ca917e695f54f11aaf56*****" // 卡片所在的会话 ID
}
}
}
```
## 响应回调的结构体
你的业务服务器接收到回调请求后,需要在 3 秒内响应回调请求,声明通过弹出 Toast 提示、更新卡片、保持原内容不变等方式响应用户交互。以下为使用卡片 JSON 代码和卡片模板响应的字段说明。要了解响应方式,参考[处理卡片回调](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/handle-card-callbacks)。warning
业务服务端不可使用重定向状态码(`HTTP 3xx`)来响应卡片的回调请求,否则用户端将会出现交互请求错误。
### 使用卡片 JSON 代码响应
字段 | 数据类型 | 是否必填 | 描述
---|---|---|---
toast | object | 否 | 客户端的 Toast 弹窗提示。
type | string | 否 | 弹窗提示的类型。可选值有:info、success、error、和 warning。<br>不同的值的展示效果如下图所示:<br>![img_v3_02ao_9fdce3f7-5ba1-4f86-941f-2e5e7f6fd4eg.jpg](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/e62145dca9a372b1b51f0ea2e2629160_y1gPzFePcx.jpg?height=844&lazyload=true&width=1280)
content | string | 否 | 单语言提示文案。要配置多语言提示文案,请使用 `i18n` 字段。
i18n | Map | 否 | 多语言提示文案。示例配置:<br>```json<br>{<br>"i18n": {<br>"zh_cn": "更新成功!",<br>"en_us": "Successful update"<br>}<br>}<br>```
key | string | 否 | 语言。可选值:<br>- `zh_cn`: 简体中文<br>- `en_us`: 英文<br>- `zh_hk`: 繁体中文(香港)<br>- `zh_tw`: 繁体中文(台湾)<br>- `ja_jp`: 日语<br>- `id_id`: 印尼语<br>- `vi_vn`: 越南语<br>- `th_th`: 泰语<br>- `pt_br`: 葡萄牙语<br>- `es_es`: 西班牙语<br>- `ko_kr`: 韩语<br>- `de_de`: 德语<br>- `fr_fr`: 法语<br>- `it_it`: 意大利语<br>- `ru_ru`: 俄语<br>- `ms_my`: 马来语
value | string | 否 | 语言对应的文案。
card | object | 否 | 卡片数据。
type | string | 是 | 卡片类型。可选值:<br>- `template`:搭建工具构建的卡片,可视为一个卡片模板<br>- `raw`:由 JSON 构建的卡片<br>要使用卡片 JSON 代码响应,请选择 `raw`。
data | object | 是 | 卡片的 JSON 数据。 <br>- 若发送卡片时,卡片 JSON 结构为 1.0 版本,那么你需传入卡片 JSON 1.0 数据。详情参考[卡片 JSON 1.0 结构](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-structure)<br>- 若发送卡片时,卡片 JSON 结构为 2.0 版本,那么你需传入卡片 JSON 2.0 数据。详情参考[卡片 JSON 2.0 结构](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-structure)
响应回调的结构体示例(以 JSON 2.0 结构为例)
```json
{
"toast": {
"type": "info",
"content": "卡片交互成功",
"i18n": {
"zh_cn": "卡片交互成功",
"en_us": "card action success"
}
},
"card": {
"type": "raw",
"data": {
"schema": "2.0",
"config": {
"update_multi": true,
"style": {
"text_size": {
"normal_v2": {
"default": "normal",
"pc": "normal",
"mobile": "heading"
}
}
}
},
"body": {
"direction": "vertical",
"padding": "12px 12px 12px 12px",
"elements": [
{
"tag": "div",
"text": {
"tag": "plain_text",
"content": "示例文本",
"text_size": "normal_v2",
"text_align": "left",
"text_color": "default"
},
"margin": "0px 0px 0px 0px"
}
]
},
"header": {
"title": {
"tag": "plain_text",
"content": "示例标题"
},
"subtitle": {
"tag": "plain_text",
"content": "示例文本"
},
"template": "blue",
"padding": "12px 12px 12px 12px"
}
}
}
}
```
### 使用卡片模板响应
字段 | 数据类型 | 是否必填 | 描述
---|---|---|---
toast | object | 否 | 客户端的 Toast 弹窗提示。
type | string | 否 | 弹窗提示的类型。可选值有:info、success、error、和 warning。<br>不同的值的展示效果如下图所示:<br>![img_v3_02ao_9fdce3f7-5ba1-4f86-941f-2e5e7f6fd4eg.jpg](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/e62145dca9a372b1b51f0ea2e2629160_y1gPzFePcx.jpg?height=844&lazyload=true&width=1280)
content | string | 否 | 单语言提示文案。要配置多语言提示文案,请使用 `i18n` 字段。
i18n | Map | 否 | 多语言提示文案。示例配置:<br>```json<br>{<br>"i18n": {<br>"zh_cn": "更新成功!",<br>"en_us": "Successful update"<br>}<br>}<br>```
key | string | 否 | 语言。可选值:<br>- `zh_cn`: 简体中文<br>- `en_us`: 英文<br>- `zh_hk`: 繁体中文(香港)<br>- `zh_tw`: 繁体中文(台湾)<br>- `ja_jp`: 日语<br>- `id_id`: 印尼语<br>- `vi_vn`: 越南语<br>- `th_th`: 泰语<br>- `pt_br`: 葡萄牙语<br>- `es_es`: 西班牙语<br>- `ko_kr`: 韩语<br>- `de_de`: 德语<br>- `fr_fr`: 法语<br>- `it_it`: 意大利语<br>- `ru_ru`: 俄语<br>- `ms_my`: 马来语
value | string | 否 | 语言对应的文案。
card | object | 否 | 卡片数据。
type | string | 是 | 卡片类型。可选值:<br>- `template`:搭建工具构建的卡片,可视为一个卡片模板<br>- `raw`:由 JSON 构建的卡片<br>要使用卡片模板响应,请选择 `template`。
data | object | 是 | 卡片模板的数据。
template_id | string | 是 | 搭建工具中创建的卡片(也称卡片模板)的 ID,如 AAqigYkzabcef。可在搭建工具中通过复制卡片模板 ID 获取。<br>![image.png](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/8bf97ff2bceed633b28f5ce2d2ec0270_zPTWqjljT8.png?height=329&lazyload=true&maxWidth=500&width=1574)
template_variable | object | 否 | 若卡片绑定了变量,你需在该字段中传入实际变量数据的值。示例:如果变量名称在搭建工具中被定义为 open_id,则此处需要对 open_id 变量传入值。以“ou_d506829e8b6a17607e56bcd6b1aabcef”为示例:<br>```json<br>{<br>"open_id": "ou_d506829e8b6a17607e56bcd6b1aabcef"<br>}<br>```
template_version_name | string | 否 | 搭建工具中创建的卡片的版本号,如 1.0.0。卡片发布后,将生成版本号。可在搭建工具 **版本管理** 处获取。<br>![image.png](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/b3e96c8ca7c5c029bdbce6c0ca1ba413_aoV0ao7VUo.png?height=384&lazyload=true&maxWidth=500&width=1459)
响应回调的结构体示例
```json
{
"toast": {
"type": "info",
"content": "卡片交互成功",
"i18n": {
"zh_cn": "卡片交互成功",
"en_us": "card action success"
}
},
"card": {
"type": "template",
"data": {
"template_id": "AAqi6xJ8rabcd",
"template_version_name": "1.0.0",
"template_variable": {
"open_id": "ou_d506829e8b6a17607e56bcd6b1aabcef"
}
}
}
}
```
## 错误码
在飞书客户端进行卡片交互时,若交互出错,将返回如下图对应的错误码。错误码说明及解决方案如下表所示。
![](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/29558d328f22a099dc8ce5c66bf4e5ba_DD7lIR8Lxk.png?height=64&lazyload=true&width=285)
错误码仅支持飞书客户端 7.28 及以上版本。若未返回错误码,请升级飞书客户端后重试。
错误码 | 描述 | 解决方案
---|---|---
200340 | 应用未配置飞书卡片回调地址或配置的请求地址无效。<br>若应用已配置,请确保你已创建并发布了最新的应用版本使修改生效。 | 1. 前往[开发者后台](https://open.feishu.cn/app),点击目标应用,选择 **开发配置** > **事件与回调**。<br>2. 在 **事件与回调** 页面 **回调配置** 页签下,填写正确有效的请求地址并保存。<br>3. 在 **已订阅的回调** 项中,确保已添加卡片回传交互回调。<br>**提示**:你也可以选择使用长连接接收回调。了解更多,参考[配置回调订阅方式](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/callback-subscription/configure-callback-request-address)。
200341 | 所请求的卡片回调服务未在规定时间内响应飞书卡片服务端。 | 请确保配置的回调地址能够在 3 秒内响应卡片回调请求。
200342 | 飞书卡片服务端无法与该卡片回调地址建立 TCP 连接。 | 请检查并确保配置的回调地址可以正常访问。
200343 | 飞书卡片服务端解析该卡片回调地址的 DNS 失败。 | 请检查并确保配置的回调地址的域名正确。
200530 | 在表单容器中的交互组件的 name (表单项标识)属性为空。 | `name` 是表单容器内组件的唯一标识,用于识别用户提交的数据属于哪个组件,在单张卡片内不可为空、不可重复。<br>- 如果你使用卡片 JSON 搭建卡片,请确保所有的 name 属性的值不为空。`name` 数据类型为字符串。<br>- 如果你使用卡片搭建工具搭建卡片:<br>1. 在卡片编辑页面,选中表单内的交互组件,在右侧属性页签下,确保 **表单项标识** 已填写。<br>![](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/93894aaf05f60f3576e64cb5a0f22569_62E0goGKeA.png?height=482&lazyload=true&width=1547)<br>2. 点击右上角的 **保存**,然后点击 **发布**,确保修改生效。<br>![](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/b704b7552c24d7956b402092c7c38775_c3K900pZqf.png?height=557&lazyload=true&width=1557)
200080 | 飞书卡片服务端请求该卡片回调地址时发生错误。 | 请联系[技术支持](https://applink.feishu.cn/TLJpeNdW)进行处理。
200671 | 请求的卡片回调服务返回了非 `HTTP 200` 的状态码,导致无法进行正常的卡片交互。 | 请检查并确保接口代码逻辑正常,确保不会返回异常状态码。
200672 | 请求的卡片回调服务返回了错误的响应体格式。 | - 如果你添加的是新版卡片回传交互(`card.action.trigger`)回调,请参考[卡片回传交互](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-callback-communication#65787609)检查响应回调的结构体的格式是否有误。<br>- 如果你添加的是旧版卡片回传交互(`card.action.trigger_v1`)回调,请参考[消息卡片回传交互(旧)](https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/configuring-card-callbacks/card-callback-structure)检查响应回调的结构体的格式是否有误。<br>- 如果你同时添加了新版和旧版卡片回传交互回调,响应其中任一回调即为成功响应。建议你删除多余的请求方式。
200673 | 请求的卡片回调服务返回了错误的卡片。 | - 如果你添加的是新版卡片回传交互(`card.action.trigger`)回调,请参考[卡片回传交互](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-callback-communication#65787609)检查响应回调的结构体中 `card` 部分是否有误。<br>- 如果你添加的是旧版卡片回传交互(`card.action.trigger_v1`)回调,请参考[消息卡片回传交互(旧)](https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/configuring-card-callbacks/card-callback-structure)检查响应回调的结构体中除 `toast` 外的其它部分是否有误。
200830 | JSON 2.0 结构的卡片无法更新为 JSON 1.0 结构卡片。 | 如果交互前卡片的结构为[卡片 JSON 2.0 结构](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-structure),交互后的卡片结构仍必须为 2.0 结构。
300000 | 服务内部错误。 | 请联系[技术支持](https://applink.feishu.cn/TLJpeNdW)。
+281
View File
@@ -0,0 +1,281 @@
# 卡片 JSON 2.0 结构
本文档介绍卡片 JSON 2.0 的整体结构和属性说明。
## 概念说明
- 卡片 JSON 2.0 是指在卡片 JSON 数据中,声明了 `schema` 属性为 `"2.0"` 的版本。与 1.0 版本相比,2.0 版本有较多不兼容差异和新增属性,详情参考[卡片 JSON 2.0 版本更新说明](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-breaking-changes-release-notes)。
- 在可视化搭建工具中,你可通过搭建[新版卡片](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/feishu-card-cardkit/cardkit-upgraded-version-card-release-notes),获取 2.0 版本的卡片 JSON 源代码。
## 注意事项
- 卡片 JSON 2.0 结构支持飞书客户端 7.20 及之后版本。当使用 JSON 2.0 结构的卡片发送至低于 7.20 版本的客户端时,卡片标题可正常显示,但内容将展示兜底的升级提示文案。
![](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/35efb2f0bfbe5d22fe4b7a420925d2af_g5UivxGopO.png?height=449&lazyload=true&maxWidth=400&width=742)
- 卡片 JSON 2.0 结构暂时仅支持共享卡片,不支持独享卡片配置。即 `update_multi` 参数仅支持设为 `true`
- 卡片 JSON 2.0 结构中,一张卡片最多支持 200 个元素(如 `tag``plain_text` 的文本元素)或组件。
## JSON 结构
以下为卡片 JSON 2.0 的整体结构。
```JSON
{
"schema": "2.0", // 卡片 JSON 结构的版本。默认为 1.0。要使用 JSON 2.0 结构,必须显示声明 2.0。
"config": {
"streaming_mode": true, // 卡片是否处于流式更新模式,默认值为 false。
"streaming_config": {}, // 流式更新配置。详情参考下文。
"summary": { // 卡片摘要信息。可通过该参数自定义客户端聊天栏消息预览中的展示文案。
"content": "自定义内容", // 自定义摘要信息。如果开启了流式更新模式,该参数将默认为“生成中”。
"i18n_content": { // 摘要信息的多语言配置。了解支持的所有语种。参考配置卡片多语言文档。
"zh_cn": "",
"en_us": "",
"ja_jp": ""
}
},
"locales": [ // JSON 2.0 新增属性。用于指定生效的语言。如果配置 locales,则只有 locales 中的语言会生效。
"en_us",
"ja_jp"
],
"enable_forward": true, // 是否支持转发卡片。默认值为 true。
"update_multi": true, // 是否为共享卡片。默认值为 true,JSON 2.0 暂时仅支持设为 true,即更新卡片的内容对所有收到这张卡片的人员可见。
"width_mode": "fill", // 卡片宽度模式。支持 "compact"(紧凑宽度 400px)模式 或 "fill"(撑满聊天窗口宽度)模式。默认不填时的宽度为 600px。
"use_custom_translation": false, // 是否使用自定义翻译数据。默认值 false。为 true 时,在用户点击消息翻译后,使用 i18n 对应的目标语种作为翻译结果。若 i18n 取不到,则使用当前内容请求翻译,不使用自定义翻译数据。
"enable_forward_interaction": false, // 转发的卡片是否仍然支持回传交互。默认值 false。
"style": { // 添加自定义字号和颜色。可应用在组件 JSON 数据中,设置字号和颜色属性。
"text_size": { // 分别为移动端和桌面端添加自定义字号,同时添加兜底字号。用于在组件 JSON 中设置字号属性。支持添加多个自定义字号对象。
"cus-0": {
"default": "medium", // 在无法差异化配置字号的旧版飞书客户端上,生效的字号属性。选填。
"pc": "medium", // 桌面端的字号。
"mobile": "large" // 移动端的字号。
}
},
"color": { // 分别为飞书客户端浅色主题和深色主题添加 RGBA 语法。用于在组件 JSON 中设置颜色属性。支持添加多个自定义颜色对象。
"cus-0": {
"light_mode": "rgba(5,157,178,0.52)", // 浅色主题下的自定义颜色语法
"dark_mode": "rgba(78,23,108,0.49)" // 深色主题下的自定义颜色语法
}
}
}
},
"card_link": {
// 指定卡片整体的跳转链接。
"url": "https://www.baidu.com", // 默认链接地址。未配置指定端地址时,该配置生效。
"android_url": "https://developer.android.com/",
"ios_url": "https://developer.apple.com/",
"pc_url": "https://www.windows.com"
},
"header": {
"title": {
// 卡片主标题。必填。要为标题配置多语言,参考配置卡片多语言文档。
"tag": "plain_text", // 文本类型的标签。可选值:plain_text 和 lark_md。
"content": "示例标题" // 标题内容。
},
"subtitle": {
// 卡片副标题。可选。
"tag": "plain_text", // 文本类型的标签。可选值:plain_text 和 lark_md。
"content": "示例文本" // 标题内容。
},
"text_tag_list": [
// 标题后缀标签,最多设置 3 个 标签,超出不展示。可选。
{
"tag": "text_tag",
"element_id": "custom_id", // 操作元素的唯一标识。用于在调用组件相关接口中指定元素。需开发者自定义。
"text": {
// 标签内容
"tag": "plain_text",
"content": "标签 1"
},
"color": "neutral" // 标签颜色
}
],
"i18n_text_tag_list": {
// 多语言标题后缀标签。每个语言环境最多设置 3 个 tag,超出不展示。可选。同时配置原字段和国际化字段,优先生效多语言配置。
"zh_cn": [],
"en_us": [],
"ja_jp": [],
"zh_hk": [],
"zh_tw": []
},
"template": "blue", // 标题主题样式颜色。支持 "blue"|"wathet"|"turquoise"|"green"|"yellow"|"orange"|"red"|"carmine"|"violet"|"purple"|"indigo"|"grey"|"default"。默认值 default。
"icon": { // 前缀图标。
"tag": "standard_icon", // 图标类型。
"token": "chat-forbidden_outlined", // 图标的 token。仅在 tag 为 standard_icon 时生效。
"color": "orange", // 图标颜色。仅在 tag 为 standard_icon 时生效。
"img_key": "img_v2_38811724" // 图片的 key。仅在 tag 为 custom_icon 时生效。
},
"padding": "12px 8px 12px 8px" // 标题组件的内边距。JSON 2.0 新增属性。默认值 "12px",支持范围 [0,99]px。
},
"body": { // 卡片正文。
// JSON 2.0 新增布局类属性,用于控制子元素排列:
"direction": "vertical", // 正文或容器内组件的排列方向。可选值:"vertical"(垂直排列)、"horizontal"(水平排列)。默认为 "vertical"。
"padding": "12px 8px 12px 8px", // 正文或容器内组件的内边距,支持范围 [0,99]px。
"horizontal_spacing": "3px", // 正文或容器内组件的水平间距,可选值:"small"(4px)、"medium"(8px)、"large"(12px)、"extra_large"(16px)或[0,99]px。
"horizontal_align": "left", // 正文或容器内组件的水平对齐方式,可选值:"left"、"center"、"right"。默认值为 "left"。
"vertical_spacing": "4px", // 正文或容器内组件的垂直间距,可选值:"small"(4px)、"medium"(8px)、"large"(12px)、"extra_large"(16px)或[0,99]px。
"vertical_align": "center", // 正文或容器内组件的垂直对齐方式,可选值:"top"、"center"、"bottom",默认值为 "top"。
"elements": [ // 在此传入各个组件的 JSON 数据,组件将按数组顺序纵向流式排列。
{
"tag": "xxx", // 组件的标签。
"margin": "4px", // 组件的外边距,默认值 "0",支持范围 [-99,99]px。JSON 2.0 新增属性。
"element_id": "custom_id" // 操作组件的唯一标识。JSON 2.0 新增属性。用于在调用流式更新相关接口中指定组件。在同一张卡片内,该字段的值全局唯一。仅允许使用字母、数字和下划线,必须以字母开头,不得超过 20 字符。
}
]
}
}
```
## 属性说明
本小节介绍卡片结构中的属性。
### 全局属性
卡片全局属性包括以下字段。
```JSON
{
"schema": "2.0",
"config": {},
"card_link": {},
"header": {},
"body": {
"elements": []
}
}
```
各个字段说明如下所示。
若这些字段均不传,则卡片 JSON 为 "{}"。飞书开放平台支持发送卡片 JSON 为 "{}" 的空白卡片。
字段 | 是否必填 | 描述
---|---|---
schema | 否 | 卡片结构的版本声明。默认为 1.0 版本。要使用 JSON 2.0 结构,必须显示声明 2.0。可选值:<br>- 1.0:卡片 JSON 1.0 结构。详情参考[卡片 JSON 1.0 结构](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-structure)。<br>- 2.0:卡片 JSON 2.0 结构。支持更多字段和能力,如卡片流式更新能力、富文本组件(markdown)更多语法等。详情参考[卡片 JSON 2.0 不兼容变更&更新说明](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-breaking-changes-release-notes)。
config | 否 | 配置卡片的全局行为,包括流式更新模式(JSON 2.0 新增能力)、是否允许被转发、是否为共享卡片等。
card_link | 否 | 指定卡片整体的点击跳转链接。你可以配置一个默认链接,也可以分别为 PC 端、Android 端、iOS 端配置不同的跳转链接。
header | 否 | 标题组件相关配置。详情参考[标题](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/content-components/title)组件。
body | 否 | 卡片正文,包含一个名为 elements 的数组,用于放置各类组件。
### 卡片全局行为设置 `config`
`config` 用于配置卡片的全局行为,包括流式更新模式、是否允许被转发、是否为共享卡片等。
```json
{
"config": {
"streaming_mode": true, // 卡片是否处于流式更新模式,默认值为 false。
"streaming_config": { // 流式更新配置。
"print_frequency_ms": { // // 流式更新频率,单位:ms
"default": 30,
"android": 25,
"ios": 40,
"pc": 50
},
"print_step": { // // 流式更新步长,单位:字符数
"default": 2,
"android": 3,
"ios": 4,
"pc": 5
},
"print_strategy": "fast" // 流式更新策略,枚举值,可取:fast/delay
},
"summary": { // 卡片摘要信息。可通过该参数自定义客户端聊天栏消息预览中的展示文案。
"content": "自定义内容", // 自定义摘要信息。如果开启了流式更新模式,该参数将默认为“生成中”。
"i18n_content": { // 摘要信息的多语言配置。了解支持的所有语种。参考配置卡片多语言文档。
"zh_cn": "",
"en_us": "",
"ja_jp": ""
}
},
"locales": [ // JSON 2.0 新增属性。用于指定生效的语言。如果配置 locales,则只有 locales 中的语言会生效。
"en_us",
"ja_jp"
], // 卡片支持的语言列表。
"enable_forward": true, // 是否支持转发卡片。默认值为 true。
"update_multi": true, // 是否为共享卡片。默认值为 true,JSON 2.0 暂时仅支持设为 true,即更新卡片的内容对所有收到这张卡片的人员可见。
"width_mode": "fill", // 卡片宽度模式。支持 "compact"(紧凑宽度 400px)模式 或 "fill"(撑满聊天窗口宽度)模式。默认不填时的宽度为 600px。
"use_custom_translation": false, // 是否使用自定义翻译数据。默认值 false。为 true 时,在用户点击消息翻译后,使用 i18n 对应的目标语种作为翻译结果。若 i18n 取不到,则使用当前内容请求翻译,不使用自定义翻译数据。
"enable_forward_interaction": false, // 转发的卡片是否仍然支持回传交互。默认值 false。
"style": { // 添加自定义字号和颜色。可应用在组件 JSON 数据中,设置字号和颜色属性。
"text_size": { // 分别为移动端和桌面端添加自定义字号,同时添加兜底字号。用于在组件 JSON 中设置字号属性。支持添加多个自定义字号对象。
"cus-0": {
"default": "medium", // 在无法差异化配置字号的旧版飞书客户端上,生效的字号属性。选填。
"pc": "medium", // 桌面端的字号。
"mobile": "large" // 移动端的字号。
}
},
"color": { // 分别为飞书客户端浅色主题和深色主题添加 RGBA 语法。用于在组件 JSON 中设置颜色属性。支持添加多个自定义颜色对象。
"cus-0": {
"light_mode": "rgba(5,157,178,0.52)", // 浅色主题下的自定义颜色语法
"dark_mode": "rgba(78,23,108,0.49)" // 深色主题下的自定义颜色语法
}
}
}
}
}
```
`config` 下的各字段说明如下表所示。
字段名称 | 是否必填 | 类型 | 默认值 | 说明
---|---|---|---|---
streaming_mode | 否 | Boolean | false | 卡片是否处于流式更新模式。详情参考[流式更新卡片](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/streaming-updates-openapi-overview)。
streaming_config | 否 | object | / | 流式更新相关配置。详情参考[流式更新卡片](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/streaming-updates-openapi-overview)。
summary | 否 | Object | / | 自定义摘要信息配置。即飞书客户端聊天栏消息预览中的文案。
content | 否 | String | 无 | 摘要文本。当 `streaming_mode``true` 时,该字段默认为“生成中”。支持自定义。
i18n_content | 否 | Object | / | 摘要文本的多语言配置。详情参考[局部国际化配置](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/configure-multi-language-content)。
enable_forward | 否 | Boolean | true | 是否允许转发卡片。取值:<br>- true:允许<br>- false:不允许
update_multi | 否 | Boolean | true | 是否为共享卡片。取值:<br>- true:是共享卡片,更新卡片的内容对所有收到这张卡片的人员可见。<br>- false:非共享卡片,仅操作用户可见卡片的更新内容。
width_mode | 否 | String | default | 卡片宽度模式。取值:<br>- default:默认宽度。PC 端宽版、iPad 端上的宽度上限为 600px。<br>- compact:紧凑宽度 400px<br>- fill:自适应屏幕宽度<br>注意:卡片搭建工具上暂时不支持 `width_mode` 属性。
use_custom_translation | 否 | Boolean | false | 是否使用自定义翻译数据。取值:<br>- true:在用户点击消息翻译后,使用 i18n 对应的目标语种作为翻译结果。若 i18n 取不到,则使用当前内容请求飞书的机器翻译。<br>- false:不使用自定义翻译数据,直接请求飞书的机器翻译。
enable_forward_interaction | 否 | Boolean | false | 转发的卡片是否仍然支持回传交互。
style | 否 | Object | 空 | 添加自定义字号和颜色。可应用于组件的 JSON 数据中,设置字号和颜色属性。
text_size | 否 | Object | 空 | 分别为移动端和桌面端添加自定义字号,同时添加兜底字号。用于在普通文本组件和富文本组件 JSON 中设置字号属性。支持添加多个自定义字号对象。详情参考[普通文本](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/content-components/plain-text)组件和[富文本(Markdown](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/content-components/rich-text)组件。
color | 否 | Object | 空 | 分别为飞书客户端浅色主题和深色主题添加 RGBA 语法。用于在组件 JSON 中设置颜色属性。支持添加多个自定义颜色对象。详情参考[颜色枚举值](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/enumerations-for-fields-related-to-color)。
### 卡片全局跳转链接 `card_link`
`card_link` 字段用于指定卡片整体的点击跳转链接。你可以配置一个默认链接,也可以分别为 PC 端、Android 端、iOS 端配置不同的跳转链接。
```json
"card_link": {
// 指定卡片整体的跳转链接。
"url": "https://www.baidu.com", // 默认链接地址。未配置指定端地址时,该配置生效。
"android_url": "https://developer.android.com/",
"ios_url": "https://developer.apple.com/",
"pc_url": "https://www.windows.com"
}
```
card_link 下的各字段说明如下表所示。
**注意事项****注意**
- url 和各端的链接(android_url、ios_url、pc_url)必填其中一个。如果不填写 url,则必须完整填写 android_url、ios_url、pc_url 三个字段。如果同时填写了 url 和 android_url、ios_url、pc_urlurl 字段生效。
- 如果需要禁止某端进行跳转,可以将对应的参数值配置为 `lark://msgcard/unsupported_action`
字段名称 | 是否必填 | 类型 | 说明
---|---|---|---
url | 否 | String | 默认的链接地址。
pc_url | 否 | String | PC 端的链接地址。
ios_url | 否 | String | iOS 端的链接地址。
android_url | 否 | String | Android 端的链接地址。
### 卡片标题 `header`
`header` 字段用于配置卡片的标题。了解`header` 字段说明,参见[标题组件](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/content-components/title)。
```json
"header": {} // 卡片标题
```
### 卡片正文 `body`
在卡片的`body`字段中,你需要添加卡片组件作为卡片正文内容,组件将按数组顺序纵向流式排列。了解卡片组件,参考[卡片 JSON 2.0 版本组件概述](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/component-json-v2-overview)。
在卡片 JSON 2.0 结构中,所有组件(标题组件除外)和元素(如 tag 为 plain_text 的文本元素)新增 element_id 属性,作为操作组件或元素的唯一标识。在同一张卡片内,该字段的值全局唯一。仅允许使用字母、数字和下划线,必须以字母开头,不得超过 20 字符。
```json
{
"body": { // 卡片正文。
"elements": [ // 在此传入各个组件的 JSON 数据,组件将按数组顺序纵向流式排列。
{
"tag": "xxx", // 组件的标签。
"element_id": "custom_id" // 操作组件的唯一标识。
}
]
}
}
```
@@ -0,0 +1,239 @@
# 卡片 JSON 2.0 版本更新说明
本文档介绍卡片 JSON 2.0 版本与 1.0 版本结构之间的不兼容变更和优化说明。了解完整的 JSON 2.0 结构数据,参考[卡片 JSON 2.0 结构](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-structure)。
## 注意事项
- 卡片 JSON 2.0 结构支持飞书客户端 7.20 及之后版本。当使用 JSON 2.0 结构的卡片发送至低于 7.20 版本的客户端时,卡片标题可正常显示,但内容将展示兜底的升级提示文案。
![](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/35efb2f0bfbe5d22fe4b7a420925d2af_g5UivxGopO.png?height=449&lazyload=true&maxWidth=400&width=742)
- 卡片 JSON 2.0 结构暂时仅支持共享卡片,不支持独享卡片配置。即 `update_multi` 参数仅支持设为 `true`
## 不兼容变更
本小节介绍卡片 JSON 2.0 版本相对于 1.0 版本所发生的不兼容变更。
### 卡片交互有效期变更
- 1.0 结构:发出卡片的可交互时间为 30 天,可更新时间为 14 天(如果在第 14-30 天交互卡片,且交互回调动作为更新卡片,更新动作将不会生效)。
- 2.0 结构:卡片可交互和可更新时间统一为 14 天。
### 属性校验变更
在 JSON 2.0 版本中,传入不支持的属性将报错。
| **1.0 结构** | **2.0 结构** |
| ------------- | ----------- |
| 传入不支持的属性作忽略处理 | 传入不支持的属性将报错 |
### JSON 全局结构和字段变更
- **结构变更**
- JSON 2.0 版本新增 `body` 字段,`elements` 属性放置在 `body` 层级下。了解 2.0 整体结构,参考[卡片 JSON 2.0 结构](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-structure)。
- JSON 2.0 版本不再支持通过 `i18n_elements` 字段设置全局多语言。你可通过 `i18n_content` 等局部多语言字段实现组件级别的多语言配置,详情参考[配置卡片多语言](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/configure-multi-language-content)。
- **`fallback`** **字段变更**
JSON 2.0 版本暂不支持使用 `fallback` 字段配置自定义的全局降级规则。
- **默认值变更**
JSON 2.0 版本中的 `update_multi` 默认值变更为 `true`,且暂时仅支持设为 `true``update_multi` 属性用于设置卡片是否为共享卡片;`true` 表示设置卡片为共享卡片,更新卡片的内容对所有收到这张卡片的人员可见;`false` 表示设置卡片为独享卡片,更新卡片的内容对他人不可见。
1.0 结构 | 2.0 结构
---|---
```json<br>{<br>"schema": "1.0", // 不填默认为 1.0<br>"config": {<br>"update_multi": false // 默认值为 false。<br>},<br>"card_link": {},<br>"header": {},<br>"i18n_header": {},<br>"elements": [],<br>"i18n_elements": {},<br>"fallback": {}<br>}<br>``` | ```json<br>{<br>"schema": "2.0", // 2.0 需主动声明<br>"config": {<br>"update_multi": true // 默认值为 true,且暂时仅支持设为 `true`<br>},<br>"card_link": {},<br>"header": {},<br>"body": { // 新增 body 字段,elements 属性放置在 body 层级下。<br>"elements": [] // 不再支持 i18n_elements 字段<br>},<br>"fallback": {}<br>}<br>```
### 容器类组件布局属性默认值变更
- [表单容器](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-components/containers/form-container)的 `vertical_spacing` 和 `horizontal_spacing` 字段的默认值由 `16px` 改为 `12px`,且支持开发者自定义配置。
1.0 结构 | 2.0 结构
---|---
```json<br>{<br>"margin": "0", // 容器的外边距设置。<br>"padding": "0", // 容器的内边距设置。<br>"vertical_spacing": "16px", // 容器内组件的垂直边距设置。<br>"horizontal_spacing": "16px" // 容器内组件的水平边距设置。<br>}<br>``` | ```json<br>{<br>"margin": "0",<br>"padding": "0",<br>"vertical_spacing": "12px", // 默认值变更,且支持自定义。<br>"horizontal_spacing": "12px" // 默认值变更,且支持自定义。<br>}<br>```
<br>
- [交互容器](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-components/containers/interactive-container)的 `vertical_spacing` 和 `horizontal_spacing` 字段的默认值由 `12px` 改为 `4px` 和 `8px`,且支持开发者自定义配置。
1.0 结构 | 2.0 结构
---|---
```json<br>{<br>"margin": "0", // 容器的外边距设置。<br>"padding": "4px 12px", // 容器的内边距设置。<br>"vertical_spacing": "12px", // 容器内组件的垂直边距设置。<br>"horizontal_spacing": "12px" // 容器内组件的水平边距设置。<br>}<br>``` | ```json<br>{<br>"margin": "0",<br>"padding": "4px 12px",<br>"vertical_spacing": "4px", // 默认值变更,且支持自定义。<br>"horizontal_spacing": "8px" // 默认值变更,且支持自定义。<br>}<br>```
<br>
- [折叠面板](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-components/containers/collapsible-panel)的 `padding` 字段默认值变更:
- 当折叠面板配置了边框(border)或背景色(background_color)时,标题区 `padding` 字段的默认值变更为上下边距 4px,左右边距 8px。
1.0 结构 | 2.0 结构
---|---
```json<br>// 有边框(border)或背景色(background_color)时<br>{<br>"header": {<br>"margin": "0",<br>"padding": "8px" // 标题区的内边距。<br>},<br>"margin": "0",<br>"padding": "8px",<br>"vertical_spacing": "8px",<br>"horizontal_spacing": "8px"<br>}<br>``` | ```json<br>// 有边框(border)或背景色(background_color)时<br>{<br>"header": {<br>"margin": "0",<br>"padding": "4px 8px" // 标题区的内边距默认值变更。上下边距为 4px,左右边距为 8px。<br>},<br>"margin": "0",<br>"padding": "8px",<br>"vertical_spacing": "8px",<br>"horizontal_spacing": "8px"<br>}<br>```
- 当折叠面板未配置边框(border)或背景色(background_color)时,标题区 `padding` 字段的默认值变更为 0,内容区的内边距默认值变更为上边距 8px,右、下、左边距 0。
1.0 结构 | 2.0 结构
---|---
```json<br>// 无边框(border)或背景色(background_color)时<br>{<br>"header": {<br>"margin": "0",<br>"padding": "8px 0 8px 0" // 标题区的内边距。<br>},<br>"margin": "0",<br>"padding": "0",<br>"vertical_spacing": "8px",<br>"horizontal_spacing": "8px"<br>} | ```json<br>// 无边框(border)或背景色(background_color)时<br>{<br>"header": {<br>"margin": "0",<br>"padding": "0" // 标题区的内边距默认值变更<br>},<br>"margin": "0",<br>"padding": "8px 0 0 0", // 内容区的内边距默认值变更。上边距为 8px,右、下、左边距为 0px。<br>"vertical_spacing": "8px",<br>"horizontal_spacing": "8px"<br>}
### `vertical_spacing` 和 `horizontal_spacing` 枚举值 & 映射数值变更
1.0 结构 | 2.0 结构
---|---
<code>vertical_spacing</code> 和 <code>horizontal_spacing</code>字段的枚举和对应的值为:<br>- small4px<br>- medium8px<br>- large16px | <code>vertical_spacing</code> 和 <code>horizontal_spacing</code>字段的枚举和对应的值为:<br>- small4px<br>- medium8px<br>- large12px<br>- extra_large16px
### 标题组件配置变更
- 标题组件的 icon 配置结构变更,对齐其它组件:
1.0 结构 | 2.0 结构
---|---
```json<br>{<br>"header": {<br>"title": {},<br>"icon": {<br>"img_key": "img_v2_38811724" <br>},<br>"ud_icon": {<br>"token": "chat-forbidden_outlined", <br>"style": {<br>"color": "red"<br>}<br>}<br>}<br>}<br>``` | ```json<br>{<br>"header": {<br>"title": {},<br>"icon": {<br>"tag": "standard_icon",<br>"token": "chat-forbidden_outlined",<br>"color": "orange", <br>"img_key": "img_v2_38811724"<br>}<br>}<br>}<br>```
### 图片组件不再支持通栏配置
1.0 结构 | 2.0 结构
---|---
支持 stretch_without_padding 通栏配置,图片的宽度将撑满卡片宽度。<br>```json<br>{<br>"tag": "img",<br>"img_key": "img_v3_0238_073f1823-df2b-4377-86c6-e293f183622j",<br>"size": "stretch_without_padding" // 支持通栏配置,图片宽度将撑满卡片宽度。<br>}<br>``` | 不再支持通栏配置,但可设置 margin 字段为负数实现通栏效果。<br>```json<br>{<br>"tag": "img",<br>"img_key": "img_v3_0238_073f1823-df2b-4377-86c6-e293f183622j",<br>"size": "crop_center",<br>"margin": "4px -12px"<br>}<br>```
### 富文本(Markdown)组件废弃差异化跳转语法
2.0 结构不再支持以下差异化跳转语法。你可使用`<link></link>` 标签替代,如 `<link icon='chat_outlined' url='``https://applink.feishu.cn/client/chat/xxxxx'`` pc_url='' ios_url='' android_url=''> `` 战略研讨会 </link> `
```json
{
"tag": "markdown",
"href": {
"urlVal": {
"url": "xxx",
"pc_url": "xxx",
"ios_url": "xxx",
"android_url": "xxx"
}
},
"content": "[差异化跳转]($urlVal)"
}
```
### 兜底高度 & 宽度变更
1.0 结构 | 2.0 结构
---|---
- 卡片兜底高度:24px<br>- 组件宽度设置的像素值如果大于父容器宽度,会收缩限制到父容器宽度,仅在交互容器中会截断展示 | - 卡片的兜底高度:40px<br>- 组件宽度设置的像素值如果大于父容器宽度,将会截断展示
### 废弃备注组件 & 交互模块
2.0 结构不再支持[备注](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-components/content-components/note)note)组件和[交互模块](https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements)"tag" 为 "action"),相关效果可由以下组件和属性实现:
- 备注(note)组件:可由普通文本组件配置 notation 字号、grey 字体颜色、icon 属性替代;
- 交互模块:可由按钮(button)或折叠按钮组(overflow)组件配置合适的组件间距 (`vertical_spacing``horizontal_spacing`) 替代。
## 新增属性和优化说明
本小节介绍 2.0 结构新增的属性和优化点。
### 新增 `streaming_mode` 属性,支持流式更新
2.0 结构新增 `streaming_mode``summary` 字段,支持卡片流式更新、文本流式更新能力。详情参考[流式更新 OpenAPI 调用指南](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/streaming-updates-openapi-overview)。
```json
{
"schema": "2.0", // 卡片 JSON 结构的版本。默认为 1.0。
"config": {
"streaming_mode": true, // 卡片是否处于流式更新模式,默认值为 false。
"summary": {
"content": "自定义内容", // 自定义摘要信息。默认为“生成中”。
"i18n_content": { // 摘要信息的多语言配置。了解支持的所有语种。参考。
"zh_cn": "",
"en_us": "",
"ja_jp": ""
}
}
}
}
```
### 新增 `element_id` 属性,用于操作组件
所有组件和元素(如 `tag``plain_text` 的文本元素)新增 `element_id` 属性,作为操作组件或元素的唯一标识。在同一张卡片内,该字段的值全局唯一。仅允许使用字母、数字和下划线,必须以字母开头,不得超过 20 字符。
```json
{
"tag": "button", // 组件的标签。
"element_id": "button_1" // 操作组件时的唯一标识。
}
```
### 组件统一支持布局相关能力
卡片 JSON 2.0 结构中,各类组件统一新增了一批布局类属性。
```json
// 卡片层级
{
"schema": "2.0",
"header": {
"title": {},
"padding": "4px" // 支持设置[0,99]px
},
"body": {
"vertical_spacing": "4px", // body 内子组件的垂直间距,支持设置[0,99]px
"padding": "4px", // body 的内边距配置,支持设置[0,99]px
"elements": []
}
}
// 组件
{
"tag": "xxxx",
// 各组件均新增的布局类属性
"margin": "4px", // 外边距,默认值 "0",支持范围 [-99,99]px
// 容器类组件(含elements)新增的布局类属性,用于控制子元素排列
"padding": "4px", // 内边距,支持范围 [0,99]px
"direction": "vertical", // 布局方向,支持 "vertical"|"horizontal",默认值 "vertical"
"horizontal_spacing": "3px", // 水平间距,支持范围 [0,99]px
"vertical_spacing": "4px", // 垂直间距,支持范围 [0,99]px
"horizontal_align": "left", // 水平对齐,支持 "left"|"center"|"right",默认值 "left"
"vertical_align": "center", // 垂直对齐,支持 "top"|"center"|"bottom",默认值 "top"
// 其他
"elements": []
}
```
[普通文本组件](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-components/content-components/plain-text)支持配置 width 属性。可取值:
- fill:文本的宽度将与组件宽度一致,撑满组件。
- auto:文本的宽度自适应文本内容本身的长度。
- [16,999]px:自定义文本宽度。
```json
{
"tag": "div",
"width": "fill", // 文本宽度。支持 "fill"|"auto"|"{{[16,999]}}px"。默认值为 fill。
}
```
### 富文本组件支持标准 markdown 语法
[卡片 JSON 2.0 结构](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-structure)支持除 `HTMLBlock` 外所有标准的 Markdown 语法和部分 HTML 语法。了解 Markdown 标准语法,请参考 [CommonMark Spec 官方文档](https://spec.commonmark.org/0.31.2/)。你也可以使用 [CommonMark playground](https://spec.commonmark.org/dingus/) 预览 Markdown 效果。了解更多,参考[富文本(Markdown](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/content-components/rich-text)。
注意,在卡片的富文本组件中,以下语法的渲染效果与 CommonMark 有差异:
- 富文本组件支持使用一个 Enter 键作为软换行(Soft Break);支持两个 Enter 键作为硬换行(Hard Break)。软换行在渲染时可能会被忽略,具体取决于渲染器如何处理;硬换行在渲染时始终会显示为一个新行。
- 2.0 结构支持以下 HTML 语法:
- 开标签 `<br>`
- 自闭合标签 `<br/>`
- 开标签 `<hr>`
- 自闭合标签 `<hr/>`
- 闭合标签 `<person></person>`
- 闭合标签 `<local_datetime></local_datetime>`
- 闭合标签 `<at></at>`
- 闭合标签 `<a></a>`
- 闭合标签 `<text_tag></text_tag>`
- 闭合标签 `<raw></raw>`
- 闭合标签 `<link></link>`
- 闭合标签 `</font>`,支持嵌套其它标签,如 `<font color=red>red<font color=green>greenagain</font>`。其它标签包括:
- 闭合标签 `<local_datetime></local_datetime>`
- 闭合标签 `<at></at>`
- 闭合标签 `<a></a>`
- 闭合标签 `<link></link>`
- 闭合标签 `<font></font>`
### 容器类组件新增可内嵌的组件类型
JSON 2.0 结构中,表单容器、交互容器、折叠面板、分栏组件可内嵌除表单容器和表格组件外的其它所有组件。
1.0 结构 | 2.0 结构
---|---
- 表单容器:不支持内嵌表格、图表、和表单容器组件;不可直接内嵌普通文本组件<br>- 交互容器:仅支持内嵌普通文本、富文本、图片、备注、分栏、勾选器、交互容器组件<br>- 折叠面板:不支持内嵌表单容器(form)和表格组件(table)组件<br>- 分栏:不支持内嵌表格(table)、表单(form)和多图混排(img_combination)组件 | 表单容器、交互容器、折叠面板、分栏组件可内嵌除表单容器(form)和表格组件(table)外的其它所有组件。
+330
View File
@@ -0,0 +1,330 @@
# 飞书 Bot 开发经验总结
基于 `lark-oapi` Python SDK 的实战经验,涵盖接收消息、发送消息、文件上传、WebSocket 长连接等。
---
## 1. SDK 初始化
```python
import lark_oapi as lark
client = (
lark.Client.builder()
.app_id(APP_ID)
.app_secret(APP_SECRET)
.log_level(lark.LogLevel.WARNING)
.build()
)
```
`client` 是全局单例,线程安全,可在模块级别创建。
---
## 2. 接收消息:WebSocket 长连接模式
飞书推荐使用 WebSocket 长连接(而非 HTTP 回调),省去公网暴露和回调验证。
### 2.1 事件注册
```python
from lark_oapi.api.im.v1 import P2ImMessageReceiveV1
handler = (
lark.EventDispatcherHandler.builder("", "") # 加密 key 和验证 token,长连接模式留空
.register_p2_im_message_receive_v1(on_message) # 注册消息回调
.build()
)
```
### 2.2 启动 WebSocket 客户端
```python
ws_client = lark.ws.Client(
APP_ID,
APP_SECRET,
event_handler=handler,
log_level=lark.LogLevel.INFO,
)
ws_client.start() # 阻塞调用
```
### 2.3 关键坑点:事件循环
`lark_oapi.ws.client` 在 import 时捕获当前事件循环。如果你的主程序用了 `asyncio`(如 uvicorn),
必须在 WebSocket 线程中创建新的事件循环并替换:
```python
import lark_oapi.ws.client as _lark_ws_client
thread_loop = asyncio.new_event_loop()
asyncio.set_event_loop(thread_loop)
_lark_ws_client.loop = thread_loop # 关键:替换 SDK 内部持有的 loop
```
### 2.4 断线重连
`ws_client.start()` 在连接断开时会直接返回(不抛异常),需要自己包裹重连循环:
```python
backoff = 1.0
while True:
try:
ws_client.start()
except Exception:
pass
time.sleep(backoff)
backoff = min(backoff * 2, 60.0)
```
### 2.5 解析收到的消息
```python
def on_message(data: P2ImMessageReceiveV1):
event = data.event
message = event.message
sender = event.sender
chat_id = message.chat_id # 会话 ID(群/私聊)
msg_type = message.message_type # "text", "image", "file", ...
content = json.loads(message.content) # 消息内容是 JSON 字符串
text = content.get("text", "")
# 发送者信息
open_id = sender.sender_id.open_id # 用户唯一标识
```
**注意**
- `message.content` 是 JSON 字符串,不是纯文本,需要 `json.loads`
- 群聊中 @bot 的消息会包含 `@xxx` 前缀,需要用正则清除:`re.sub(r"@\S+\s*", "", text)`
- 飞书遇到网络问题会在 ~60s 内重发相同消息,需要做**去重**(按 `(user_id, content)` + 时间窗口)
---
## 3. 发送消息
所有发送都通过 `client.im.v1.message.create`,通过 `msg_type``content` 区分消息类型。
### 3.1 `receive_id_type` 的选择
| 值 | 含义 | 用途 |
|---|---|---|
| `"chat_id"` | 会话 ID | 回复到当前会话(群聊或私聊) |
| `"open_id"` | 用户 open_id | 主动私聊某个用户(如通知) |
| `"user_id"` | 用户 user_id | 同上,另一种 ID 体系 |
**经验**:回复消息用 `chat_id`,主动推送通知用 `open_id`
### 3.2 发送纯文本
```python
content = json.dumps({"text": "Hello"}, ensure_ascii=False)
request = (
CreateMessageRequest.builder()
.receive_id_type("chat_id")
.request_body(
CreateMessageRequestBody.builder()
.receive_id(chat_id)
.msg_type("text")
.content(content)
.build()
)
.build()
)
response = client.im.v1.message.create(request)
```
### 3.3 发送卡片消息(Markdown
卡片消息是飞书中展示富文本的主要方式。`msg_type``"interactive"`content 为卡片 JSON。
#### JSON 2.0 Markdown 卡片(推荐)
```python
card = {
"schema": "2.0",
"body": {
"elements": [
{
"tag": "markdown",
"content": "**粗体** *斜体* `code`\n- 列表项\n```python\nprint('hello')\n```",
}
]
}
}
content = json.dumps(card, ensure_ascii=False)
# msg_type = "interactive"
```
#### JSON 1.0 卡片(旧版,带标题栏)
```python
card = {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": "标题"},
"template": "turquoise", # 标题栏颜色
},
"elements": [
{"tag": "div", "text": {"tag": "lark_md", "content": "**markdown** 内容"}},
],
}
```
**JSON 1.0 vs 2.0 的区别**
- 1.0 用 `lark_md` tag + `div` 容器,2.0 直接用 `markdown` tag
- 2.0 支持标准 CommonMark1.0 有较多语法限制
- 2.0 支持表格、标题(`# ## ###`),1.0 不支持
- 需要标题栏时用 1.0 的 `header`;纯内容展示用 2.0 更简洁
### 3.4 发送文件
两步流程:先上传文件获取 `file_key`,再发送文件消息。
```python
from lark_oapi.api.im.v1 import CreateFileRequest, CreateFileRequestBody
# Step 1: 上传
with open(path, "rb") as f:
req = (
CreateFileRequest.builder()
.request_body(
CreateFileRequestBody.builder()
.file_type("stream") # "stream", "opus", "mp4", "pdf", "doc", "xls", "ppt"
.file_name(file_name)
.file(f)
.build()
)
.build()
)
resp = client.im.v1.file.create(req)
file_key = resp.data.file_key
# Step 2: 发送
content = json.dumps({"file_key": file_key}, ensure_ascii=False)
# msg_type = "file"
```
---
## 4. Markdown 语法速查(JSON 2.0 卡片)
JSON 2.0 支持标准 CommonMark(除 HTMLBlock),外加飞书扩展语法。
### 标准语法
| 效果 | 语法 |
|---|---|
| 粗体 | `**text**` |
| 斜体 | `*text*` |
| 删除线 | `~~text~~` |
| 行内代码 | `` `code` `` |
| 代码块 | ` ```python\ncode\n``` ` |
| 链接 | `[text](url)` |
| 图片 | `![alt](image_key)` |
| 有序列表 | `1. item` (4 空格缩进为子项) |
| 无序列表 | `- item` |
| 引用 | `> text` |
| 标题 | `# ~ ######` (1-6 级) |
| 分割线 | `---``<hr>` |
| 表格 | 标准 Markdown 表格语法 |
### 飞书扩展语法
| 效果 | 语法 |
|---|---|
| @某人 | `<at id=open_id></at>` |
| @所有人 | `<at id=all></at>` |
| 彩色文本 | `<font color='red'>红色</font>` |
| 标签 | `<text_tag color='blue'>标签</text_tag>` |
| 飞书表情 | `:DONE:` `:THUMBSUP:` |
| 带图标链接 | `<link icon='chat_outlined' url='...'>文本</link>` |
| 电话链接 | `[显示文本](tel://号码)` (仅移动端) |
### 特殊字符转义
Markdown 特殊字符(`* ~ > < [ ] ( ) # : + " '` 等)需要 HTML 实体转义才能原样显示:
| 字符 | 转义 |
|---|---|
| `*` | `&#42;` |
| `<` | `&#60;` |
| `>` | `&#62;` |
| `~` | `&sim;` |
| `` ` `` | `&#96;` |
完整对照参考 [HTML 转义标准](https://www.w3school.com.cn/charsets/ref_html_8859.asp)。
---
## 5. 长消息处理
飞书单条文本消息有长度限制(约 4000 字符),卡片消息整体 JSON 约 28KB。超长内容需要拆分:
```python
MAX_TEXT_LEN = 3900
def split_message(text: str) -> list[str]:
"""在换行符处分割,避免截断代码块或段落中间。"""
if len(text) <= MAX_TEXT_LEN:
return [text]
parts = []
remaining = text
while remaining:
if len(remaining) <= MAX_TEXT_LEN:
parts.append(remaining)
break
chunk = remaining[:MAX_TEXT_LEN]
last_newline = chunk.rfind("\n")
if last_newline > MAX_TEXT_LEN // 2:
chunk = remaining[:last_newline + 1]
parts.append(chunk)
remaining = remaining[len(chunk):]
return parts
```
多段发送时加 `await asyncio.sleep(0.3)` 避免飞书限流。
---
## 6. 异步兼容
`lark-oapi` 的 SDK 方法全是同步的(阻塞 I/O),在 asyncio 环境中需要用 `run_in_executor`
```python
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None,
lambda: client.im.v1.message.create(request),
)
```
---
## 7. 错误处理
所有 API 调用检查 `response.success()`
```python
if not response.success():
logger.error("code=%s msg=%s", response.code, response.msg)
```
常见错误码:
- **权限不足**:机器人未添加到群聊,或未开通对应 API 权限
- **receive_id 无效**ID 类型与 `receive_id_type` 不匹配
- **内容过长**:消息体超出限制
---
## 8. 实用模式总结
| 场景 | msg_type | 发送函数 |
|---|---|---|
| 系统通知、短消息 | `text` | `send_text` |
| LLM/AI 回复(含代码、列表) | `interactive` | `send_markdown` (JSON 2.0 卡片) |
| 结构化信息(带标题栏) | `interactive` | `send_card` (JSON 1.0 卡片) |
| 文件传输 | `file` | `send_file` (先上传再发送) |
| 主动推送给用户 | 同上 | `receive_id_type="open_id"` |
| 回复当前会话 | 同上 | `receive_id_type="chat_id"` |
+31
View File
@@ -0,0 +1,31 @@
# Host client configuration example
# Copy to host_config.yaml and fill in your values
# Node identification
NODE_ID: home-pc
DISPLAY_NAME: Home PC
# Router connection
ROUTER_URL: ws://192.168.1.100:8000/ws/node
ROUTER_SECRET: your-shared-secret-for-router-host-auth
# LLM configuration (used by mailboy LLM)
OPENAI_BASE_URL: https://open.bigmodel.cn/api/paas/v4/
OPENAI_API_KEY: your_openai_api_key
OPENAI_MODEL: glm-4.7
# Local working directory (where Claude Code sessions run)
WORKING_DIR: C:/Users/yourname/projects
# Optional: 秘塔AI Search API key for web search
METASO_API_KEY: your_metaso_api_key
# Bot command prefix (default: "//")
# "//" avoids conflicts with Claude Code's own "/" commands.
# COMMAND_PREFIX: "//"
# Which Feishu users this node serves
# List of open_ids from Feishu
SERVES_USERS:
- ou_abc123def456
- ou_789ghi012jkl
+8
View File
@@ -1,3 +1,11 @@
# Server configuration
# Only used in router mode (python main.py) or standalone mode (python standalone.py)
# Default: 8000
PORT: 8000
# Root directory for all project sessions (absolute path)
# Only used in standalone mode (python standalone.py)
# In router mode (python main.py), this field is ignored
WORKING_DIR: "/path/to/working/directory"
FEISHU_APP_ID: your_feishu_app_id
FEISHU_APP_SECRET: your_feishu_app_secret
-116
View File
@@ -1,116 +0,0 @@
"""PhoneWork entry point: FastAPI app + Feishu long-connection client."""
from __future__ import annotations
import asyncio
import logging
import time
import uvicorn
from fastapi import FastAPI
from rich.logging import RichHandler
from agent.manager import manager
from bot.handler import start_websocket_client, get_ws_status
logging.basicConfig(
level=logging.DEBUG,
format="%(name)-20s %(message)s",
datefmt="[%X]",
handlers=[RichHandler(
rich_tracebacks=True,
markup=True,
show_path=False,
omit_repeated_times=False,
)],
)
for _noisy in ("httpcore", "httpx", "openai._base_client", "urllib3", "lark_oapi", "websockets"):
logging.getLogger(_noisy).setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
app = FastAPI(title="PhoneWork", version="0.1.0")
START_TIME = time.time()
@app.get("/health")
async def health() -> dict:
sessions = manager.list_sessions()
ws_status = get_ws_status()
uptime = time.time() - START_TIME
result = {
"status": "ok",
"uptime_seconds": round(uptime, 1),
"active_sessions": len(sessions),
"websocket": ws_status,
}
if ws_status.get("connected"):
result["status"] = "ok"
else:
result["status"] = "degraded"
return result
@app.get("/health/claude")
async def health_claude() -> dict:
"""Smoke test: run a simple claude -p command."""
from agent.pty_process import run_claude
import tempfile
import os
start = time.time()
try:
with tempfile.TemporaryDirectory() as tmpdir:
output = await run_claude(
"Say 'pong' and nothing else",
cwd=tmpdir,
timeout=30.0,
)
elapsed = time.time() - start
return {
"status": "ok",
"elapsed_seconds": round(elapsed, 2),
"output_preview": output[:100] if output else None,
}
except asyncio.TimeoutError:
return {"status": "timeout", "elapsed_seconds": 30.0}
except Exception as e:
return {"status": "error", "error": str(e)}
@app.get("/sessions")
async def list_sessions() -> list:
return manager.list_sessions()
@app.on_event("startup")
async def startup_event() -> None:
await manager.start()
from agent.scheduler import scheduler
await scheduler.start()
loop = asyncio.get_running_loop()
start_websocket_client(loop)
logger.info("PhoneWork started")
@app.on_event("shutdown")
async def shutdown_event() -> None:
await manager.stop()
from agent.scheduler import scheduler
await scheduler.stop()
logger.info("PhoneWork shut down")
if __name__ == "__main__":
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=False,
log_level="info",
ws_ping_interval=20,
ws_ping_timeout=60,
)
+62 -65
View File
@@ -8,7 +8,6 @@ from __future__ import annotations
import asyncio
import json
import logging
import re
from collections import defaultdict
from typing import Dict, List, Optional
@@ -22,7 +21,7 @@ from langchain_core.messages import (
from langchain_openai import ChatOpenAI
from agent.manager import manager
from config import OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL, WORKING_DIR
from config import OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL, WORKING_DIR, COMMAND_PREFIX as _P
from orchestrator.tools import TOOLS, set_current_user
logger = logging.getLogger(__name__)
@@ -40,28 +39,59 @@ Pass these names directly to `create_conversation` — the tool resolves them au
{active_session_line}
Your responsibilities:
Bot command prefix: {prefix}
## Tools — two distinct categories
### Bot control commands (use `run_command`)
`run_command` executes PhoneWork commands. Use it when the user asks to:
- Change permission mode: "切换到只读模式" → run_command("{prefix}perm plan")
- Close/switch sessions: "关掉第一个" → run_command("{prefix}close 1")
- Change routing mode: "切换到直连模式" → run_command("{prefix}direct")
- Set a reminder: "10分钟后提醒我" → run_command("{prefix}remind 10m 提醒我")
- Check status: "看看现在有哪些 session" → run_command("{prefix}list")
- Any other command the user would type manually
Available bot commands (pass verbatim to run_command):
{prefix}new <dir> [msg] [--perm default|edit|plan|bypass] — create session
{prefix}close [n|conv_id] — close session
{prefix}switch <n> — switch active session
{prefix}perm <mode> [conv_id] — permission mode: default, edit, plan, bypass
{prefix}direct — direct mode (bypass LLM for CC messages)
{prefix}smart — smart mode (LLM routing, default)
{prefix}list — list sessions
{prefix}remind <Ns|Nm|Nh> <msg> — one-shot reminder
{prefix}tasks — list background tasks
### Host shell commands (use `run_shell`)
`run_shell` executes shell commands on the host machine (git, ls, cat, pip, etc.).
NEVER use `run_shell` for bot control. NEVER use `run_command` for shell commands.
## Session responsibilities
1. NEW session: call `create_conversation` with the project name/path. \
If the user's message also contains a task, pass it as `initial_message` too.
2. Follow-up to ACTIVE session: call `send_to_conversation` with the active conv_id shown above.
3. List sessions: call `list_conversations`.
4. Close session: call `close_conversation`.
5. GENERAL QUESTIONS: If the user asks a general question (not about a specific project or file), \
answer directly using your own knowledge. Do NOT create a session for simple Q&A.
6. WEB / SEARCH: Use the `web` tool when the user needs current information. \
Call it ONCE (or at most twice with a refined query). Then synthesize and reply — \
do NOT keep searching in a loop. If the first search returns results, use them.
7. BACKGROUND TASKS: When `create_conversation` or `send_to_conversation` returns a \
"Task #... started" message, the task is running in the background. \
Immediately reply to the user that the task has started and they will be notified. \
Do NOT call `task_status` in a loop waiting for it — the system sends a notification when done.
3. BOT CONTROL: call `run_command` with the appropriate command.
4. GENERAL QUESTIONS: answer directly — do NOT create a session for simple Q&A.
5. WEB / SEARCH: use `web` at most twice, then synthesize and reply.
6. BACKGROUND TASKS: when a task starts, reply immediately — do NOT poll `task_status`.
## Progress queries
When the user asks about task progress (e.g. "怎么样了?" "做得如何?" "进度"),
use `session_progress` with the active conv_id:
- busy=true → summarize recent_tools to tell the user what CC is doing
- busy=false + last_result → summarize the result
- pending_approval is not empty → remind the user to approve/deny
- error is not empty → report the error details
## Passthrough mode
Direct mode sends messages straight to the Claude Code session (no LLM overhead).
The user gets an immediate "executing" confirmation; results are pushed to Feishu on completion.
Guidelines:
- Relay Claude Code's output verbatim.
- If no active session and the user sends a task without naming a directory, ask them which project.
- For general knowledge questions (e.g., "what is a Python generator?", "explain async/await"), \
answer directly without creating a session.
- After using any tool, always produce a final text reply to the user. Never end a turn on a tool call.
- If no active session and the user sends a task without naming a directory, ask which project.
- After using any tool, always produce a final text reply. Never end a turn on a tool call.
- Keep your own words brief — let Claude Code's output speak.
- Reply in the same language the user uses (Chinese or English).
"""
@@ -69,35 +99,6 @@ Guidelines:
MAX_ITERATIONS = 10
_TOOL_MAP = {t.name: t for t in TOOLS}
QUESTION_PATTERNS = [
r'\?$', # ends with ?
r'$', # ends with Chinese ?
r'\b(what|how|why|when|where|who|which|explain|describe|tell me|can you|could you|is there|are there|do you know)\b',
r'(什么|怎么|为什么|何时|哪里|谁|哪个|解释|描述|告诉我|能否|可以|有没有|是不是)',
]
def _is_general_question(text: str) -> bool:
"""Check if text looks like a general knowledge question (not a project task)."""
text_lower = text.lower().strip()
project_indicators = [
'create', 'make', 'build', 'fix', 'update', 'delete', 'remove', 'add',
'implement', 'refactor', 'test', 'run', 'execute', 'start', 'stop',
'project', 'folder', 'directory', 'file', 'code', 'session',
'创建', '制作', '构建', '修复', '更新', '删除', '添加', '实现', '重构', '测试', '运行', '项目', '文件夹', '文件', '代码',
]
for indicator in project_indicators:
if indicator in text_lower:
return False
for pattern in QUESTION_PATTERNS:
if re.search(pattern, text_lower, re.IGNORECASE):
return True
return False
class OrchestrationAgent:
"""Per-user agent with conversation history and active session tracking."""
@@ -132,6 +133,7 @@ class OrchestrationAgent:
working_dir=WORKING_DIR,
active_session_line=active_line,
today=today,
prefix=_P,
)
def get_active_conv(self, user_id: str) -> Optional[str]:
@@ -156,10 +158,22 @@ class OrchestrationAgent:
logger.info(">>> user=...%s conv=%s msg=%r", short_uid, active_conv, text[:80])
logger.debug(" history_len=%d", len(self._history[user_id]))
# Always handle bot commands first — even in passthrough mode.
# Bot commands must never reach Claude Code.
from config import COMMAND_PREFIX
if text.strip().startswith(COMMAND_PREFIX):
from bot.commands import handle_command
result = await handle_command(user_id, text)
if result is not None:
return result
logger.debug(" unknown command, falling through to LLM")
# Passthrough mode: if enabled and active session, bypass LLM
if self._passthrough[user_id] and active_conv:
try:
reply = await manager.send(active_conv, text, user_id=user_id)
from orchestrator.tools import get_current_chat
chat_id = get_current_chat()
reply = await manager.send_message(active_conv, text, user_id=user_id, chat_id=chat_id)
logger.info("<<< [passthrough] reply: %r", reply[:120])
return reply
except KeyError:
@@ -169,23 +183,6 @@ class OrchestrationAgent:
logger.exception("Passthrough error for user=%s", user_id)
return f"[Error] {exc}"
# Direct Q&A: if no active session and message looks like a general question, answer directly
if not active_conv and _is_general_question(text):
logger.debug(" → direct Q&A (no tools)")
llm_no_tools = ChatOpenAI(
base_url=OPENAI_BASE_URL,
api_key=OPENAI_API_KEY,
model=OPENAI_MODEL,
temperature=0.7,
)
qa_prompt = (
"You are a helpful assistant. Answer the user's question concisely and accurately. "
"Reply in the same language the user uses.\n\n"
f"Question: {text}"
)
response = await llm_no_tools.ainvoke([HumanMessage(content=qa_prompt)])
return response.content or ""
messages: list[BaseMessage] = (
[SystemMessage(content=self._build_system_prompt(user_id))]
+ self._history[user_id]
+102 -5
View File
@@ -83,7 +83,6 @@ class CreateConversationInput(BaseModel):
)
initial_message: Optional[str] = Field(None, description="Optional first message to send after spawning")
idle_timeout: Optional[int] = Field(None, description="Idle timeout in seconds (default 1800)")
cc_timeout: Optional[float] = Field(None, description="Claude Code execution timeout in seconds (default 300)")
class SendToConversationInput(BaseModel):
@@ -108,23 +107,24 @@ class CreateConversationTool(BaseTool):
)
args_schema: Type[BaseModel] = CreateConversationInput
def _run(self, working_dir: str, initial_message: Optional[str] = None, idle_timeout: Optional[int] = None, cc_timeout: Optional[float] = None) -> str:
def _run(self, working_dir: str, initial_message: Optional[str] = None, idle_timeout: Optional[int] = None) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, working_dir: str, initial_message: Optional[str] = None, idle_timeout: Optional[int] = None, cc_timeout: Optional[float] = None) -> str:
async def _arun(self, working_dir: str, initial_message: Optional[str] = None, idle_timeout: Optional[int] = None) -> str:
try:
resolved = _resolve_dir(working_dir)
except ValueError as exc:
return json.dumps({"error": str(exc)})
user_id = get_current_user()
chat_id = get_current_chat()
conv_id = str(uuid.uuid4())[:8]
await manager.create(
conv_id,
str(resolved),
owner_id=user_id or "",
idle_timeout=idle_timeout or 1800,
cc_timeout=cc_timeout or 300.0,
chat_id=chat_id,
)
result: dict = {
@@ -154,8 +154,9 @@ class SendToConversationTool(BaseTool):
async def _arun(self, conv_id: str, message: str) -> str:
user_id = get_current_user()
chat_id = get_current_chat()
try:
output = await manager.send(conv_id, message, user_id=user_id)
output = await manager.send_and_wait(conv_id, message, user_id=user_id, chat_id=chat_id)
return json.dumps({"conv_id": conv_id, "response": output}, ensure_ascii=False)
except KeyError:
return json.dumps({"error": f"No active session for conv_id={conv_id!r}"})
@@ -716,12 +717,108 @@ class TaskStatusTool(BaseTool):
}, ensure_ascii=False)
class RunCommandInput(BaseModel):
command: str = Field(
...,
description=(
"A bot slash command to execute (e.g. '//perm edit', '//close 1', '//switch 2'). "
"This runs bot control commands — NOT shell commands on the host machine. "
"Use run_shell for host shell commands (git, ls, etc.)."
),
)
class RunCommandTool(BaseTool):
name: str = "run_command"
description: str = (
"Execute a PhoneWork bot slash command on behalf of the user. "
"Use this to control sessions, switch modes, change permissions, etc. "
"Examples: '/perm edit', '/close 1', '/switch 2', '/direct', '/smart', '/status'. "
"Do NOT use this for shell commands — use run_shell for those."
)
args_schema: Type[BaseModel] = RunCommandInput
def _run(self, command: str) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, command: str) -> str:
from bot.commands import handle_command
from orchestrator.tools import get_current_user
user_id = get_current_user()
if not user_id:
return "Error: no user context"
result = await handle_command(user_id, command.strip())
if result is None:
return f"Unknown command: {command!r}. Use /help to see available commands."
return result
class SessionProgressInput(BaseModel):
conv_id: str = Field(..., description="Conversation ID to check progress")
class SessionProgressTool(BaseTool):
name: str = "session_progress"
description: str = (
"Check the progress of a running Claude Code session. "
"Returns: busy status, elapsed time, recent tool calls, "
"recent text output, and any pending approval requests. "
"Use this when the user asks about task status or progress."
)
args_schema: Type[BaseModel] = SessionProgressInput
def _run(self, conv_id: str) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, conv_id: str) -> str:
user_id = get_current_user()
progress = manager.get_progress(conv_id, user_id)
if progress is None:
return json.dumps({"error": f"Session {conv_id} not found"})
return json.dumps({
"busy": progress.busy,
"elapsed_seconds": int(progress.elapsed_seconds),
"current_prompt": progress.current_prompt[:100],
"recent_text": progress.text_messages[-3:],
"recent_tools": progress.tool_calls[-5:],
"last_result": progress.last_result[:500] if not progress.busy else "",
"error": progress.error,
"pending_approval": progress.pending_approval,
}, ensure_ascii=False)
class InterruptConversationInput(BaseModel):
conv_id: str = Field(..., description="Conversation ID to interrupt")
class InterruptConversationTool(BaseTool):
name: str = "interrupt_conversation"
description: str = "Interrupt a running Claude Code task in a session."
args_schema: Type[BaseModel] = InterruptConversationInput
def _run(self, conv_id: str) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, conv_id: str) -> str:
user_id = get_current_user()
try:
success = await manager.interrupt(conv_id, user_id)
return "Interrupted" if success else "No active task to interrupt"
except KeyError:
return f"Session {conv_id} not found"
except PermissionError as e:
return str(e)
# Module-level tool list for easy import
TOOLS = [
CreateConversationTool(),
SendToConversationTool(),
ListConversationsTool(),
CloseConversationTool(),
SessionProgressTool(),
InterruptConversationTool(),
RunCommandTool(),
ShellTool(),
FileReadTool(),
FileWriteTool(),
+3
View File
@@ -0,0 +1,3 @@
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
+5
View File
@@ -0,0 +1,5 @@
pytest>=8.0.0
pytest-asyncio>=0.24.0
pytest-bdd>=7.0.0
pytest-recording>=0.13.0
pytest-mock>=3.12.0
+1 -1
View File
@@ -4,7 +4,7 @@ lark-oapi>=1.3.0
langchain>=0.2.0
langchain-openai>=0.1.0
langchain-community>=0.2.0
pywinpty>=2.0.0
pyyaml>=6.0.0
rich>=13.0.0
httpx>=0.27.0
claude-agent-sdk
+19
View File
@@ -74,3 +74,22 @@ def create_app(router_secret: Optional[str] = None) -> FastAPI:
logger.info("Router shut down")
return app
# Create top-level app instance for uvicorn
from config import ROUTER_SECRET
app = create_app(router_secret=ROUTER_SECRET)
if __name__ == "__main__":
from config import PORT
import uvicorn
uvicorn.run(
"router.main:app",
host="0.0.0.0",
port=PORT,
reload=False,
log_level="info",
ws_ping_interval=20,
ws_ping_timeout=60,
)
+44 -4
View File
@@ -59,6 +59,7 @@ class NodeRegistry:
self._nodes: dict[str, NodeConnection] = {}
self._user_nodes: dict[str, Set[str]] = {}
self._active_node: dict[str, str] = {}
self._known_users: Set[str] = set()
self._secret = router_secret
self._lock = asyncio.Lock()
@@ -68,6 +69,23 @@ class NodeRegistry:
return True
return secret == self._secret
def track_user(self, user_id: str) -> bool:
"""Record a user as known. Returns True if this is their first message."""
if user_id in self._known_users:
return False
self._known_users.add(user_id)
return True
def _get_notifiable_users(self, node: NodeConnection) -> Set[str]:
"""Get users to notify about a node event.
If the node has explicit serves_users, return those.
Otherwise (serves everyone), return all known users.
"""
if node.serves_users:
return node.serves_users
return self._known_users.copy()
async def register(self, ws: Any, msg: RegisterMessage) -> NodeConnection:
"""Register a new node connection."""
async with self._lock:
@@ -95,12 +113,24 @@ class NodeRegistry:
msg.capabilities,
)
users_to_notify = self._get_notifiable_users(node)
if is_reconnect:
for user_id in msg.serves_users:
for user_id in users_to_notify:
asyncio.create_task(self._notify_reconnect(user_id, node.display_name))
else:
for user_id in users_to_notify:
asyncio.create_task(self._notify_new_node(user_id, node.display_name))
return node
async def _notify_new_node(self, user_id: str, node_name: str) -> None:
"""Notify user about a new node coming online."""
try:
from bot.feishu import send_text
await send_text(user_id, "open_id", f"🟢 Node \"{node_name}\" is online.")
except Exception as e:
logger.error("Failed to send new node notification: %s", e)
async def _notify_reconnect(self, user_id: str, node_name: str) -> None:
"""Notify user about node reconnect."""
try:
@@ -114,7 +144,7 @@ class NodeRegistry:
async with self._lock:
node = self._nodes.pop(node_id, None)
if node:
affected_users = list(node.serves_users)
users_to_notify = self._get_notifiable_users(node)
for user_id in node.serves_users:
if user_id in self._user_nodes:
@@ -128,7 +158,7 @@ class NodeRegistry:
logger.info("Node unregistered: %s", node_id)
for user_id in affected_users:
for user_id in users_to_notify:
asyncio.create_task(self._notify_disconnect(user_id, node.display_name))
async def _notify_disconnect(self, user_id: str, node_name: str) -> None:
@@ -161,7 +191,17 @@ class NodeRegistry:
def get_nodes_for_user(self, user_id: str) -> list[NodeConnection]:
"""Get all nodes that serve a user."""
node_ids = self._user_nodes.get(user_id, set())
# Get nodes explicitly mapped to this user
user_node_ids = self._user_nodes.get(user_id, set())
# Get nodes that serve all users (empty serves_users set)
all_users_node_ids = set()
for node_id, node in self._nodes.items():
if not node.serves_users:
all_users_node_ids.add(node_id)
# Combine both sets
node_ids = user_node_ids | all_users_node_ids
return [self._nodes[nid] for nid in node_ids if nid in self._nodes]
def get_active_node(self, user_id: str) -> Optional[NodeConnection]:
+11 -2
View File
@@ -76,8 +76,17 @@ async def route(user_id: str, chat_id: str, text: str) -> tuple[Optional[str], s
if len(online_nodes) == 1:
return online_nodes[0].node_id, "Only one node available"
if text.strip().startswith("/"):
return "meta", "Meta command"
from config import COMMAND_PREFIX
if text.strip().startswith(COMMAND_PREFIX):
cmd = text.strip().split()[0].lower()
meta_cmds = {COMMAND_PREFIX + s for s in ("nodes", "node", "help", "h", "?")}
if cmd in meta_cmds:
return "meta", "Meta command"
# Session commands: forward to active node directly (no LLM call needed)
active = registry.get_active_node(user_id)
if active:
return active.node_id, "Forwarding command to active node"
return online_nodes[0].node_id, "Forwarding command to first available node"
active_node = registry.get_active_node(user_id)
active_node_id = active_node.node_id if active_node else None
+2 -2
View File
@@ -96,10 +96,10 @@ async def handle_task_complete(msg: TaskComplete) -> None:
"""Handle a task completion notification from a host client."""
logger.info("Task %s completed for user %s", msg.task_id, msg.user_id)
from bot.feishu import send_text
from bot.feishu import send_markdown
try:
await send_text(msg.chat_id, "chat_id", msg.result)
await send_markdown(msg.chat_id, "chat_id", msg.result)
except Exception as e:
logger.error("Failed to send task completion notification: %s", e)
+6 -2
View File
@@ -24,7 +24,8 @@ logger = logging.getLogger(__name__)
async def run_standalone() -> None:
"""Run router + host client in a single process."""
secret = secrets.token_hex(16)
router_url = "ws://127.0.0.1:8000/ws/node"
from config import PORT
router_url = f"ws://127.0.0.1:{PORT}/ws/node"
from router.main import create_app
from host_client.main import NodeClient
@@ -34,12 +35,15 @@ async def run_standalone() -> None:
config.router_url = router_url
config.router_secret = secret
import uvicorn
from config import PORT
app = create_app(router_secret=secret)
config_obj = uvicorn.Config(
app,
host="0.0.0.0",
port=8000,
port=PORT,
log_level="info",
ws_ping_interval=20,
ws_ping_timeout=60,
+38
View File
@@ -0,0 +1,38 @@
# test_websocket.py
import asyncio
import websockets
async def test_connection():
uri = "ws://47.101.162.164:9600/ws/node"
headers = {
"Authorization": "Bearer family-member-x9"
}
try:
print(f"Connecting to {uri}...")
async with websockets.connect(uri, extra_headers=headers) as ws:
print("✅ Connection successful!")
print("WebSocket connection established.")
# 发送注册消息
import json
register_msg = {
"type": "register",
"node_id": "test-node",
"display_name": "Test Node",
"serves_users": ["test-user"],
"working_dir": "/tmp",
"capabilities": ["claude_code", "shell", "file_ops"]
}
await ws.send(json.dumps(register_msg))
print("Sent registration message")
# 等待响应
response = await ws.recv()
print(f"Received response: {response}")
except Exception as e:
print(f"❌ Connection failed: {e}")
if __name__ == "__main__":
asyncio.run(test_connection())
+114
View File
@@ -0,0 +1,114 @@
"""
Shared test fixtures for PhoneWork tests.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
# ── Feishu send mock ─────────────────────────────────────────────────────────
@pytest.fixture
def feishu_calls():
"""Capture all calls to bot.feishu send functions."""
captured: dict[str, list] = {"texts": [], "cards": [], "markdowns": [], "files": []}
async def mock_send_text(receive_id, receive_id_type, text):
captured["texts"].append(text)
async def mock_send_markdown(receive_id, receive_id_type, content):
captured["markdowns"].append(content)
async def mock_send_card(receive_id, receive_id_type, card):
captured["cards"].append(card)
async def mock_send_file(receive_id, receive_id_type, file_path, file_type="stream"):
captured["files"].append(file_path)
with patch("bot.feishu.send_text", side_effect=mock_send_text), \
patch("bot.feishu.send_markdown", side_effect=mock_send_markdown), \
patch("bot.feishu.send_card", side_effect=mock_send_card), \
patch("bot.feishu.send_file", side_effect=mock_send_file), \
patch("bot.handler.send_text", side_effect=mock_send_text), \
patch("bot.handler.send_markdown", side_effect=mock_send_markdown):
yield captured
# ── Singleton state resets ───────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def reset_manager(tmp_path):
from agent.manager import manager
import agent.manager as mgr_mod
# Redirect persistence to tmp_path
original_file = mgr_mod.PERSISTENCE_FILE
mgr_mod.PERSISTENCE_FILE = tmp_path / "sessions.json"
manager._sessions.clear()
yield
manager._sessions.clear()
mgr_mod.PERSISTENCE_FILE = original_file
@pytest.fixture(autouse=True)
def reset_agent():
from orchestrator.agent import agent
agent._history.clear()
agent._active_conv.clear()
agent._passthrough.clear()
agent._user_locks.clear()
yield
agent._history.clear()
agent._active_conv.clear()
agent._passthrough.clear()
agent._user_locks.clear()
@pytest.fixture(autouse=True)
def reset_task_runner():
from agent.task_runner import task_runner
task_runner._tasks.clear()
yield
task_runner._tasks.clear()
@pytest.fixture(autouse=True)
def reset_scheduler(tmp_path):
from agent.scheduler import scheduler
import agent.scheduler as sched_mod
# Redirect persistence to tmp_path so tests don't pollute production data
original_file = sched_mod.PERSISTENCE_FILE
sched_mod.PERSISTENCE_FILE = tmp_path / "scheduled_jobs.json"
for task in list(getattr(scheduler, "_tasks", {}).values()):
task.cancel()
scheduler._jobs.clear()
yield
for task in list(getattr(scheduler, "_tasks", {}).values()):
task.cancel()
scheduler._jobs.clear()
sched_mod.PERSISTENCE_FILE = original_file
@pytest.fixture(autouse=True)
def reset_contextvars():
from orchestrator.tools import set_current_user, set_current_chat
set_current_user(None)
set_current_chat(None)
yield
set_current_user(None)
set_current_chat(None)
# ── Working directory isolation ──────────────────────────────────────────────
@pytest.fixture
def tmp_working_dir(tmp_path, monkeypatch):
import config
import orchestrator.tools as tools_mod
monkeypatch.setattr(config, "WORKING_DIR", tmp_path)
monkeypatch.setattr(tools_mod, "WORKING_DIR", tmp_path)
(tmp_path / "myproject").mkdir()
return tmp_path
+10
View File
@@ -0,0 +1,10 @@
FEISHU_APP_ID: test_app_id
FEISHU_APP_SECRET: test_app_secret
OPENAI_BASE_URL: https://open.bigmodel.cn/api/paas/v4/
OPENAI_API_KEY: test_api_key_for_vcr
OPENAI_MODEL: glm-4.7
WORKING_DIR: /tmp/phonework_test
METASO_API_KEY: ""
ROUTER_MODE: false
ROUTER_SECRET: ""
ALLOWED_OPEN_IDS: []
+396
View File
@@ -0,0 +1,396 @@
"""Tests for bot slash commands (replaces BDD feature tests).
Covers: //help, //new, //close, //switch, //status, //perm,
//direct, //smart, //shell, //remind, //tasks, //stop, //progress, //nodes
"""
from __future__ import annotations
import time
import pytest
from agent.manager import manager, Session
from orchestrator.agent import agent
from orchestrator.tools import set_current_user, set_current_chat
def _setup_user(user_id="user_abc123", chat_id=None):
set_current_user(user_id)
if chat_id:
set_current_chat(chat_id)
def _add_session(conv_id, cwd="/tmp/proj", user_id="user_abc123", activate=False):
session = Session(conv_id=conv_id, cwd=cwd, owner_id=user_id)
manager._sessions[conv_id] = session
if activate:
agent._active_conv[user_id] = conv_id
return session
# ── //help ──────────────────────────────────────────────────────────────────
class TestHelp:
@pytest.mark.asyncio
async def test_help_lists_commands(self):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//help")
for cmd in ("//new", "//list", "//close", "//switch", "//perm",
"//stop", "//progress", "//direct", "//smart", "//shell",
"//help"):
assert cmd in reply, f"Missing {cmd} in help"
# Should NOT list //retry (unimplemented)
assert "//retry" not in reply
# Should list aliases
assert "alias" in reply.lower()
@pytest.mark.asyncio
async def test_h_alias(self):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//h")
assert "//new" in reply
@pytest.mark.asyncio
async def test_unknown_command_returns_none(self):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//unknown_xyz")
assert reply is None
# ── //new ───────────────────────────────────────────────────────────────────
class TestNew:
@pytest.mark.asyncio
async def test_no_args_shows_usage(self):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//new")
assert "Usage" in reply
@pytest.mark.asyncio
async def test_creates_session(self, tmp_working_dir):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//new myproject")
assert "myproject" in reply
assert len(manager.list_sessions(user_id="user_abc123")) == 1
@pytest.mark.asyncio
async def test_path_traversal_blocked(self, tmp_working_dir):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//new ../../etc")
assert "Error" in reply
assert len(manager.list_sessions(user_id="user_abc123")) == 0
@pytest.mark.asyncio
async def test_with_perm_flag(self, tmp_working_dir):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//new myproject --perm plan")
sessions = manager.list_sessions(user_id="user_abc123")
assert len(sessions) == 1
assert sessions[0]["permission_mode"] == "plan"
@pytest.mark.asyncio
async def test_sends_card_when_chat_set(self, tmp_working_dir, feishu_calls):
from bot.commands import handle_command
_setup_user(chat_id="chat1")
reply = await handle_command("user_abc123", "//new myproject")
assert reply == "" # card was sent instead
assert len(feishu_calls["cards"]) >= 1
# ── //close ─────────────────────────────────────────────────────────────────
class TestClose:
@pytest.mark.asyncio
async def test_no_sessions(self):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//close")
assert "No sessions" in reply or "No active" in reply
@pytest.mark.asyncio
async def test_close_active(self):
from bot.commands import handle_command
_setup_user()
_add_session("s1", activate=True)
reply = await handle_command("user_abc123", "//close")
assert "Closed" in reply
assert len(manager.list_sessions(user_id="user_abc123")) == 0
@pytest.mark.asyncio
async def test_close_by_number(self):
from bot.commands import handle_command
_setup_user()
_add_session("s1")
_add_session("s2")
reply = await handle_command("user_abc123", "//close 1")
assert "Closed" in reply
assert len(manager.list_sessions(user_id="user_abc123")) == 1
@pytest.mark.asyncio
async def test_invalid_number(self):
from bot.commands import handle_command
_setup_user()
_add_session("s1")
reply = await handle_command("user_abc123", "//close 9")
assert "Invalid" in reply
@pytest.mark.asyncio
async def test_cannot_close_other_user(self):
from bot.commands import handle_command
_setup_user()
_add_session("s1", user_id="other_user")
reply = await handle_command("user_abc123", "//close s1")
assert "another user" in reply or "not found" in reply.lower()
# ── //switch ────────────────────────────────────────────────────────────────
class TestSwitch:
@pytest.mark.asyncio
async def test_no_sessions(self):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//switch 1")
assert "No sessions" in reply
@pytest.mark.asyncio
async def test_valid_switch(self):
from bot.commands import handle_command
_setup_user()
_add_session("s1")
_add_session("s2")
reply = await handle_command("user_abc123", "//switch 2")
assert "Switched" in reply
assert agent._active_conv["user_abc123"] == "s2"
@pytest.mark.asyncio
async def test_out_of_range(self):
from bot.commands import handle_command
_setup_user()
_add_session("s1")
reply = await handle_command("user_abc123", "//switch 5")
assert "Invalid" in reply
@pytest.mark.asyncio
async def test_non_numeric(self):
from bot.commands import handle_command
_setup_user()
_add_session("s1")
reply = await handle_command("user_abc123", "//switch abc")
assert "Invalid" in reply
# ── //status ────────────────────────────────────────────────────────────────
class TestList:
@pytest.mark.asyncio
async def test_no_sessions(self):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//list")
assert "No active sessions" in reply
@pytest.mark.asyncio
async def test_shows_sessions(self):
from bot.commands import handle_command
_setup_user()
_add_session("s1")
_add_session("s2")
reply = await handle_command("user_abc123", "//list")
assert "s1" in reply
assert "s2" in reply
@pytest.mark.asyncio
async def test_shows_active_marker(self):
from bot.commands import handle_command
_setup_user()
_add_session("s1", activate=True)
reply = await handle_command("user_abc123", "//list")
assert "" in reply
@pytest.mark.asyncio
async def test_status_alias_still_works(self):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//status")
assert "No active sessions" in reply
# ── //perm ──────────────────────────────────────────────────────────────────
class TestPerm:
@pytest.mark.asyncio
async def test_no_args_shows_usage(self):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//perm")
assert "Usage" in reply
@pytest.mark.asyncio
async def test_set_edit(self):
from bot.commands import handle_command
_setup_user()
_add_session("s1", activate=True)
reply = await handle_command("user_abc123", "//perm edit")
assert "edit" in reply
assert manager._sessions["s1"].permission_mode == "acceptEdits"
@pytest.mark.asyncio
async def test_set_plan(self):
from bot.commands import handle_command
_setup_user()
_add_session("s1", activate=True)
reply = await handle_command("user_abc123", "//perm plan")
assert "plan" in reply
assert manager._sessions["s1"].permission_mode == "plan"
@pytest.mark.asyncio
async def test_set_auto(self):
from bot.commands import handle_command
_setup_user()
_add_session("s1", activate=True)
reply = await handle_command("user_abc123", "//perm auto")
assert "auto" in reply
assert manager._sessions["s1"].permission_mode == "dontAsk"
@pytest.mark.asyncio
async def test_unknown_mode(self):
from bot.commands import handle_command
_setup_user()
_add_session("s1", activate=True)
reply = await handle_command("user_abc123", "//perm xyz")
assert "Unknown" in reply
@pytest.mark.asyncio
async def test_no_active_session(self):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//perm edit")
assert "No active session" in reply
# ── //direct + //smart ──────────────────────────────────────────────────────
class TestDirectSmart:
@pytest.mark.asyncio
async def test_direct_requires_session(self):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//direct")
assert "No active session" in reply
@pytest.mark.asyncio
async def test_direct_enables_passthrough(self):
from bot.commands import handle_command
_setup_user()
_add_session("s1", activate=True)
reply = await handle_command("user_abc123", "//direct")
assert "Direct mode ON" in reply
assert agent._passthrough["user_abc123"] is True
@pytest.mark.asyncio
async def test_smart_disables_passthrough(self):
from bot.commands import handle_command
_setup_user()
_add_session("s1", activate=True)
agent._passthrough["user_abc123"] = True
reply = await handle_command("user_abc123", "//smart")
assert "Smart mode ON" in reply
assert agent._passthrough["user_abc123"] is False
# ── //shell ─────────────────────────────────────────────────────────────────
class TestShell:
@pytest.mark.asyncio
async def test_no_args_shows_usage(self):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//shell")
assert "Usage" in reply
@pytest.mark.asyncio
async def test_echo(self, tmp_working_dir):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//shell echo hello")
assert "hello" in reply
assert "exit code: 0" in reply
@pytest.mark.asyncio
async def test_blocked_dangerous(self, tmp_working_dir):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//shell rm -rf /")
assert "Blocked" in reply
# ── //remind ────────────────────────────────────────────────────────────────
class TestRemind:
@pytest.mark.asyncio
async def test_no_args(self):
from bot.commands import handle_command
_setup_user(chat_id="chat1")
reply = await handle_command("user_abc123", "//remind")
assert "Usage" in reply
@pytest.mark.asyncio
async def test_missing_message(self):
from bot.commands import handle_command
_setup_user(chat_id="chat1")
reply = await handle_command("user_abc123", "//remind 10m")
assert "Usage" in reply
@pytest.mark.asyncio
async def test_valid_reminder(self):
from bot.commands import handle_command
from agent.scheduler import scheduler
_setup_user(chat_id="chat1")
reply = await handle_command("user_abc123", "//remind 30s check build")
assert "Reminder" in reply
assert len(scheduler._jobs) == 1
# ── //tasks ─────────────────────────────────────────────────────────────────
class TestTasks:
@pytest.mark.asyncio
async def test_no_tasks(self):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//tasks")
assert "No background tasks" in reply
@pytest.mark.asyncio
async def test_shows_running_task(self):
from bot.commands import handle_command
from agent.task_runner import task_runner, BackgroundTask, TaskStatus
_setup_user()
task_runner._tasks["t1"] = BackgroundTask(
task_id="t1", description="fix bug", started_at=time.time(),
status=TaskStatus.RUNNING,
)
reply = await handle_command("user_abc123", "//tasks")
assert "t1" in reply
assert "" in reply
# ── //nodes ─────────────────────────────────────────────────────────────────
class TestNodes:
@pytest.mark.asyncio
async def test_nodes_outside_router_mode(self, monkeypatch):
import config
monkeypatch.setattr(config, "ROUTER_MODE", False)
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//nodes")
assert "Not in router mode" in reply
+969
View File
@@ -0,0 +1,969 @@
"""Unit tests for the SDK migration (secretary model).
Tests cover:
- SDKSession lifecycle, message buffering, get_progress, approval
- sdk_hooks audit + deny
- SessionManager new methods (send_message, send_and_wait, get_progress, interrupt, approve)
- audit.py new functions (log_tool_use, log_permission_decision)
- bot/commands.py new commands (//stop, //progress, //perm auto)
- orchestrator/tools.py new tools (SessionProgressTool, InterruptConversationTool)
- bot/handler.py text approval fallback
"""
from __future__ import annotations
import asyncio
import json
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
import pytest
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_audit_dir(tmp_path):
"""Redirect audit logs to a temp directory."""
import agent.audit as audit_mod
original = audit_mod.AUDIT_DIR
audit_mod.AUDIT_DIR = tmp_path / "audit"
yield tmp_path / "audit"
audit_mod.AUDIT_DIR = original
@pytest.fixture
def mock_sdk_client():
"""Create a mock ClaudeSDKClient that yields controllable messages."""
client = AsyncMock()
client.connect = AsyncMock()
client.disconnect = AsyncMock()
client.query = AsyncMock()
client.interrupt = AsyncMock()
client.set_permission_mode = AsyncMock()
return client
@pytest.fixture
def mock_feishu():
"""Mock all Feishu send functions."""
captured = {"texts": [], "cards": [], "markdowns": []}
async def _send_text(rid, rtype, text):
captured["texts"].append(text)
async def _send_card(rid, rtype, card):
captured["cards"].append(card)
async def _send_markdown(rid, rtype, content):
captured["markdowns"].append(content)
with patch("bot.feishu.send_text", side_effect=_send_text), \
patch("bot.feishu.send_card", side_effect=_send_card), \
patch("bot.feishu.send_markdown", side_effect=_send_markdown), \
patch("bot.handler.send_text", side_effect=_send_text), \
patch("bot.handler.send_markdown", side_effect=_send_markdown):
yield captured
# ===========================================================================
# 1. SDKSession unit tests
# ===========================================================================
class TestSDKSessionProgress:
"""Test SDKSession.get_progress() with buffered messages."""
def test_initial_progress_is_idle(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
p = s.get_progress()
assert p.busy is False
assert p.current_prompt == ""
assert p.text_messages == []
assert p.tool_calls == []
def test_progress_after_manual_state_change(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
s._busy = True
s._current_prompt = "write hello.py"
s._started_at = time.time() - 5
s._text_buffer = ["I'll create the file", "Done"]
s._tool_buffer = ["Write(hello.py)", "Read(hello.py)"]
p = s.get_progress()
assert p.busy is True
assert p.current_prompt == "write hello.py"
assert p.elapsed_seconds >= 4
assert len(p.text_messages) == 2
assert len(p.tool_calls) == 2
def test_buffer_limits(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
for i in range(30):
s._text_buffer.append(f"text-{i}")
if len(s._text_buffer) > s.MAX_BUFFER_TEXTS:
s._text_buffer.pop(0)
assert len(s._text_buffer) == s.MAX_BUFFER_TEXTS
assert s._text_buffer[0] == "text-10"
def test_progress_pending_approval(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
s._pending_approval_desc = "Bash: `rm -rf /tmp/test`"
p = s.get_progress()
assert p.pending_approval == "Bash: `rm -rf /tmp/test`"
class TestSDKSessionApproval:
"""Test the approval mechanism."""
@pytest.mark.asyncio
async def test_approve_resolves_future(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
loop = asyncio.get_running_loop()
s._pending_approval = loop.create_future()
await s.approve(True)
assert s._pending_approval.result() is True
@pytest.mark.asyncio
async def test_approve_deny(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
loop = asyncio.get_running_loop()
s._pending_approval = loop.create_future()
await s.approve(False)
assert s._pending_approval.result() is False
@pytest.mark.asyncio
async def test_approve_no_pending_is_noop(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
# Should not raise
await s.approve(True)
class TestSDKSessionClose:
"""Test clean shutdown."""
@pytest.mark.asyncio
async def test_close_without_start(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
# Should not raise
await s.close()
assert s.client is None
@pytest.mark.asyncio
async def test_close_disconnects_client(self, mock_sdk_client):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
s.client = mock_sdk_client
s._message_loop_task = None
await s.close()
mock_sdk_client.disconnect.assert_awaited_once()
assert s.client is None
class TestSDKSessionSend:
"""Test send() and send_and_wait() with mocked client."""
@pytest.mark.asyncio
async def test_send_returns_immediately(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1", chat_id="chat1")
# Provide a mock client that has receive_messages yielding nothing
mock_client = AsyncMock()
mock_client.query = AsyncMock()
async def _empty_messages():
return
yield # make it an async generator
mock_client.receive_messages = _empty_messages
s.client = mock_client
result = await s.send("hello")
assert "已开始执行" in result
assert s._busy is True
assert s._current_prompt == "hello"
# Cleanup
if s._message_loop_task:
s._message_loop_task.cancel()
try:
await s._message_loop_task
except asyncio.CancelledError:
pass
class TestSDKSessionFormatSummary:
"""Test _format_tool_summary."""
def test_bash_summary(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
result = s._format_tool_summary("Bash", {"command": "ls -la"})
assert "`ls -la`" in result
def test_edit_summary(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
result = s._format_tool_summary("Edit", {"file_path": "/tmp/test.py"})
assert "test.py" in result
def test_other_summary_truncated(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
result = s._format_tool_summary("CustomTool", {"key": "x" * 500})
assert len(result) <= 200
# ===========================================================================
# 2. sdk_hooks tests
# ===========================================================================
class TestSDKHooks:
@pytest.mark.asyncio
async def test_audit_hook_logs(self, tmp_audit_dir):
from agent.sdk_hooks import audit_hook
input_data = {
"session_id": "test-session",
"tool_name": "Bash",
"tool_input": {"command": "echo hello"},
"tool_response": "hello\n",
}
result = await audit_hook(input_data, "tu-1", {"signal": None})
assert result == {}
# Check JSONL was written
log_file = tmp_audit_dir / "test-session.jsonl"
assert log_file.exists()
entry = json.loads(log_file.read_text().strip())
assert entry["type"] == "tool_use"
assert entry["tool_name"] == "Bash"
@pytest.mark.asyncio
async def test_deny_dangerous_rm_rf(self):
from agent.sdk_hooks import deny_dangerous_hook
input_data = {
"tool_name": "Bash",
"tool_input": {"command": "rm -rf /"},
}
result = await deny_dangerous_hook(input_data, None, {"signal": None})
assert result.get("hookSpecificOutput", {}).get("permissionDecision") == "deny"
@pytest.mark.asyncio
async def test_deny_allows_safe_commands(self):
from agent.sdk_hooks import deny_dangerous_hook
input_data = {
"tool_name": "Bash",
"tool_input": {"command": "ls -la /tmp"},
}
result = await deny_dangerous_hook(input_data, None, {"signal": None})
assert result == {}
@pytest.mark.asyncio
async def test_deny_ignores_non_bash(self):
from agent.sdk_hooks import deny_dangerous_hook
input_data = {
"tool_name": "Edit",
"tool_input": {"file_path": "/etc/passwd"},
}
result = await deny_dangerous_hook(input_data, None, {"signal": None})
assert result == {}
def test_build_hooks_returns_expected_structure(self):
from agent.sdk_hooks import build_hooks
hooks = build_hooks("test-conv")
assert "PostToolUse" in hooks
assert "PreToolUse" in hooks
assert len(hooks["PostToolUse"]) == 1
assert len(hooks["PreToolUse"]) == 1
# ===========================================================================
# 3. manager tests (new methods)
# ===========================================================================
class TestSessionManagerNew:
@pytest.fixture(autouse=True)
def _reset(self):
from agent.manager import manager
manager._sessions.clear()
yield
manager._sessions.clear()
@pytest.mark.asyncio
async def test_create_session_no_cc_timeout(self):
from agent.manager import manager, Session
s = await manager.create("c1", "/tmp/test", owner_id="u1", chat_id="chat1")
assert s.conv_id == "c1"
assert s.chat_id == "chat1"
assert not hasattr(s, "cc_timeout") or "cc_timeout" not in s.to_dict()
@pytest.mark.asyncio
async def test_get_progress_no_session(self):
from agent.manager import manager
result = manager.get_progress("nonexistent")
assert result is None
@pytest.mark.asyncio
async def test_get_progress_no_sdk_session(self):
from agent.manager import manager
await manager.create("c1", "/tmp/test", owner_id="u1")
p = manager.get_progress("c1", user_id="u1")
assert p is not None
assert p.busy is False
@pytest.mark.asyncio
async def test_interrupt_no_sdk_session(self):
from agent.manager import manager
await manager.create("c1", "/tmp/test", owner_id="u1")
result = await manager.interrupt("c1", user_id="u1")
assert result is False
@pytest.mark.asyncio
async def test_approve_no_sdk_session(self):
from agent.manager import manager
await manager.create("c1", "/tmp/test", owner_id="u1")
# Should not raise
await manager.approve("c1", True)
@pytest.mark.asyncio
async def test_close_with_sdk_session(self):
from agent.manager import manager
from agent.sdk_session import SDKSession
await manager.create("c1", "/tmp/test", owner_id="u1")
mock_sdk = MagicMock(spec=SDKSession)
mock_sdk.close = AsyncMock()
manager._sessions["c1"].sdk_session = mock_sdk
result = await manager.close("c1", user_id="u1")
assert result is True
mock_sdk.close.assert_awaited_once()
@pytest.mark.asyncio
async def test_set_permission_mode_with_sdk_session(self):
from agent.manager import manager
from agent.sdk_session import SDKSession
await manager.create("c1", "/tmp/test", owner_id="u1")
mock_sdk = MagicMock(spec=SDKSession)
mock_sdk.set_permission_mode = AsyncMock()
manager._sessions["c1"].sdk_session = mock_sdk
manager.set_permission_mode("c1", "acceptEdits", user_id="u1")
assert manager._sessions["c1"].permission_mode == "acceptEdits"
def test_list_sessions_includes_busy(self):
from agent.manager import manager, Session
from agent.sdk_session import SDKSession
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
mock_sdk = MagicMock(spec=SDKSession)
mock_sdk._busy = True
session.sdk_session = mock_sdk
manager._sessions["c1"] = session
result = manager.list_sessions()
assert result[0]["busy"] is True
def test_session_from_dict_strips_old_fields(self):
from agent.manager import Session
old_data = {
"conv_id": "c1",
"cwd": "/tmp",
"owner_id": "u1",
"cc_session_id": "old-uuid",
"started": True,
"cc_timeout": 300.0,
"last_activity": 0.0,
"idle_timeout": 1800,
"permission_mode": "default",
}
s = Session.from_dict(old_data)
assert s.conv_id == "c1"
assert not hasattr(s, "cc_session_id")
def test_session_to_dict_excludes_sdk_session(self):
from agent.manager import Session
s = Session(conv_id="c1", cwd="/tmp")
s.sdk_session = MagicMock()
d = s.to_dict()
assert "sdk_session" not in d
# ===========================================================================
# 4. audit tests (new functions)
# ===========================================================================
class TestAuditNewFunctions:
def test_log_tool_use(self, tmp_audit_dir):
from agent.audit import log_tool_use
log_tool_use(
session_id="s1",
tool_name="Bash",
tool_input={"command": "echo hello"},
tool_response="hello\n",
)
log_file = tmp_audit_dir / "s1.jsonl"
assert log_file.exists()
entry = json.loads(log_file.read_text().strip())
assert entry["type"] == "tool_use"
assert entry["tool_name"] == "Bash"
def test_log_permission_decision_approved(self, tmp_audit_dir):
from agent.audit import log_permission_decision
log_permission_decision(
conv_id="c1",
tool_name="Bash",
tool_input={"command": "rm test.txt"},
approved=True,
)
log_file = tmp_audit_dir / "c1.jsonl"
assert log_file.exists()
entry = json.loads(log_file.read_text().strip())
assert entry["type"] == "permission_decision"
assert entry["approved"] is True
def test_log_permission_decision_denied(self, tmp_audit_dir):
from agent.audit import log_permission_decision
log_permission_decision(
conv_id="c1",
tool_name="Write",
tool_input={"file_path": "/etc/passwd"},
approved=False,
)
log_file = tmp_audit_dir / "c1.jsonl"
entry = json.loads(log_file.read_text().strip())
assert entry["approved"] is False
# ===========================================================================
# 5. bot/commands.py new commands
# ===========================================================================
class TestNewCommands:
@pytest.fixture(autouse=True)
def _reset(self):
from agent.manager import manager
from orchestrator.agent import agent
from orchestrator.tools import set_current_user, set_current_chat
manager._sessions.clear()
agent._active_conv.clear()
agent._passthrough.clear()
set_current_user(None)
set_current_chat(None)
yield
manager._sessions.clear()
agent._active_conv.clear()
@pytest.mark.asyncio
async def test_stop_no_active_session(self):
from bot.commands import handle_command
result = await handle_command("u1", "//stop")
assert "No active session" in result
@pytest.mark.asyncio
async def test_stop_with_session_no_sdk(self):
from bot.commands import handle_command
from agent.manager import manager, Session
from orchestrator.agent import agent
from orchestrator.tools import set_current_user
set_current_user("u1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
result = await handle_command("u1", "//stop")
assert "No active task" in result
@pytest.mark.asyncio
async def test_progress_no_session(self):
from bot.commands import handle_command
result = await handle_command("u1", "//progress")
assert "No active session" in result
@pytest.mark.asyncio
async def test_progress_idle_session(self):
from bot.commands import handle_command
from agent.manager import manager, Session
from orchestrator.agent import agent
from orchestrator.tools import set_current_user
set_current_user("u1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
result = await handle_command("u1", "//progress")
assert "空闲" in result
@pytest.mark.asyncio
async def test_progress_busy_session(self):
from bot.commands import handle_command
from agent.manager import manager, Session
from agent.sdk_session import SDKSession
from orchestrator.agent import agent
from orchestrator.tools import set_current_user
set_current_user("u1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
sdk = SDKSession("c1", "/tmp", "u1")
sdk._busy = True
sdk._started_at = time.time() - 10
sdk._tool_buffer = ["Bash(echo hello)", "Read(test.py)"]
session.sdk_session = sdk
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
result = await handle_command("u1", "//progress")
assert "执行中" in result
assert "Bash" in result
@pytest.mark.asyncio
async def test_progress_with_pending_approval(self):
from bot.commands import handle_command
from agent.manager import manager, Session
from agent.sdk_session import SDKSession
from orchestrator.agent import agent
from orchestrator.tools import set_current_user
set_current_user("u1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
sdk = SDKSession("c1", "/tmp", "u1")
sdk._busy = True
sdk._started_at = time.time() - 5
sdk._pending_approval_desc = "Bash: `rm test`"
session.sdk_session = sdk
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
result = await handle_command("u1", "//progress")
assert "审批" in result
@pytest.mark.asyncio
async def test_perm_auto_alias(self):
from bot.commands import handle_command
from agent.manager import manager, Session
from orchestrator.agent import agent
from orchestrator.tools import set_current_user
set_current_user("u1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
result = await handle_command("u1", "//perm auto")
assert "auto" in result
assert manager._sessions["c1"].permission_mode == "dontAsk"
# ===========================================================================
# 6. orchestrator/tools.py new tools
# ===========================================================================
class TestNewTools:
@pytest.fixture(autouse=True)
def _reset(self):
from agent.manager import manager
from orchestrator.tools import set_current_user, set_current_chat
manager._sessions.clear()
set_current_user("u1")
set_current_chat("chat1")
yield
manager._sessions.clear()
set_current_user(None)
set_current_chat(None)
@pytest.mark.asyncio
async def test_session_progress_not_found(self):
from orchestrator.tools import SessionProgressTool
tool = SessionProgressTool()
result = await tool._arun("nonexistent")
data = json.loads(result)
assert "error" in data
@pytest.mark.asyncio
async def test_session_progress_idle(self):
from orchestrator.tools import SessionProgressTool
from agent.manager import manager, Session
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
manager._sessions["c1"] = session
tool = SessionProgressTool()
result = await tool._arun("c1")
data = json.loads(result)
assert data["busy"] is False
@pytest.mark.asyncio
async def test_session_progress_busy(self):
from orchestrator.tools import SessionProgressTool
from agent.manager import manager, Session
from agent.sdk_session import SDKSession
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
sdk = SDKSession("c1", "/tmp", "u1")
sdk._busy = True
sdk._started_at = time.time() - 30
sdk._current_prompt = "fix the bug"
sdk._tool_buffer = ["Read(main.py)", "Edit(main.py)"]
session.sdk_session = sdk
manager._sessions["c1"] = session
tool = SessionProgressTool()
result = await tool._arun("c1")
data = json.loads(result)
assert data["busy"] is True
assert data["elapsed_seconds"] >= 29
assert "Edit" in str(data["recent_tools"])
@pytest.mark.asyncio
async def test_interrupt_not_found(self):
from orchestrator.tools import InterruptConversationTool
tool = InterruptConversationTool()
result = await tool._arun("nonexistent")
assert "not found" in result
@pytest.mark.asyncio
async def test_interrupt_no_active_task(self):
from orchestrator.tools import InterruptConversationTool
from agent.manager import manager, Session
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
manager._sessions["c1"] = session
tool = InterruptConversationTool()
result = await tool._arun("c1")
assert "No active task" in result
# ===========================================================================
# 7. bot/handler.py text approval fallback
# ===========================================================================
class TestTextApprovalFallback:
@pytest.fixture(autouse=True)
def _reset(self):
from agent.manager import manager
from orchestrator.agent import agent
from orchestrator.tools import set_current_chat
manager._sessions.clear()
agent._active_conv.clear()
set_current_chat(None)
yield
manager._sessions.clear()
agent._active_conv.clear()
@pytest.mark.asyncio
async def test_y_resolves_pending_approval(self, mock_feishu):
from agent.manager import manager, Session
from agent.sdk_session import SDKSession
from orchestrator.agent import agent
from orchestrator.tools import set_current_chat
set_current_chat("chat1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
sdk = SDKSession("c1", "/tmp", "u1", chat_id="chat1")
loop = asyncio.get_running_loop()
sdk._pending_approval = loop.create_future()
session.sdk_session = sdk
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
from bot.handler import _process_message
await _process_message("u1", "chat1", "y")
assert sdk._pending_approval.done()
assert sdk._pending_approval.result() is True
# handler calls send_text which is mocked separately from send_markdown
assert any("批准" in t for t in mock_feishu["texts"])
@pytest.mark.asyncio
async def test_n_denies_pending_approval(self, mock_feishu):
from agent.manager import manager, Session
from agent.sdk_session import SDKSession
from orchestrator.agent import agent
from orchestrator.tools import set_current_chat
set_current_chat("chat1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
sdk = SDKSession("c1", "/tmp", "u1", chat_id="chat1")
loop = asyncio.get_running_loop()
sdk._pending_approval = loop.create_future()
session.sdk_session = sdk
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
from bot.handler import _process_message
await _process_message("u1", "chat1", "n")
assert sdk._pending_approval.done()
assert sdk._pending_approval.result() is False
assert any("拒绝" in t for t in mock_feishu["texts"])
@pytest.mark.asyncio
async def test_y_without_pending_falls_through(self, mock_feishu):
"""If there's no pending approval, 'y' should not be consumed."""
from agent.manager import manager, Session
from orchestrator.agent import agent
from orchestrator.tools import set_current_chat
set_current_chat("chat1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
from bot.handler import _process_message
# Patch agent.run to avoid actual LLM call
with patch("orchestrator.agent.agent.run", new_callable=AsyncMock, return_value="ok"):
await _process_message("u1", "chat1", "y")
# Should not have consumed as approval
assert not any("批准" in t for t in mock_feishu["markdowns"])
# ===========================================================================
# 8. bot/feishu.py build_approval_card
# ===========================================================================
class TestBuildApprovalCard:
def test_card_structure(self):
from bot.feishu import build_approval_card
card = build_approval_card("c1", "Bash", "`echo hello`", timeout=60)
assert card["schema"] == "2.0"
assert "权限审批" in card["header"]["title"]["content"]
body_elements = card["body"]["elements"]
tags = [e["tag"] for e in body_elements]
# JSON 2.0: no "action" wrapper, no "note" — buttons are direct elements
assert "action" not in tags
assert "note" not in tags
assert "markdown" in tags
assert tags.count("button") == 2
# Buttons carry approve/deny values
buttons = [e for e in body_elements if e["tag"] == "button"]
assert buttons[0]["value"]["action"] == "approve"
assert buttons[0]["value"]["conv_id"] == "c1"
assert buttons[1]["value"]["action"] == "deny"
# ===========================================================================
# 8b. bot/handler.py card callback response
# ===========================================================================
class TestCardCallbackResponse:
"""Test _handle_card_action returns proper P2CardActionTriggerResponse per
docs/feishu/card_callback_communication.md."""
def _make_trigger(self, action: str, conv_id: str, **extra) -> "P2CardActionTrigger":
from lark_oapi.event.callback.model.p2_card_action_trigger import (
CallBackAction, CallBackOperator, P2CardActionTrigger, P2CardActionTriggerData,
)
value = {"action": action, "conv_id": conv_id, **extra}
act = CallBackAction()
act.value = value
act.tag = "button"
op = CallBackOperator()
op.open_id = "ou_test_user"
data = P2CardActionTriggerData()
data.action = act
data.operator = op
trigger = P2CardActionTrigger()
trigger.event = data
return trigger
def test_approve_returns_toast_and_card(self):
from bot.handler import _handle_card_action
trigger = self._make_trigger("approve", "c1")
with patch("bot.handler._main_loop", new=MagicMock()):
resp = _handle_card_action(trigger)
assert resp.toast is not None
assert resp.toast.type == "success"
assert "批准" in resp.toast.content
assert resp.card is not None
assert resp.card.type == "raw"
assert resp.card.data["header"]["template"] == "green"
def test_deny_returns_warning_toast(self):
from bot.handler import _handle_card_action
trigger = self._make_trigger("deny", "c1")
with patch("bot.handler._main_loop", new=MagicMock()):
resp = _handle_card_action(trigger)
assert resp.toast.type == "warning"
assert "拒绝" in resp.toast.content
assert resp.card.data["header"]["template"] == "red"
def test_missing_value_returns_empty_response(self):
from bot.handler import _handle_card_action
from lark_oapi.event.callback.model.p2_card_action_trigger import (
CallBackAction, P2CardActionTrigger, P2CardActionTriggerData,
)
act = CallBackAction()
act.value = {} # no action/conv_id
data = P2CardActionTriggerData()
data.action = act
trigger = P2CardActionTrigger()
trigger.event = data
resp = _handle_card_action(trigger)
assert resp.toast is None
assert resp.card is None
def test_answer_question_returns_success_toast(self):
from bot.handler import _handle_card_action
trigger = self._make_trigger(
"answer_question", "c1",
question="Which lang?", answer="Python"
)
with patch("bot.handler._main_loop", new=MagicMock()):
resp = _handle_card_action(trigger)
assert resp.toast.type == "success"
assert "Python" in resp.toast.content
assert "Python" in resp.card.data["body"]["elements"][0]["content"]
# ===========================================================================
# 8c. AskUserQuestion flow
# ===========================================================================
class TestAskUserQuestion:
def test_build_question_card(self):
from bot.feishu import build_question_card
questions = [
{
"question": "Which language?",
"header": "Language",
"options": [
{"label": "Python", "description": "Great for AI"},
{"label": "TypeScript", "description": "Great for web"},
],
"multiSelect": False,
}
]
card = build_question_card("c1", questions)
assert card["schema"] == "2.0"
assert "提问" in card["header"]["title"]["content"]
elements = card["body"]["elements"]
buttons = [e for e in elements if e["tag"] == "button"]
assert len(buttons) == 2
assert buttons[0]["value"]["action"] == "answer_question"
assert buttons[0]["value"]["question"] == "Which language?"
assert buttons[0]["value"]["answer"] == "Python"
assert buttons[1]["value"]["answer"] == "TypeScript"
@pytest.mark.asyncio
async def test_answer_question_resolves_future(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
loop = asyncio.get_running_loop()
s._pending_question = loop.create_future()
await s.answer_question({"Which language?": "Python"})
assert s._pending_question.done()
assert s._pending_question.result() == {"Which language?": "Python"}
def test_card_callback_answer_question(self):
from bot.handler import _handle_card_action
from lark_oapi.event.callback.model.p2_card_action_trigger import (
CallBackAction, CallBackOperator, P2CardActionTrigger, P2CardActionTriggerData,
)
act = CallBackAction()
act.value = {"action": "answer_question", "conv_id": "c1",
"question": "Which lang?", "answer": "Python"}
act.tag = "button"
op = CallBackOperator()
op.open_id = "ou_test"
data = P2CardActionTriggerData()
data.action = act
data.operator = op
trigger = P2CardActionTrigger()
trigger.event = data
with patch("bot.handler._main_loop", new=MagicMock()):
resp = _handle_card_action(trigger)
assert resp.toast.type == "success"
assert "Python" in resp.toast.content
assert "Python" in resp.card.data["body"]["elements"][0]["content"]
def test_progress_shows_pending_question(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
s._busy = True
s._started_at = time.time()
s._pending_question_data = {
"questions": [{"question": "Pick a color?", "options": [{"label": "Red"}, {"label": "Blue"}]}],
"conv_id": "c1",
}
p = s.get_progress()
assert p.pending_question is not None
assert p.pending_question["questions"][0]["question"] == "Pick a color?"
# ===========================================================================
# 9. Permission mode constants
# ===========================================================================
class TestPermissionModes:
def test_valid_modes_includes_dontask(self):
from agent.sdk_session import VALID_PERMISSION_MODES
assert "dontAsk" in VALID_PERMISSION_MODES
def test_perm_aliases_has_auto(self):
from bot.commands import _PERM_ALIASES
assert _PERM_ALIASES["auto"] == "dontAsk"
def test_perm_labels_has_dontask(self):
from bot.commands import _PERM_LABELS
assert _PERM_LABELS["dontAsk"] == "auto"
# ===========================================================================
# 10. Config
# ===========================================================================
class TestConfig:
def test_sdk_approval_timeout_exists(self):
from config import SDK_APPROVAL_TIMEOUT
assert isinstance(SDK_APPROVAL_TIMEOUT, int)
assert SDK_APPROVAL_TIMEOUT > 0