Compare commits
32
Commits
a3622ce26d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72ebf3b75d | ||
|
|
26746335c4 | ||
|
|
44bd1a4300 | ||
|
|
eac90941ef | ||
|
|
ba1b5b76c6 | ||
|
|
9c1fc7e5b8 | ||
|
|
b707fa84f9 | ||
|
|
9c04d47c8e | ||
|
|
3ca492634d | ||
|
|
f4249e5b0d | ||
|
|
1b2bb8cdc2 | ||
|
|
a278ece348 | ||
|
|
ed70588d95 | ||
|
|
5a9fe871fe | ||
|
|
c6e38026ec | ||
|
|
28e0fe7c27 | ||
|
|
88b7eabe14 | ||
|
|
2a8f745b3d | ||
|
|
ed7bbb1497 | ||
|
|
c0ecea9d3a | ||
|
|
9173aa6094 | ||
|
|
71e3f14788 | ||
|
|
a7ea6307e7 | ||
|
|
b36acf65ca | ||
|
|
4ea15d4612 | ||
|
|
8591467592 | ||
|
|
d6183594d6 | ||
|
|
cbeafa35a5 | ||
|
|
8dab229aaf | ||
|
|
6cf2143987 | ||
|
|
52a9d085f7 | ||
|
|
80e4953cf9 |
@@ -70,3 +70,11 @@ dmypy.json
|
||||
|
||||
# Ruff
|
||||
.ruff_cache/
|
||||
|
||||
# Runtime data (sessions, audit logs, scheduled jobs)
|
||||
data/
|
||||
|
||||
# Legacy paths (pre-consolidation)
|
||||
sessions.json
|
||||
scheduled_jobs.json
|
||||
audit/
|
||||
|
||||
@@ -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`.
|
||||
@@ -4,31 +4,58 @@ Feishu bot that lets users control Claude Code CLI from their phone.
|
||||
|
||||
## Architecture
|
||||
|
||||
PhoneWork uses a **Router + Host Client** architecture that supports both single-machine and multi-host deployments:
|
||||
|
||||
```
|
||||
┌─────────────┐ WebSocket ┌──────────────┐ LangChain ┌─────────────┐
|
||||
│ Feishu │ ◄──────────────► │ FastAPI │ ◄──────────────► │ LLM API │
|
||||
│ (client) │ │ (server) │ │ (ZhipuAI) │
|
||||
└─────────────┘ └──────────────┘ └─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Claude Code │
|
||||
│ (headless) │
|
||||
└─────────────┘
|
||||
┌─────────────────┐ ┌──────────┐ WebSocket ┌────────────────────────────────────┐
|
||||
│ Feishu App │ │ Feishu │◄────────────►│ Router (public VPS) │
|
||||
│ (User's Phone) │◄───────►│ Cloud │ │ - Feishu event handler │
|
||||
└─────────────────┘ └──────────┘ │ - Router LLM (routing only) │
|
||||
│ - Node registry + active node map │
|
||||
└───────────┬────────────────────────┘
|
||||
│ WebSocket (host clients connect in)
|
||||
┌───────────┴────────────────────────┐
|
||||
│ │
|
||||
┌──────────▼──────────┐ ┌────────────▼────────┐
|
||||
│ Host Client A │ │ Host Client B │
|
||||
│ (home-pc) │ │ (work-server) │
|
||||
│ - Mailboy LLM │ │ - Mailboy LLM │
|
||||
│ - CC sessions │ │ - CC sessions │
|
||||
│ - Shell / files │ │ - Shell / files │
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
**Key design decisions:**
|
||||
- Host clients connect TO the router (outbound WebSocket) — NAT-transparent
|
||||
- A user can be registered on multiple nodes simultaneously
|
||||
- The **router LLM** decides *which node* to route each message to
|
||||
- The **node mailboy LLM** handles the full orchestration loop
|
||||
- Each node maintains its own conversation history per user
|
||||
|
||||
**Deployment modes:**
|
||||
- **Standalone (`python standalone.py`):** Runs router + host client at localhost. Same architecture, simpler setup for single-machine use.
|
||||
- **Multi-host:** Router on a public VPS, host clients behind NAT on different machines.
|
||||
|
||||
**Components:**
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `main.py` | FastAPI entry point, starts WebSocket client + session manager + scheduler |
|
||||
| `standalone.py` | Single-process entry point: runs router + host client together |
|
||||
| `router/main.py` | FastAPI app factory, mounts `//ws/node` endpoint, can be run directly |
|
||||
| `shared/protocol.py` | Wire protocol for router-host communication |
|
||||
| `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 |
|
||||
| `router/routing_agent.py` | Single-shot routing LLM to decide which node handles each message |
|
||||
| `host_client/main.py` | WebSocket client connecting to router, message handling, reconnection |
|
||||
| `host_client/config.py` | Host client configuration loader |
|
||||
| `bot/handler.py` | Receives Feishu events via long-connection WebSocket |
|
||||
| `bot/feishu.py` | Sends text/file/card replies back to Feishu |
|
||||
| `bot/commands.py` | Slash command handler (`/new`, `/status`, `/shell`, `/remind`, `/tasks`, etc.) |
|
||||
| `bot/feishu.py` | Sends text/file replies back to Feishu |
|
||||
| `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 |
|
||||
@@ -111,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.
|
||||
@@ -122,8 +156,71 @@ ALLOWED_OPEN_IDS:
|
||||
# Optional: 秘塔AI Search API key for web search functionality
|
||||
# Get your key at: https://metaso.cn/search-api/api-keys
|
||||
METASO_API_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Optional: Multi-host mode configuration
|
||||
# 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
|
||||
NODE_ID: home-pc
|
||||
DISPLAY_NAME: Home PC
|
||||
ROUTER_URL: ws://192.168.1.100:8000/ws/node
|
||||
ROUTER_SECRET: <shared_secret>
|
||||
|
||||
OPENAI_BASE_URL: https://open.bigmodel.cn/api/paas/v4/
|
||||
OPENAI_API_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
OPENAI_MODEL: glm-4.7
|
||||
|
||||
WORKING_DIR: C:/Users/me/projects
|
||||
METASO_API_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Which Feishu open_ids this node serves
|
||||
SERVES_USERS:
|
||||
- ou_abc123def456
|
||||
```
|
||||
|
||||
#### Determining `ROUTER_URL`
|
||||
|
||||
`ROUTER_URL` is the WebSocket address of the machine running the router (`python main.py`).
|
||||
|
||||
| Scenario | Value |
|
||||
|---|---|
|
||||
| Router and host client on the same LAN | `ws://192.168.x.x:8000/ws/node` |
|
||||
| Router on a public VPS (no TLS) | `ws://your-server-ip:8000/ws/node` |
|
||||
| Router behind a reverse proxy with TLS | `wss://yourdomain.com/ws/node` |
|
||||
|
||||
To find the router machine's LAN IP on Windows: `ipconfig` → look for IPv4 Address under your active adapter.
|
||||
|
||||
The router binds to `0.0.0.0:8000`, so any IP or hostname that reaches that machine on port 8000 will work.
|
||||
|
||||
#### Determining `ROUTER_SECRET`
|
||||
|
||||
`ROUTER_SECRET` is a shared secret that authenticates host client connections. Generate it once on any machine:
|
||||
|
||||
```bash
|
||||
python -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
Copy the output. Set the **same value** in:
|
||||
- `keyring.yaml` on the router machine (under `ROUTER_SECRET`)
|
||||
- `host_config.yaml` on every host client machine (under `ROUTER_SECRET`)
|
||||
|
||||
If the secrets don't match, the router will reject the connection with code 4001.
|
||||
|
||||
In standalone mode (`python standalone.py`), the secret is auto-generated at startup and never needs to be configured.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Installation & Run
|
||||
@@ -136,14 +233,47 @@ source .venv/Scripts/activate # Windows
|
||||
# source .venv/bin/activate # Linux/macOS
|
||||
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
Server listens on `http://0.0.0.0:8000`.
|
||||
### Standalone mode (single machine)
|
||||
|
||||
Health check: `GET /health`
|
||||
Claude smoke test: `GET /health/claude`
|
||||
Active sessions: `GET /sessions`
|
||||
Runs router + host client in one process. This is the normal setup for personal use.
|
||||
|
||||
```bash
|
||||
cp keyring.example.yaml keyring.yaml
|
||||
# Fill in keyring.yaml, then:
|
||||
python standalone.py
|
||||
```
|
||||
|
||||
### Multi-host mode
|
||||
|
||||
**Router** (public VPS or any reachable machine — runs the Feishu bot):
|
||||
|
||||
```bash
|
||||
# keyring.yaml must have ROUTER_SECRET set
|
||||
python -m router.main
|
||||
```
|
||||
|
||||
**Host client** (your dev machine behind NAT — runs Claude Code):
|
||||
|
||||
```bash
|
||||
# Fill in host_config.yaml with ROUTER_URL and ROUTER_SECRET, then:
|
||||
python -m host_client.main
|
||||
```
|
||||
|
||||
Generate a shared secret for `ROUTER_SECRET`:
|
||||
|
||||
```bash
|
||||
python -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
Set the **same value** in `keyring.yaml` on the router and `host_config.yaml` on each host client.
|
||||
|
||||
### Health check
|
||||
|
||||
```
|
||||
GET /health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -151,54 +281,58 @@ Active sessions: `GET /sessions`
|
||||
|
||||
| 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 |
|
||||
| `/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.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Core Reliability
|
||||
### Prototype Consolidation (Milestone 1)
|
||||
|
||||
#### Core Reliability
|
||||
|
||||
- **Message splitting** - Long responses automatically split into multiple messages instead of getting cut off
|
||||
- **Concurrent handling** - Multiple users can message the bot simultaneously without conflicts
|
||||
- **Session persistence** - Active sessions survive server restarts (saved to disk)
|
||||
- **Direct mode** - Messages go straight to Claude Code, skipping the LLM for faster responses
|
||||
|
||||
### Better Interaction
|
||||
#### 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
|
||||
#### 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
|
||||
|
||||
### Security
|
||||
#### Security
|
||||
|
||||
- **User allowlist** - Configure which Feishu users are allowed to use the bot
|
||||
- **Session isolation** - Each user can only see and access their own sessions
|
||||
@@ -212,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)
|
||||
|
||||
@@ -227,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`)
|
||||
@@ -236,7 +370,43 @@ 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
|
||||
|
||||
### Multi-Host Architecture (Milestone 3)
|
||||
|
||||
#### Deployment Options
|
||||
|
||||
**Single-Machine Mode:**
|
||||
```bash
|
||||
python standalone.py
|
||||
```
|
||||
Runs both router and host client in one process. Identical UX to pre-M3 setup.
|
||||
|
||||
**Router Mode (Public VPS):**
|
||||
```bash
|
||||
# Set ROUTER_SECRET in keyring.yaml
|
||||
python -m router.main
|
||||
```
|
||||
Runs only the router: Feishu handler + routing LLM + node registry.
|
||||
|
||||
**Host Client Mode (Behind NAT):**
|
||||
```bash
|
||||
# Create host_config.yaml with ROUTER_URL and ROUTER_SECRET
|
||||
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
|
||||
- Automatic routing: LLM decides which node handles each message
|
||||
- Health monitoring: Router tracks node heartbeats
|
||||
- Reconnection: Host clients auto-reconnect on disconnect
|
||||
|
||||
#### Security
|
||||
- Shared secret authentication between router and host clients
|
||||
- User isolation: Each node only serves configured users
|
||||
- Path sandboxing: Sessions restricted to WORKING_DIR
|
||||
|
||||
+37
-37
@@ -1,9 +1,9 @@
|
||||
# PhoneWork — Roadmap
|
||||
|
||||
## Milestone 2: Mailboy as a Versatile Assistant
|
||||
## ✅ Milestone 2: Mailboy as a Versatile Assistant (COMPLETED)
|
||||
|
||||
**Goal:** Elevate the mailboy (GLM-4.7 orchestrator) from a mere Claude Code relay into a
|
||||
fully capable phone assistant. Users should be able to control their machine, manage files,
|
||||
fully capable phone assistant. Users can control their machine, manage files,
|
||||
search the web, get direct answers, and track long-running tasks — all without necessarily
|
||||
opening a Claude Code session.
|
||||
|
||||
@@ -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) |
|
||||
@@ -177,21 +177,21 @@ args: action ("remind" | "repeat"), delay_seconds (int), interval_seconds (int),
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] M2.1: Ask "what is a Python generator?" — mailboy replies directly, no tool call
|
||||
- [ ] M2.2: Send "check git status in todo_app" — `ShellTool` runs, output returned
|
||||
- [ ] M2.2: Send "rm -rf /" — blocked by safety guard
|
||||
- [ ] M2.3: Send "show me the last 50 lines of audit/abc123.jsonl" — file content returned
|
||||
- [ ] M2.3: Send "send me the sessions.json file" — file arrives in Feishu chat
|
||||
- [ ] M2.4: Start a long CC task (e.g. `--timeout 120`) — bot replies immediately, notifies on finish
|
||||
- [ ] M2.4: `/tasks` — lists running task with elapsed time
|
||||
- [ ] M2.5: "Python 3.13 有哪些新特性?" — `web ask` returns RAG answer from metaso
|
||||
- [ ] M2.5: "帮我读取这个URL: https://example.com" — page content extracted as markdown
|
||||
- [ ] M2.6: `/remind 10m deploy check` — 10 min later, message arrives in Feishu
|
||||
- [x] M2.1: Ask "what is a Python generator?" — mailboy replies directly, no tool call
|
||||
- [x] M2.2: Send "check git status in todo_app" — `ShellTool` runs, output returned
|
||||
- [x] M2.2: Send "rm -rf /" — blocked by safety guard
|
||||
- [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.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
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
## Milestone 3: Multi-Host Architecture (Router / Host Client Split)
|
||||
## ✅ Milestone 3: Multi-Host Architecture (Router / Host Client Split) (COMPLETED)
|
||||
|
||||
**Goal:** Split PhoneWork into two deployable components — a public-facing **Router** and
|
||||
one or more **Host Clients** behind NAT. A user can be served by multiple nodes simultaneously.
|
||||
@@ -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,22 +513,22 @@ 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
|
||||
|
||||
---
|
||||
|
||||
## M3 Verification Checklist
|
||||
|
||||
- [ ] `python standalone.py` — works identically to current `python main.py`
|
||||
- [ ] Router starts, host client connects, registration logged
|
||||
- [ ] Feishu message → routing LLM selects node → forwarded → reply returned
|
||||
- [ ] `/nodes` shows all connected nodes with active marker
|
||||
- [ ] `/node work-server` — switches active node, confirmed in next message
|
||||
- [ ] Two nodes serving same user — message routed to active node
|
||||
- [ ] Kill host client → router marks offline, user sees "Node home-pc is offline"
|
||||
- [ ] Host client reconnects → re-registered, messages flow again
|
||||
- [ ] Long CC task on node finishes → router forwards completion notification to Feishu
|
||||
- [ ] Wrong `ROUTER_SECRET` → connection rejected with 401
|
||||
- [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] 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
|
||||
- [x] Long CC task on node finishes → router forwards completion notification to Feishu
|
||||
- [x] Wrong `ROUTER_SECRET` → connection rejected with 401
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+56
-1
@@ -10,7 +10,7 @@ from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AUDIT_DIR = Path(__file__).parent.parent / "audit"
|
||||
AUDIT_DIR = Path(__file__).parent.parent / "data" / "audit"
|
||||
|
||||
|
||||
def _ensure_audit_dir() -> None:
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
+130
-71
@@ -5,19 +5,21 @@ 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 / "sessions.json"
|
||||
PERSISTENCE_FILE = Path(__file__).parent.parent / "data" / "sessions.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -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,79 +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")
|
||||
# --- Secretary model: async send (returns immediately) ---
|
||||
|
||||
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()
|
||||
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()
|
||||
self._ensure_sdk_session(session, chat_id)
|
||||
return await session.sdk_session.send(message, chat_id)
|
||||
|
||||
if cc_timeout > 60:
|
||||
from agent.task_runner import task_runner
|
||||
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)
|
||||
|
||||
# --- Kept for backward compatibility (used by bot/commands.py _cmd_new) ---
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
# --- Progress, interrupt, approve ---
|
||||
|
||||
task_id = await task_runner.submit(
|
||||
run_task,
|
||||
description=f"CC session {conv_id}: {message[:50]}",
|
||||
notify_chat_id=chat_id,
|
||||
)
|
||||
return f"⏳ Task #{task_id} started (timeout: {int(cc_timeout)}s). I'll notify you when it's done."
|
||||
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()
|
||||
|
||||
output = await run_claude(
|
||||
message,
|
||||
cwd=cwd,
|
||||
cc_session_id=cc_session_id,
|
||||
resume=not first_message,
|
||||
timeout=cc_timeout,
|
||||
)
|
||||
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
|
||||
|
||||
log_interaction(
|
||||
conv_id=conv_id,
|
||||
prompt=message,
|
||||
response=output,
|
||||
cwd=cwd,
|
||||
user_id=user_id,
|
||||
)
|
||||
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)
|
||||
|
||||
return output
|
||||
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:
|
||||
@@ -158,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)
|
||||
@@ -172,17 +193,52 @@ 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()}
|
||||
PERSISTENCE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(PERSISTENCE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
logger.debug("Saved %d sessions to %s", len(data), PERSISTENCE_FILE)
|
||||
@@ -214,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:
|
||||
|
||||
+2
-1
@@ -14,7 +14,7 @@ from typing import Any, Callable, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PERSISTENCE_FILE = Path(__file__).parent.parent / "scheduled_jobs.json"
|
||||
PERSISTENCE_FILE = Path(__file__).parent.parent / "data" / "scheduled_jobs.json"
|
||||
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
@@ -98,6 +98,7 @@ class Scheduler:
|
||||
"""Save jobs to persistence file."""
|
||||
try:
|
||||
data = {jid: job.to_dict() for jid, job in self._jobs.items()}
|
||||
PERSISTENCE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(PERSISTENCE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
except Exception:
|
||||
|
||||
@@ -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]),
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
@@ -57,6 +57,7 @@ class TaskRunner:
|
||||
description: str,
|
||||
notify_chat_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
on_complete: Optional[Callable[[BackgroundTask], Awaitable[None]]] = None,
|
||||
) -> str:
|
||||
"""Submit a coroutine as a background task."""
|
||||
task_id = str(uuid.uuid4())[:8]
|
||||
@@ -72,11 +73,11 @@ class TaskRunner:
|
||||
async with self._lock:
|
||||
self._tasks[task_id] = task
|
||||
|
||||
asyncio.create_task(self._run_task(task_id, coro))
|
||||
asyncio.create_task(self._run_task(task_id, coro, on_complete))
|
||||
logger.info("Submitted background task %s: %s", task_id, description)
|
||||
return task_id
|
||||
|
||||
async def _run_task(self, task_id: str, coro: Awaitable[Any]) -> None:
|
||||
async def _run_task(self, task_id: str, coro: Awaitable[Any], on_complete: Optional[Callable[[BackgroundTask], Awaitable[None]]] = None) -> None:
|
||||
"""Execute a task and send notification on completion."""
|
||||
async with self._lock:
|
||||
task = self._tasks.get(task_id)
|
||||
@@ -107,6 +108,12 @@ class TaskRunner:
|
||||
else:
|
||||
await self._send_notification(task)
|
||||
|
||||
if on_complete and task.status == TaskStatus.COMPLETED:
|
||||
try:
|
||||
await on_complete(task)
|
||||
except Exception:
|
||||
logger.exception("on_complete callback failed for task %s", task_id)
|
||||
|
||||
async def _send_notification(self, task: BackgroundTask) -> None:
|
||||
"""Send Feishu notification about task completion."""
|
||||
from bot.feishu import send_text
|
||||
|
||||
+215
-65
@@ -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}"
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
mode = "Direct 🟢" if agent.get_passthrough(user_id) else "Smart ⚪"
|
||||
card = build_sessions_card(sessions, conv_id, mode)
|
||||
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 data.get("response"):
|
||||
await send_text(chat_id, "chat_id", data["response"])
|
||||
if initial_msg and response:
|
||||
await send_text(chat_id, "chat_id", response)
|
||||
return ""
|
||||
|
||||
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]}..."
|
||||
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
|
||||
except Exception:
|
||||
return result
|
||||
|
||||
|
||||
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 — 允许所有工具,不询问"""
|
||||
+126
-48
@@ -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.
|
||||
@@ -184,17 +264,15 @@ async def send_file(receive_id: str, receive_id_type: str, file_path: str, file_
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Step 1: Upload file → get file_key
|
||||
with open(path, "rb") as f:
|
||||
file_data = f.read()
|
||||
|
||||
def _upload():
|
||||
with open(path, "rb") as f:
|
||||
req = (
|
||||
CreateFileRequest.builder()
|
||||
.request_body(
|
||||
CreateFileRequestBody.builder()
|
||||
.file_type(file_type)
|
||||
.file_name(file_name)
|
||||
.file(file_data)
|
||||
.file(f)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
+206
-5
@@ -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
|
||||
@@ -184,6 +377,14 @@ def start_websocket_client(loop: asyncio.AbstractEventLoop) -> None:
|
||||
backoff = 1.0
|
||||
max_backoff = 60.0
|
||||
|
||||
# lark_oapi.ws.client captures the event loop at module import time.
|
||||
# In standalone mode uvicorn already owns the main loop, so we create
|
||||
# a fresh loop for this thread and redirect the lark module to use it.
|
||||
thread_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(thread_loop)
|
||||
import lark_oapi.ws.client as _lark_ws_client
|
||||
_lark_ws_client.loop = thread_loop
|
||||
|
||||
while True:
|
||||
try:
|
||||
_ws_connected = False
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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()))
|
||||
@@ -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()))
|
||||
@@ -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()))
|
||||
@@ -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()))
|
||||
@@ -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)的回调,并且需要立即响应返回链接预览内容,从而使终端用户看到链接预览效果。
|
||||
|
||||
## 回调与事件的区别
|
||||
|
||||

|
||||
|
||||
回调与[事件](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
|
||||
@@ -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)。
|
||||
|
||||
## 回调
|
||||
|
||||
基本信息 |
|
||||
---|---
|
||||
回调类型 | 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>
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
|
||||
响应回调的结构体示例
|
||||
|
||||
```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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
## 错误码
|
||||
|
||||
在飞书客户端进行卡片交互时,若交互出错,将返回如下图对应的错误码。错误码说明及解决方案如下表所示。
|
||||
|
||||

|
||||
错误码仅支持飞书客户端 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><br>2. 点击右上角的 **保存**,然后点击 **发布**,确保修改生效。<br>
|
||||
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)。
|
||||
@@ -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 版本的客户端时,卡片标题可正常显示,但内容将展示兜底的升级提示文案。
|
||||
|
||||

|
||||
|
||||
- 卡片 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_url,url 字段生效。
|
||||
- 如果需要禁止某端进行跳转,可以将对应的参数值配置为 `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 版本的客户端时,卡片标题可正常显示,但内容将展示兜底的升级提示文案。
|
||||
|
||||

|
||||
|
||||
- 卡片 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>- small:4px<br>- medium:8px<br>- large:16px | <code>vertical_spacing</code> 和 <code>horizontal_spacing</code>字段的枚举和对应的值为:<br>- small:4px<br>- medium:8px<br>- large:12px<br>- extra_large:16px
|
||||
|
||||
### 标题组件配置变更
|
||||
|
||||
- 标题组件的 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)外的其它所有组件。
|
||||
|
||||
@@ -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 支持标准 CommonMark,1.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)` |
|
||||
| 图片 | `` |
|
||||
| 有序列表 | `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 实体转义才能原样显示:
|
||||
|
||||
| 字符 | 转义 |
|
||||
|---|---|
|
||||
| `*` | `*` |
|
||||
| `<` | `<` |
|
||||
| `>` | `>` |
|
||||
| `~` | `∼` |
|
||||
| `` ` `` | ``` |
|
||||
|
||||
完整对照参考 [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"` |
|
||||
@@ -0,0 +1,482 @@
|
||||
# 富文本组件
|
||||
|
||||
JSON 2.0 结构卡片的富文本(Markdown)组件支持渲染标题、表情、表格、图片、代码块、分割线等元素。
|
||||
**注意事项**:本文档介绍富文本组件的 JSON 2.0 结构,要查看历史 JSON 1.0 结构,参考[富文本(Markdown)](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-components/content-components/rich-text)。
|
||||
|
||||

|
||||
|
||||
## 注意事项
|
||||
富文本 JSON 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)"
|
||||
}
|
||||
```
|
||||
|
||||
## 组件属性
|
||||
|
||||
### JSON 结构
|
||||
|
||||
富文本组件的完整 JSON 2.0 结构如下所示:
|
||||
```json
|
||||
{
|
||||
"schema": "2.0", // 卡片 JSON 结构的版本。默认为 1.0。要使用 JSON 2.0 结构,必须显示声明 2.0。
|
||||
"body": {
|
||||
"elements": [
|
||||
{
|
||||
"tag": "markdown",
|
||||
"element_id": "custom_id", // 操作组件的唯一标识。JSON 2.0 新增属性。用于在调用组件相关接口中指定组件。需开发者自定义。
|
||||
"margin": "0px 0px 0px 0px", // 组件的外边距,JSON 2.0 新增属性。默认值 "0",支持范围 [-99,99]px。
|
||||
"content": "人员<person id = 'ou_449b53ad6aee526f7ed311b216aabcef' show_name = true show_avatar = true style = 'normal'></person>", // 采用 mardown 语法编写的内容。2.0 结构不再支持 "[差异化跳转]($urlVal)" 语法
|
||||
"text_size": "normal", // 文本大小。默认值 normal。支持自定义在移动端和桌面端的不同字号。
|
||||
"text_align": "left", // 文本对齐方式。默认值 left。
|
||||
"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 时生效。
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
富文本组件包含的参数说明如下表所示。
|
||||
|
||||
字段名称 | 是否必填 | 类型 | 默认值 | 说明
|
||||
---|---|---|---|---
|
||||
tag | 是 | String | / | 组件的标签。富文本组件固定取值为 `markdown`。
|
||||
element_id | 否 | String | 空 | 操作组件的唯一标识。JSON 2.0 新增属性。用于在调用[组件相关接口](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/cardkit-v1/card-element/create)中指定组件。在同一张卡片内,该字段的值全局唯一。仅允许使用字母、数字和下划线,必须以字母开头,不得超过 20 字符。
|
||||
margin | 否 | String | 0 | 组件的外边距。JSON 2.0 新增属性。值的取值范围为 [-99,99]px。可选值:<br>- 单值,如 "10px",表示组件的四个外边距都为 10 px。<br>- 双值,如 "4px 0",表示组件的上下外边距为 4 px,左右外边距为 0 px。使用空格间隔(边距为 0 时可不加单位)。<br>- 多值,如 "4px 0 4px 0",表示组件的上、右、下、左的外边距分别为 4px,12px,4px,12px。使用空格间隔。
|
||||
text_align | 否 | String | left | 设置文本内容的对齐方式。可取值有:<br>* left:左对齐<br>* center:居中对齐<br>* right:右对齐
|
||||
text_size | 否 | String | normal | 文本大小。可取值如下所示。如果你填写了其它值,卡片将展示为 `normal` 字段对应的字号。<br>- heading-0:特大标题(30px)<br>- heading-1:一级标题(24px)<br>- heading-2:二级标题(20 px)<br>- heading-3:三级标题(18px)<br>- heading-4:四级标题(16px)<br>- heading:标题(16px)<br>- normal:正文(14px)<br>- notation:辅助信息(12px)<br>- xxxx-large:30px<br>- xxx-large:24px<br>- xx-large:20px<br>- x-large:18px<br>- large:16px<br>- medium:14px<br>- small:12px<br>- x-small:10px
|
||||
icon | 否 | Object | / | 添加图标作为文本前缀图标。支持自定义或使用图标库中的图标。
|
||||
└ tag | 否 | String | / | 图标类型的标签。可取值:<br>- `standard_icon`:使用图标库中的图标。<br>- `custom_icon`:使用用自定义图片作为图标。
|
||||
└ token | 否 | String | / | 图标库中图标的 token。当 `tag` 为 `standard_icon` 时生效。枚举值参见[图标库](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/enumerations-for-icons)。
|
||||
└ color | 否 | String | / | 图标的颜色。支持设置线性和面性图标(即 token 末尾为 `outlined` 或 `filled` 的图标)的颜色。当 `tag` 为 `standard_icon` 时生效。枚举值参见[颜色枚举值](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/enumerations-for-fields-related-to-color)。
|
||||
└ img_key | 否 | String | / | 自定义前缀图标的图片 key。当 `tag` 为 `custom_icon` 时生效。<br>图标 key 的获取方式:调用[上传图片](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create)接口,上传用于发送消息的图片,并在返回值中获取图片的 image_key。
|
||||
content | 是 | String | / | Markdown 文本内容。了解支持的语法,参考下文。
|
||||
|
||||
### Demo 示例
|
||||
|
||||
以下 JSON 2.0 结构的示例代码可实现如下图所示的卡片效果:
|
||||
|
||||

|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "2.0",
|
||||
"body": {
|
||||
"elements": [
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "# 一级标题",
|
||||
"margin": "0px 0px 0px 0px",
|
||||
"text_align": "left",
|
||||
"text_size": "normal"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "标准emoji 😁😢🌞💼🏆❌✅\n飞书emoji :OK::THUMBSUP:\n*斜体* **粗体** ~~删除线~~ \n这是红色文本<\/font>\n<text_tag color=\"blue\">标签<\/text_tag>\n[文字链接](https:\/\/open.feishu.cn\/document\/server-docs\/im-v1\/message-reaction\/emojis-introduce)\n<link icon='chat_outlined' url='https:\/\/open.feishu.cn' pc_url='' ios_url='' android_url=''>带图标的链接<\/link>\n<at id=all><\/at>\n- 无序列表1\n - 无序列表 1.1\n- 无序列表2\n1. 有序列表1\n 1. 有序列表 1.1\n2. 有序列表2\n```JSON\n{\"This is\": \"JSON demo\"}\n```"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "行内引用`code`"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "数字角标,支持 1-99 数字<number_tag background_color='grey' font_color='white' url='https://open.feishu.cn' pc_url='https://open.feishu.cn' android_url='https://open.feishu.cn' ios_url='https://open.feishu.cn'>1</number_tag>"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "默认数字角标展示<number_tag>1</number_tag>"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "人员<person id = 'ou_449b53ad6aee526f7ed311b216a8f88f' show_name = true show_avatar = true style = 'normal'></person>"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "> 这是一段引用文字\n引用内换行 \n"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 支持的 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 效果。
|
||||
|
||||
注意,在卡片的富文本组件中,以下语法的渲染效果与 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>`,支持嵌套其它标签,如 `red<font color=green>greenagain</font>`。其它标签包括:
|
||||
- 闭合标签 `<local_datetime></local_datetime>`
|
||||
- 闭合标签 `<at></at>`
|
||||
- 闭合标签 `<a></a>`
|
||||
- 闭合标签 `<link></link>`
|
||||
- 闭合标签 `<font></font>`
|
||||
|
||||
以下是一些常见的渲染效果及其对应的 Markdown 或 HTML 语法。
|
||||
|
||||
名称 | 语法 | 效果 | 注意事项
|
||||
---|---|---|---
|
||||
换行 | ```<br>第一行<br />第二行<br>第一行<br>第二行<br>``` | 第一行<br>第二行 | - 如果你使用卡片 JSON 构建卡片,也可使用字符串的换行语法 `\n` 换行。<br>- 如果你使用卡片搭建工具构建卡片,也可使用回车键换行。
|
||||
斜体 | ```<br>*斜体*<br>``` | *斜体* | 无
|
||||
加粗 | ```<br>**粗体** <br>或<br>__粗体__ <br>``` | __粗体__ | - 不要连续使用 4 个 `*` 或 `_` 加粗。该语法不规范,可能会导致渲染不正确。<br>- 若加粗效果未显示,请确保加粗语法前后保留一个空格。
|
||||
删除线 | ```<br>~~删除线~~<br>``` | ~~删除线~~ | 无
|
||||
@指定人 | ```<br><at id=open_id></at><br><at id=user_id></at><br><at ids=id_01,id_02,xxx></at><br><at email=test@email.com></at><br>``` | @用户名 | - 该语法用于在卡片中实现 @ 人的效果,被 @ 的用户将收到提及通知。但对于转发的卡片,用户将不再收到提及通知。<br>- 要在卡片中展示人员的用户名、头像、个人名片等,你可使用[人员](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/content-components/user-profile)或[人员列表](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/content-components/user-list)组件。但人员和人员列表组件仅作为展示,用户不会收到提及通知。<br>- [自定义机器人](https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO24yNxkjN)仅支持使用 `open_id`、`user_id` @指定人。<br>- 支持使用 `<at ids=id_01,id_02,xxx></at>` 传入多个 ID,使用 `,` 连接。<br>- 了解如何获取 user_id、open_id,参考[如何获取不同的用户 ID](https://open.feishu.cn/document/home/user-identity-introduction/open-id)。
|
||||
@所有人 | ```<br><at id=all></at><br>``` | @所有人 | @所有人需要群主开启权限。若未开启,卡片将发送失败。
|
||||
超链接 | ```<br><a href='https://open.feishu.cn'><br></a><br>``` | [https://open.feishu.cn](https://open.feishu.cn) | - 超链接必须包含 schema 才能生效,目前仅支持 HTTP 和 HTTPS。<br>- 超链接文本的颜色不支持自定义。
|
||||
彩色文本样式 | ```<br>这是一个绿色文本 <br>这是一个红色文本<br>这是一个灰色文本<br>``` | <br><br> | * 彩色文本样式不支持对链接中的文本生效<br>* color 取值:<br>- **default**:默认的白底黑字样式<br>- 卡片支持的颜色枚举值和 RGBA 语法自定义颜色。参考[颜色枚举值](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/enumerations-for-fields-related-to-color)
|
||||
可点击的电话号码 | ```<br>[文本展示的电话号码或其他文案内容](tel://移动端弹窗唤起的电话号码)<br>``` |  | 该语法仅在移动端生效。
|
||||
文字链接 | ```<br>[开放平台](https://open.feishu.cn/)<br>``` | [开放平台](https://open.feishu.cn/) | 超链接必须包含 schema 才能生效,目前仅支持 HTTP 和 HTTPS。
|
||||
差异化跳转链接 | ```<br>{<br>"tag": "markdown",<br>"href": {<br>"urlVal": {<br>"url": "xxx",<br>"pc_url":"xxx",<br>"ios_url": "xxx",<br>"android_url": "xxx"<br>}<br>},<br>"content":<br>"[差异化跳转]($urlVal)"<br>}<br>``` | \- | * 超链接必须包含 schema 才能生效,目前仅支持 HTTP 和 HTTPS。<br>- 仅在 PC 端、移动端需要跳转不同链接时使用。
|
||||
图片 | ```<br><br>``` | | * `hover_text` 指在 PC 端内光标悬浮(hover)图片所展示的文案。<br>* **image_key** 可以调用[上传图片](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create)接口获取。
|
||||
分割线 | ```<br><hr><br>或<br>---<br>``` |  | - 推荐使用 `<hr>` 语法<br>- 分割线必须单独一行使用。即如果分割线前后有文本,你必须在分割线前后添加换行符。
|
||||
飞书表情 | ```<br>:DONE:<br>``` |  | 支持的 Emoji Key 列表可以参看 [表情文案说明](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message-reaction/emojis-introduce)。
|
||||
标签 | ```<br><text_tag color='red'>标签文本</text_tag><br>``` | | `color`支持的枚举值范围包括:<br>- `neutral`: 中性色<br>- `blue`: 蓝色<br>- `turquoise`: 青绿色<br>- `lime`: 酸橙色<br>- `orange`: 橙色<br>- `violet`: 紫罗兰色<br>- `indigo`: 靛青色<br>- `wathet`: 天蓝色<br>- `green`: 绿色<br>- `yellow`: 黄色<br>- `red`: 红色<br>- `purple`: 紫色<br>- `carmine`: 洋红色
|
||||
有序列表 | ```<br>1. 有序列表1<br>1. 有序列表 1.1<br>2. 有序列表2<br>``` | 1. 有序列表1<br>1. 有序列表 1.1<br>2. 有序列表2 | * 序号需在行首使用<br>* 4 个空格代表一层缩进
|
||||
无序列表 | ```<br>- 无序列表1<br>- 无序列表 1.1<br>- 无序列表2<br>```<br>在卡片 JSON 中,需添加 `\n` 换行符:<br>```<br>\n- 无序列表1\n - 无序列表 1.1\n- 无序列表2\n1. 有序列表1\n<br>``` | - 无序列表1<br>- 无序列表 1.1<br>- 无序列表2 | * 序号需在行首使用<br>* 4 个空格代表一层缩进
|
||||
代码块 | `````markdown<br>```JSON<br>{"This is": "JSON demo"}<br>```<br>````` | ```JSON<br>{"This is": "JSON demo"}<br>``` | * 代码块语法和代码内容需在行首使用<br>* 支持指定编程语言解析。未指定默认为 Plain Text<br>- 四个及以上空格([缩进式代码块语法](https://spec.commonmark.org/0.30/#indented-code-blocks))也将触发代码块效果
|
||||
含图标的链接 | ```<br><link icon='chat_outlined' url='https://open.feishu.cn' pc_url='' ios_url='' android_url=''>战略研讨会</link><br>``` |  | 该语法中的字段说明如下所示:<br>- `icon`:链接前缀的图标。仅支持图标库中的图标,枚举值参见[图标库](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/enumerations-for-icons)。图标颜色固定为蓝色。可选。<br>- `url`:默认的链接地址,未按设备配置下述字段时,该配置生效。必填。<br>- `pc_url`:pc 端的链接地址,优先级高于 `url`。可选。<br>- `ios_url`:ios 端的链接地址,优先级高于 `url`。可选。<br>- `android_url`:android 端的链接地址,优先级高于 `url`。可选。
|
||||
人员 | `````markdown<br><person id = 'user_id' show_name = true show_avatar = true style = 'normal'></person><br>````` |  | 该语法中的字段说明如下所示:<br>- `id`:用户的 ID,支持 open_id、union_id 和 user_id。不填、为空、数据错误时展示为兜底的“未知用户”样式。了解更多,参考[如何获取不同的用户 ID](https://open.feishu.cn/document/home/user-identity-introduction/open-id)。<br>- `show_name`:是否展示用户名。默认为 true。<br>- `show_avatar`:是否展示用户头像,默认为 true。<br>- `style`:人员组件的展示样式。可选值有:<br>- `normal`:普通样式(默认)<br>- `capsule`:胶囊样式
|
||||
标题 | ```<br># 一级标题<br>## 二级标题<br>###### 六级标题<br>``` |  | 支持一级到 6 级标题。从一级到六级的字号梯度为 26, 22 , 20, 18, 17, 14px。
|
||||
引用 | ```<br>>[空格]这是一段引用文字\n引用内换行<br>``` |  |
|
||||
行内引用 | ```<br>`code`<br>``` |  |
|
||||
表格 | ```<br>| Syntax | Description |<br>| -------- | -------- |<br>| Paragraph | Text |<br>| Paragraph | Text |<br>| Paragraph | Text |<br>| Paragraph | Text |<br>| Paragraph | Text |<br>| Paragraph | Text |<br>``` |  | - 除标题行外,最多展示五行数据,超出五行将分页展示。不支持自定义。<br>- 该语法仅支持 JSON 2.0 结构。<br>- 单个富文本组件中,最多可放置四个表格。<br>- 表格的富文本语法不支持设置列宽等。要设置列宽、数据对齐方式等,可使用[表格](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/content-components/table)组件。
|
||||
数字角标 | `````markdown<br><number_tag>1</number_tag><br>`````<br>`````markdown<br><number_tag background_color='grey' font_color='white' url='https://open.feishu.cn' pc_url='https://open.feishu.cn' android_url='https://open.feishu.cn' ios_url='https://open.feishu.cn'>1</number_tag>````` |  | 数字圆形角标,支持添加 0-99 之间的数字。该语法中的字段说明如下所示:<br>- `background_color`:圆圈内的背景颜色。可选。<br>- `font_color`:数字颜色。可选。<br>- `url`:点击角标时默认的跳转链接,未按设备配置下述字段时,该配置生效。可选。<br>- `pc_url`:点击角标时 PC 端的跳转链接,优先级高于 `url`。可选。<br>- `ios_url`:点击角标时 iOS 端的跳转链接,优先级高于 `url`。可选。<br>- `android_url`:点击角标时 Android 端的跳转链接,优先级高于 `url`。可选。
|
||||
国际化时间 | ```<br><local_datetime millisecond='' format_type='date_num' link='https://www.feishu.com'></local_datetime><br>``` |  | 国际化时间标签。支持自动展示用户当地时区下的时间。该语法中的字段说明如下所示:<br>- `millisecond`:要展示的时间的 Unix 毫秒时间戳。若不填,则:<br>- 对于使用卡片 JSON 发送的卡片,默认展示发送卡片时的时间<br>- 对于使用搭建工具搭建的卡片,默认展示卡片发布的时间<br>- `format_type`:定义时间展示的格式。默认使用数字展示,如:`2019-03-15`。枚举值如下所示:<br>- `date_num`:用数字表示的日期,例如 `2019-03-15`。<br>- `date_short`:不含年份的简写日期,支持多语种自动适配,例如 `3月15日`、`Mar 15`。<br>- `date`:完整国际化日期文案,支持多语种自动适配,例如 `2019年3月15日`、`Mar 15, 2019`。<br>- `week`:完整星期文案,支持多语种自动适配,例如 `星期二`、`Tuesday`。<br>- `week_short`:简写星期文案,支持多语种自动适配,例如 `周二`、`Tue`。<br>- `time`:时间(小时:分钟)文案,例如 `13:42`。<br>- `time_sec`:时间(小时:分钟:秒)文案,例如 `13:42:53`。<br>- `timezone`:设备所属时区,格式为 `GMT±hh:mm`,例如 `GMT+8:00`。<br>- `link`:点击该时间时跳转的链接地址。
|
||||
音频 | ```<br><audio file_key='file_v3_xxx' audio_id='1' show_time=true style='normal' background_color='grey-200' fill_color='blue-800' fallback_url='https://open.feishu.cn/' fallback_pc_url='https://open.feishu.cn/' fallback_ios_url='https://open.feishu.cn/' fallback_android_url='https://open.feishu.cn/' fallback_harmony_url='https://open.feishu.cn/' fallback_text='[音频链接]'></audio><br>```<br>参考本文末尾了解音频语法使用示例。 | - style 为 normal 时:<br><br>- style 为 speak 时:<br> | 富文本内嵌音频播放器。该语法中的字段说明如下所示:<br>- `file_key`:音频文件 key,需通过[上传文件](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/file/create)获取。详情参考[音频](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/content-components/audio)组件。必填。<br>- `audio_id`:音频实例唯一标识,使用方式同[音频](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/content-components/audio)组件。可选。<br>- `show_time`:是否显示时长。可选,默认值为 false。<br>- `style`:音频样式。可选,支持以下值:<br>- `normal`:默认值,三角形播放按钮样式<br>- `speak`:语音样式<br>- `background_color`:组件背景颜色。可选。支持 default、[颜色枚举值](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/enumerations-for-fields-related-to-color)和 RGBA 语法自定义颜色。<br>- `fill_color`:图标和时间颜色。可选。支持颜色枚举值和 RGBA 语法自定义颜色。 <br>- `fallback_text`:在低于飞书 V7.49.0 版本客户端上,音频播放器将展示为文字链接。你可设置文本和 URL,引导用户点击链接访问音频。该字段指定显示文本。可选。若不指定,则低版本客户端展示时将丢弃该组件。<br>- `fallback_url`:在低于飞书 V7.49.0 版本客户端上,音频播放器将展示为文字链接。你可设置文本和 URL,引导用户点击链接访问音频。该字段指定文字链接的兜底 URL。若指定`fallback_text`,则必须指定 `fallback_url`。<br>- `fallback_pc_url`:为 PC 端低版本客户端上的音频播放器额外指定 URL,可选。优先级高于兜底的 `fallback_url`。<br>- `fallback_ios_url`:为 iOS 端低版本客户端上的音频播放器额外指定 URL,可选。优先级高于兜底的 `fallback_url`。<br>- `fallback_android_url`:为 Android 端低版本客户端上的音频播放器额外指定 URL,可选。优先级高于兜底的 `fallback_url`。<br>- `fallback_harmony_url`:为原生鸿蒙端低版本客户端上的音频播放器额外指定 URL,可选。优先级高于兜底的 `fallback_url`。
|
||||
|
||||
### 特殊字符转义说明
|
||||
如果要展示的字符命中了 markdown 语法使用的特殊字符(例如 `*、~、>、<` 这些特殊符号),需要对特殊字符进行 HTML 转义,才可正常展示。常见的转义符号对照表如下所示。查看更多转义符,参考 [HTML 转义通用标准](https://www.w3school.com.cn/charsets/ref_html_8859.asp)实现,转义后的格式为 `&#实体编号;`。
|
||||
|
||||
| **特殊字符** | **转义符** | **描述** |
|
||||
| --- | --- | --- |
|
||||
| ` ` | ` ` | 不换行空格 |
|
||||
| ` ` | ` ` | 半角空格 |
|
||||
| ` ` | ` ` | 全角空格 |
|
||||
| `>` | `>` | 大于号 |
|
||||
| `<` | `<` | 小于号 |
|
||||
| `~` | `∼` | 飘号 |
|
||||
| `-` | `-` | 连字符 |
|
||||
| `!` | `!` | 惊叹号 |
|
||||
| `*` | `*` | 星号 |
|
||||
| `/` | `/` | 斜杠 |
|
||||
| `\` | `\` | 反斜杠 |
|
||||
| `[` | `[` | 中括号左边部分 |
|
||||
| `]` | `]` | 中括号右边部分 |
|
||||
| `(` | `(` | 小括号左边部分 |
|
||||
| `)` | `)` | 小括号右边部分 |
|
||||
| `#` | `#` | 井号 |
|
||||
| `:` | `:` | 冒号 |
|
||||
| `+` | `+` | 加号 |
|
||||
| `"` | `"` | 英文引号 |
|
||||
| `'` | `'` | 英文单引号 |
|
||||
| \` | ``` | 反单引号 |
|
||||
| `$` | `$` | 美金符号 |
|
||||
| `_` | `_` | 下划线 |
|
||||
| `-` | `-` | 无序列表 |
|
||||
|
||||
### 代码块支持的编程语言
|
||||
|
||||
富文本组件支持通过代码块语法渲染代码,支持的编程语言如下列表所示,且对大小写不敏感:
|
||||
`````markdown
|
||||
```JSON
|
||||
{"This is": "JSON demo"}
|
||||
```
|
||||
`````
|
||||
- plain_text
|
||||
- abap
|
||||
- ada
|
||||
- apache
|
||||
- apex
|
||||
- assembly
|
||||
- bash
|
||||
- c_sharp
|
||||
- cpp
|
||||
- c
|
||||
- cmake
|
||||
- cobol
|
||||
- css
|
||||
- coffee_script
|
||||
- d
|
||||
- dart
|
||||
- delphi
|
||||
- diff
|
||||
- django
|
||||
- docker_file
|
||||
- erlang
|
||||
- fortran
|
||||
- gherkin
|
||||
- go
|
||||
- graphql
|
||||
- groovy
|
||||
- html
|
||||
- htmlbars
|
||||
- http
|
||||
- haskell
|
||||
- json
|
||||
- java
|
||||
- javascript
|
||||
- julia
|
||||
- kotlin
|
||||
- latex
|
||||
- lisp
|
||||
- lua
|
||||
- matlab
|
||||
- makefile
|
||||
- markdown
|
||||
- nginx
|
||||
- objective_c
|
||||
- opengl_shading_language
|
||||
- php
|
||||
- perl
|
||||
- powershell
|
||||
- prolog
|
||||
- properties
|
||||
- protobuf
|
||||
- python
|
||||
- r
|
||||
- ruby
|
||||
- rust
|
||||
- sas
|
||||
- scss
|
||||
- sql
|
||||
- scala
|
||||
- scheme
|
||||
- shell
|
||||
- solidity
|
||||
- swift
|
||||
- toml
|
||||
- thrift
|
||||
- typescript
|
||||
- vbscript
|
||||
- visual_basic
|
||||
- xml
|
||||
- yaml
|
||||
## 为移动端和桌面端定义不同的字号
|
||||
|
||||
在普通文本组件和富文本组件中,你可为同一段文本定义在移动端和桌面端的不同字号。相关字段描述如下表所示。
|
||||
|
||||
字段 | 是否必填 | 类型 | 默认值 | 说明
|
||||
---|---|---|---|---
|
||||
text_size | 否 | Object | / | 文本大小。你可在此自定义移动端和桌面端的不同字号。
|
||||
└ custom_text_size_name | 否 | Object | / | 自定义的字号。你需自定义该字段的名称,如 `cus-0`、`cus-1` 等。
|
||||
└└ default | 否 | String | / | 在无法差异化配置字号的旧版飞书客户端上,生效的字号属性。建议填写此字段。可取值如下所示。<br>- heading-0:特大标题(30px)<br>- heading-1:一级标题(24px)<br>- heading-2:二级标题(20 px)<br>- heading-3:三级标题(18px)<br>- heading-4:四级标题(16px)<br>- heading:标题(16px)<br>- normal:正文(14px)<br>- notation:辅助信息(12px)<br>- xxxx-large:30px<br>- xxx-large:24px<br>- xx-large:20px<br>- x-large:18px<br>- large:16px<br>- medium:14px<br>- small:12px<br>- x-small:10px
|
||||
└└ pc | 否 | String | / | 桌面端的字号。可取值如下所示。<br>- heading-0:特大标题(30px)<br>- heading-1:一级标题(24px)<br>- heading-2:二级标题(20 px)<br>- heading-3:三级标题(18px)<br>- heading-4:四级标题(16px)<br>- heading:标题(16px)<br>- normal:正文(14px)<br>- notation:辅助信息(12px)<br>- xxxx-large:30px<br>- xxx-large:24px<br>- xx-large:20px<br>- x-large:18px<br>- large:16px<br>- medium:14px<br>- small:12px<br>- x-small:10px
|
||||
└└ mobile | 否 | String | / | 移动端的文本字号。可取值如下所示。<br>**注意**:部分移动端的字号枚举值的具体大小与 PC 端有差异,使用时请注意区分。<br>- heading-0:特大标题(26px)<br>- heading-1:一级标题(24px)<br>- heading-2:二级标题(20 px)<br>- heading-3:三级标题(17px)<br>- heading-4:四级标题(16px)<br>- heading:标题(16px)<br>- normal:正文(14px)<br>- notation:辅助信息(12px)<br>- xxxx-large:26px<br>- xxx-large:24px<br>- xx-large:20px<br>- x-large:18px<br>- large:17px<br>- medium:14px<br>- small:12px<br>- x-small:10px
|
||||
|
||||
具体步骤如下所示。
|
||||
1. 在卡片 JSON 代码的全局行为设置中的 `config` 字段中,配置 `style` 字段,并添加自定义字号:
|
||||
```json
|
||||
{
|
||||
"config": {
|
||||
"style": { // 在此添加并配置 style 字段。
|
||||
"text_size": { // 分别为移动端和桌面端添加自定义字号,同时添加兜底字号。用于在组件 JSON 中设置字号属性。支持添加多个自定义字号对象。
|
||||
"cus-0": {
|
||||
"default": "medium", // 在无法差异化配置字号的旧版飞书客户端上,生效的字号属性。选填。
|
||||
"pc": "medium", // 桌面端的字号。
|
||||
"mobile": "large" // 移动端的字号。
|
||||
},
|
||||
"cus-1": {
|
||||
"default": "medium", // 在无法差异化配置字号的旧版飞书客户端上,生效的字号属性。选填。
|
||||
"pc": "normal", // 桌面端的字号。
|
||||
"mobile": "x-large" // 移动的字号。
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
1. 在普通文本组件或富文本组件的 `text_size` 属性中,应用自定义字号。以下为在富文本组件中应用自定义字号的示例:
|
||||
```json
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"tag": "markdown",
|
||||
"text_size": "cus-0", // 在此处应用自定义字号。
|
||||
"href": {
|
||||
"urlVal": {
|
||||
"url": "xxx1",
|
||||
"pc_url": "xxx2",
|
||||
"ios_url": "xxx3",
|
||||
"android_url": "xxx4"
|
||||
}
|
||||
},
|
||||
"content": "普通文本\n标准emoji😁😢🌞💼🏆❌✅\n*斜体*\n**粗体**\n~~删除线~~\n文字链接\n差异化跳转\n<at id=all></at>"
|
||||
},
|
||||
{
|
||||
"tag": "hr"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "上面是一行分割线\n!hover_text\n上面是一个图片标签"
|
||||
}
|
||||
],
|
||||
"header": {
|
||||
"template": "blue",
|
||||
"title": {
|
||||
"content": "这是卡片标题栏",
|
||||
"tag": "plain_text"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 富文本语法使用示例
|
||||
|
||||
### 音频
|
||||
|
||||
以下富文本语法示例代码可实现如下图所示的卡片效果。请将 `file_key` 替换为实际值后再查看效果。获取音频文件 `file_key` 时,请确保调用[上传文件](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/file/create)接口的应用与发送卡片的应用一致。
|
||||
|
||||

|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "2.0",
|
||||
"config": {
|
||||
"wide_screen_mode": true,
|
||||
"enable_forward": false,
|
||||
"update_multi": true,
|
||||
"enable_forward_interaction": true,
|
||||
"style": {
|
||||
"color": {
|
||||
"color_0": {
|
||||
"light_mode": "rgba(20,86,240,1.000000)",
|
||||
"dark_mode": "rgba(20,86,240,1.000000)"
|
||||
},
|
||||
"color_1": {
|
||||
"light_mode": "rgba(149,229,153,1.000000)",
|
||||
"dark_mode": "rgba(149,229,153,1.000000)"
|
||||
},
|
||||
"color_2": {
|
||||
"light_mode": "rgba(253,198,196,1.000000)",
|
||||
"dark_mode": "rgba(253,198,196,1.000000)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"direction": "vertical",
|
||||
"padding": "12px 12px 12px 12px",
|
||||
"elements": [
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "参数全默认效果示例:\n<audio file_key='file_v3_00or_f2c1276b-9f24-463d-8911-xxxxxxxx' audio_id='1' >",
|
||||
"text_align": "left"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "显示时间示例:\n<audio file_key='file_v3_00or_f2c1276b-9f24-463d-8911-xxxxxxxx' audio_id='111' show_time=true >",
|
||||
"text_align": "left"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "自定义颜色 background_color='rgba(20,86,240,1.000000)' fill_color='rgba(253,198,196,1.000000)' 示例:\n<audio file_key='file_v3_00or_f2c1276b-9f24-463d-8911-xxxxxxxx' audio_id='2' show_time=true background_color='color_0' fill_color='color_2'>",
|
||||
"text_align": "left"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "使用颜色枚举值 background_color='grey-200' fill_color='blue-800' 示例:\n<audio file_key='file_v3_00or_f2c1276b-9f24-463d-8911-xxxxxxxx' audio_id='3' show_time=true background_color='grey-200' fill_color='blue-800'>",
|
||||
"text_align": "left"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "播放器按钮语音样式(style=speak)示例:\n<audio file_key='file_v3_00or_f2c1276b-9f24-463d-8911-xxxxxxxx' audio_id='5' show_time=true style='speak' >",
|
||||
"text_align": "left"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "低于飞书 V7.49 版本,设置兜底文本与链接示例:\n<audio file_key='file_v3_00or_f2c1276b-9f24-463d-8911-xxxxxxxx' audio_id='6' show_time=true fallback_url='https://open.feishu.cn/'> fallback_text='[音频链接]'",
|
||||
"text_align": "left"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "#### 不同字体下,音频播放器大小示例:",
|
||||
"text_align": "left"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "heading-0<audio file_key='file_v3_00or_f2c1276b-9f24-463d-8911-xxxxxxxx' audio_id='7' show_time=true background_color='grey-200' fill_color='blue-800' fallback_url='https://open.feishu.cn/'> fallback_text='[音频链接]'",
|
||||
"text_align": "left",
|
||||
"text_size": "heading-0"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "heading-1<audio file_key='file_v3_00or_f2c1276b-9f24-463d-8911-xxxxxxxx' audio_id='8' show_time=true background_color='grey-200' fill_color='blue-800' fallback_url='https://open.feishu.cn/'> fallback_text='[音频链接]'",
|
||||
"text_align": "left",
|
||||
"text_size": "heading-1"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "heading-2<audio file_key='file_v3_00or_f2c1276b-9f24-463d-8911-xxxxxxxx' audio_id='9' show_time=true background_color='grey-200' fill_color='blue-800' fallback_url='https://open.feishu.cn/'> fallback_text='[音频链接]'",
|
||||
"text_align": "left",
|
||||
"text_size": "heading-2"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "heading-3<audio file_key='file_v3_00or_f2c1276b-9f24-463d-8911-xxxxxxxx' audio_id='10' show_time=true background_color='grey-200' fill_color='blue-800' fallback_url='https://open.feishu.cn/'> fallback_text='[音频链接]'",
|
||||
"text_align": "left",
|
||||
"text_size": "heading-3"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "heading<audio file_key='file_v3_00or_f2c1276b-9f24-463d-8911-xxxxxxxx' audio_id='11' show_time=true background_color='grey-200' fill_color='blue-800' fallback_url='https://open.feishu.cn/'> fallback_text='[音频链接]'",
|
||||
"text_align": "left",
|
||||
"text_size": "heading"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "normal<audio file_key='file_v3_00or_f2c1276b-9f24-463d-8911-xxxxxxxx' audio_id='12' show_time=true background_color='grey-200' fill_color='blue-800' fallback_url='https://open.feishu.cn/'> fallback_text='[音频链接]'",
|
||||
"text_align": "left",
|
||||
"text_size": "normal"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "notation<audio file_key='file_v3_00or_f2c1276b-9f24-463d-8911-xxxxxxxx' audio_id='13' show_time=true background_color='grey-200' fill_color='blue-800' fallback_url='https://open.feishu.cn/'> fallback_text='[音频链接]'",
|
||||
"text_align": "left",
|
||||
"text_size": "notation"
|
||||
}
|
||||
]
|
||||
},
|
||||
"header": {
|
||||
"title": {
|
||||
"tag": "plain_text",
|
||||
"content": "Markdown 音频播放器示例"
|
||||
},
|
||||
"template": "blue",
|
||||
"padding": "12px 12px 12px 12px"
|
||||
}
|
||||
}
|
||||
```
|
||||
+48
-57
@@ -1,38 +1,43 @@
|
||||
"""Host client configuration loader.
|
||||
|
||||
Loads host_config.yaml which contains:
|
||||
Loads host_config.yaml (multi-host mode) or keyring.yaml (standalone mode).
|
||||
|
||||
Fields:
|
||||
- NODE_ID, DISPLAY_NAME
|
||||
- ROUTER_URL, ROUTER_SECRET
|
||||
- LLM config (OPENAI_*)
|
||||
- OPENAI_BASE_URL, OPENAI_API_KEY, OPENAI_MODEL
|
||||
- WORKING_DIR, METASO_API_KEY
|
||||
- SERVES_USERS list
|
||||
- SERVES_USERS / ALLOWED_OPEN_IDS, CAPABILITIES
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def _load_yaml(path: Path) -> dict:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Config file not found: {path}")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
|
||||
class HostConfig:
|
||||
"""Configuration for a host client node."""
|
||||
|
||||
def __init__(self, config_path: Optional[Path] = None):
|
||||
config_path = config_path or Path(__file__).parent.parent / "host_config.yaml"
|
||||
self._load(config_path)
|
||||
|
||||
def _load(self, config_path: Path) -> None:
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(f"Config file not found: {config_path}")
|
||||
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
self.node_id: str = data.get("NODE_ID", "unknown-node")
|
||||
self.display_name: str = data.get("DISPLAY_NAME", self.node_id)
|
||||
def __init__(
|
||||
self,
|
||||
data: dict,
|
||||
*,
|
||||
serves_users_key: str = "SERVES_USERS",
|
||||
default_node_id: str = "unknown-node",
|
||||
default_display_name: str = "",
|
||||
):
|
||||
self.node_id: str = data.get("NODE_ID", default_node_id)
|
||||
self.display_name: str = data.get("DISPLAY_NAME", default_display_name or self.node_id)
|
||||
self.router_url: str = data.get("ROUTER_URL", "ws://127.0.0.1:8000/ws/node")
|
||||
self.router_secret: str = data.get("ROUTER_SECRET", "")
|
||||
|
||||
@@ -45,53 +50,39 @@ class HostConfig:
|
||||
self.working_dir: str = data.get("WORKING_DIR", str(Path.home() / "projects"))
|
||||
self.metaso_api_key: Optional[str] = data.get("METASO_API_KEY")
|
||||
|
||||
serves_users = data.get("SERVES_USERS", [])
|
||||
serves_users = data.get(serves_users_key, [])
|
||||
self.serves_users: list[str] = serves_users if isinstance(serves_users, list) else []
|
||||
|
||||
self.capabilities: list[str] = data.get(
|
||||
"CAPABILITIES",
|
||||
["claude_code", "shell", "file_ops", "web", "scheduler"],
|
||||
)
|
||||
capabilities = data.get("CAPABILITIES", ["claude_code", "shell", "file_ops", "web", "scheduler"])
|
||||
self.capabilities: list[str] = capabilities if isinstance(capabilities, list) else []
|
||||
|
||||
if not self.openai_api_key:
|
||||
raise ValueError("OPENAI_API_KEY is required in config but was not set")
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, config_path: Optional[Path] = None) -> "HostConfig":
|
||||
"""Load from host_config.yaml (multi-host mode)."""
|
||||
path = config_path or Path(__file__).parent.parent / "host_config.yaml"
|
||||
return cls(_load_yaml(path), serves_users_key="SERVES_USERS", default_node_id="unknown-node")
|
||||
|
||||
@classmethod
|
||||
def from_keyring(cls, keyring_path: Optional[Path] = None) -> "HostConfig":
|
||||
"""Create config from keyring.yaml (for standalone mode)."""
|
||||
keyring_path = keyring_path or Path(__file__).parent.parent / "keyring.yaml"
|
||||
if not keyring_path.exists():
|
||||
raise FileNotFoundError(f"keyring.yaml not found: {keyring_path}")
|
||||
|
||||
with open(keyring_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
config = cls.__new__(cls)
|
||||
config.node_id = data.get("NODE_ID", "local-node")
|
||||
config.display_name = data.get("DISPLAY_NAME", "Local Machine")
|
||||
config.router_url = data.get("ROUTER_URL", "ws://127.0.0.1:8000/ws/node")
|
||||
config.router_secret = data.get("ROUTER_SECRET", "")
|
||||
|
||||
config.openai_base_url = data.get(
|
||||
"OPENAI_BASE_URL", "https://open.bigmodel.cn/api/paas/v4/"
|
||||
"""Load from keyring.yaml (standalone mode)."""
|
||||
path = keyring_path or Path(__file__).parent.parent / "keyring.yaml"
|
||||
return cls(
|
||||
_load_yaml(path),
|
||||
serves_users_key="ALLOWED_OPEN_IDS",
|
||||
default_node_id="local-node",
|
||||
default_display_name="Local Machine",
|
||||
)
|
||||
config.openai_api_key = data.get("OPENAI_API_KEY", "")
|
||||
config.openai_model = data.get("OPENAI_MODEL", "glm-4.7")
|
||||
|
||||
config.working_dir = data.get("WORKING_DIR", str(Path.home() / "projects"))
|
||||
config.metaso_api_key = data.get("METASO_API_KEY")
|
||||
|
||||
serves_users = data.get("ALLOWED_OPEN_IDS", [])
|
||||
config.serves_users = serves_users if isinstance(serves_users, list) else []
|
||||
|
||||
config.capabilities = ["claude_code", "shell", "file_ops", "web", "scheduler"]
|
||||
|
||||
return config
|
||||
|
||||
|
||||
host_config: Optional[HostConfig] = None
|
||||
_host_config: Optional[HostConfig] = None
|
||||
|
||||
|
||||
def get_host_config() -> HostConfig:
|
||||
"""Get the global host config instance."""
|
||||
global host_config
|
||||
if host_config is None:
|
||||
host_config = HostConfig()
|
||||
return host_config
|
||||
"""Get the global host config instance (multi-host mode)."""
|
||||
global _host_config
|
||||
if _host_config is None:
|
||||
_host_config = HostConfig.from_file()
|
||||
return _host_config
|
||||
|
||||
+28
-18
@@ -43,6 +43,7 @@ class NodeClient:
|
||||
self._running = False
|
||||
self._last_heartbeat = time.time()
|
||||
self._reconnect_delay = 1.0
|
||||
self._forward_tasks: set[asyncio.Task] = set()
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to the router WebSocket."""
|
||||
@@ -53,9 +54,9 @@ class NodeClient:
|
||||
try:
|
||||
self.ws = await websockets.connect(
|
||||
self.config.router_url,
|
||||
extra_headers=headers,
|
||||
ping_interval=30,
|
||||
ping_timeout=10,
|
||||
additional_headers=headers,
|
||||
ping_interval=20,
|
||||
ping_timeout=60,
|
||||
)
|
||||
logger.info("Connected to router: %s", self.config.router_url)
|
||||
self._reconnect_delay = 1.0
|
||||
@@ -145,17 +146,9 @@ class NodeClient:
|
||||
except Exception as e:
|
||||
logger.error("Failed to send status: %s", e)
|
||||
|
||||
async def handle_message(self, data: str) -> None:
|
||||
"""Handle an incoming message from the router."""
|
||||
try:
|
||||
msg = decode(data)
|
||||
except Exception as e:
|
||||
logger.error("Failed to decode message: %s", e)
|
||||
return
|
||||
|
||||
if isinstance(msg, ForwardRequest):
|
||||
await self.handle_forward(msg)
|
||||
elif isinstance(msg, Heartbeat):
|
||||
async def handle_message_decoded(self, msg: Any) -> None:
|
||||
"""Handle an already-decoded message from the router."""
|
||||
if isinstance(msg, Heartbeat):
|
||||
if msg.type == "ping":
|
||||
if self.ws:
|
||||
try:
|
||||
@@ -165,7 +158,7 @@ class NodeClient:
|
||||
elif msg.type == "pong":
|
||||
self._last_heartbeat = time.time()
|
||||
else:
|
||||
logger.debug("Received message type: %s", msg.type)
|
||||
logger.debug("Received message type: %s", type(msg).__name__)
|
||||
|
||||
async def receive_loop(self) -> None:
|
||||
"""Main receive loop for incoming messages."""
|
||||
@@ -174,7 +167,20 @@ class NodeClient:
|
||||
|
||||
try:
|
||||
async for data in self.ws:
|
||||
await self.handle_message(data)
|
||||
try:
|
||||
msg = decode(data)
|
||||
except Exception as e:
|
||||
logger.error("Failed to decode message: %s", e)
|
||||
continue
|
||||
|
||||
if isinstance(msg, ForwardRequest):
|
||||
# Dispatch as a task so pings are handled without waiting
|
||||
# for the full agent run to complete.
|
||||
task = asyncio.create_task(self.handle_forward(msg))
|
||||
self._forward_tasks.add(task)
|
||||
task.add_done_callback(self._forward_tasks.discard)
|
||||
else:
|
||||
await self.handle_message_decoded(msg)
|
||||
except websockets.ConnectionClosed as e:
|
||||
logger.warning("Connection closed: %s", e)
|
||||
except Exception as e:
|
||||
@@ -184,14 +190,14 @@ class NodeClient:
|
||||
"""Periodic heartbeat loop."""
|
||||
while self._running:
|
||||
await asyncio.sleep(30)
|
||||
if self.ws and self.ws.open:
|
||||
if self.ws:
|
||||
await self.send_heartbeat()
|
||||
|
||||
async def status_loop(self) -> None:
|
||||
"""Periodic status update loop."""
|
||||
while self._running:
|
||||
await asyncio.sleep(60)
|
||||
if self.ws and self.ws.open:
|
||||
if self.ws:
|
||||
await self.send_status()
|
||||
|
||||
async def run(self) -> None:
|
||||
@@ -243,6 +249,10 @@ class NodeClient:
|
||||
async def stop(self) -> None:
|
||||
"""Stop the client."""
|
||||
self._running = False
|
||||
for task in list(self._forward_tasks):
|
||||
task.cancel()
|
||||
if self._forward_tasks:
|
||||
await asyncio.gather(*self._forward_tasks, return_exceptions=True)
|
||||
if self.ws:
|
||||
await self.ws.close()
|
||||
await manager.stop()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,114 +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",
|
||||
)
|
||||
+89
-57
@@ -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__)
|
||||
@@ -30,6 +29,8 @@ logger = logging.getLogger(__name__)
|
||||
SYSTEM_PROMPT_TEMPLATE = """You are PhoneWork, an AI assistant that helps users control Claude Code \
|
||||
from their phone via Feishu (飞书).
|
||||
|
||||
Today's date: {today}
|
||||
|
||||
You manage Claude Code sessions. Each session has a conv_id and runs in a project directory.
|
||||
|
||||
Base working directory: {working_dir}
|
||||
@@ -38,20 +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.
|
||||
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.
|
||||
- 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).
|
||||
"""
|
||||
@@ -59,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."""
|
||||
@@ -111,6 +122,8 @@ class OrchestrationAgent:
|
||||
self._passthrough: dict[str, bool] = defaultdict(lambda: False)
|
||||
|
||||
def _build_system_prompt(self, user_id: str) -> str:
|
||||
from datetime import date
|
||||
today = date.today().strftime("%Y-%m-%d")
|
||||
conv_id = self._active_conv[user_id]
|
||||
if conv_id:
|
||||
active_line = f"ACTIVE SESSION: conv_id={conv_id!r} ← use this for all follow-up messages"
|
||||
@@ -119,6 +132,8 @@ class OrchestrationAgent:
|
||||
return SYSTEM_PROMPT_TEMPLATE.format(
|
||||
working_dir=WORKING_DIR,
|
||||
active_session_line=active_line,
|
||||
today=today,
|
||||
prefix=_P,
|
||||
)
|
||||
|
||||
def get_active_conv(self, user_id: str) -> Optional[str]:
|
||||
@@ -143,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:
|
||||
@@ -156,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]
|
||||
@@ -181,6 +191,8 @@ class OrchestrationAgent:
|
||||
|
||||
reply = ""
|
||||
try:
|
||||
web_calls = 0
|
||||
task_status_calls = 0
|
||||
for iteration in range(MAX_ITERATIONS):
|
||||
logger.debug(" LLM call #%d", iteration)
|
||||
ai_msg: AIMessage = await self._llm_with_tools.ainvoke(messages)
|
||||
@@ -201,6 +213,26 @@ class OrchestrationAgent:
|
||||
)
|
||||
logger.info(" ⚙ %s(%s)", tool_name, args_summary)
|
||||
|
||||
if tool_name == "web":
|
||||
web_calls += 1
|
||||
if web_calls > 2:
|
||||
result = "Web search limit reached. Synthesize from results already obtained."
|
||||
logger.warning(" web call limit exceeded, blocking")
|
||||
messages.append(
|
||||
ToolMessage(content=str(result), tool_call_id=tool_id)
|
||||
)
|
||||
continue
|
||||
|
||||
if tool_name == "task_status":
|
||||
task_status_calls += 1
|
||||
if task_status_calls > 1:
|
||||
result = "Task is still running in the background. Stop polling and tell the user they will be notified when it completes."
|
||||
logger.warning(" task_status poll limit exceeded, blocking")
|
||||
messages.append(
|
||||
ToolMessage(content=str(result), tool_call_id=tool_id)
|
||||
)
|
||||
continue
|
||||
|
||||
tool_obj = _TOOL_MAP.get(tool_name)
|
||||
if tool_obj is None:
|
||||
result = f"Unknown tool: {tool_name}"
|
||||
|
||||
+135
-19
@@ -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}"})
|
||||
@@ -553,18 +554,31 @@ class WebTool(BaseTool):
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "metaso_web_search",
|
||||
"params": {"query": query, "scope": scope or "webpage"},
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "metaso_web_search",
|
||||
"arguments": {"q": query, "scope": scope or "webpage", "size": 5, "includeSummary": True},
|
||||
},
|
||||
}
|
||||
resp = await client.post(base_url, json=payload, headers=headers)
|
||||
data = resp.json()
|
||||
if "error" in data:
|
||||
return json.dumps({"error": data["error"]}, ensure_ascii=False)
|
||||
results = data.get("result", {}).get("results", [])[:5]
|
||||
content_text = data.get("result", {}).get("content", [{}])[0].get("text", "")
|
||||
result_data = json.loads(content_text) if content_text else {}
|
||||
webpages = result_data.get("webpages", [])[:5]
|
||||
output = []
|
||||
for r in results:
|
||||
output.append(f"**{r.get('title', 'No title')}**\n{r.get('snippet', '')}\n{r.get('url', '')}")
|
||||
return json.dumps({"results": "\n\n".join(output)[:max_chars]}, ensure_ascii=False)
|
||||
for r in webpages:
|
||||
date = r.get("date", "")
|
||||
title = r.get("title", "No title")
|
||||
snippet = r.get("snippet", "")[:300]
|
||||
link = r.get("link", "")
|
||||
output.append(f"[{date}] **{title}**\n{snippet}\n{link}")
|
||||
total = result_data.get("total", 0)
|
||||
return json.dumps({
|
||||
"total": total,
|
||||
"results": "\n\n".join(output)[:max_chars],
|
||||
}, ensure_ascii=False)
|
||||
|
||||
elif action == "fetch":
|
||||
if not url:
|
||||
@@ -572,15 +586,18 @@ class WebTool(BaseTool):
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "metaso_web_reader",
|
||||
"params": {"url": url, "format": "markdown"},
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "metaso_web_reader",
|
||||
"arguments": {"url": url, "format": "markdown"},
|
||||
},
|
||||
}
|
||||
resp = await client.post(base_url, json=payload, headers=headers)
|
||||
data = resp.json()
|
||||
if "error" in data:
|
||||
return json.dumps({"error": data["error"]}, ensure_ascii=False)
|
||||
content = data.get("result", {}).get("content", "")
|
||||
return json.dumps({"content": content[:max_chars]}, ensure_ascii=False)
|
||||
content_text = data.get("result", {}).get("content", [{}])[0].get("text", "")
|
||||
return json.dumps({"content": content_text[:max_chars]}, ensure_ascii=False)
|
||||
|
||||
elif action == "ask":
|
||||
if not query:
|
||||
@@ -588,15 +605,18 @@ class WebTool(BaseTool):
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "metaso_chat",
|
||||
"params": {"query": query},
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "metaso_chat",
|
||||
"arguments": {"message": query},
|
||||
},
|
||||
}
|
||||
resp = await client.post(base_url, json=payload, headers=headers)
|
||||
data = resp.json()
|
||||
if "error" in data:
|
||||
return json.dumps({"error": data["error"]}, ensure_ascii=False)
|
||||
answer = data.get("result", {}).get("answer", "")
|
||||
return json.dumps({"answer": answer[:max_chars]}, ensure_ascii=False)
|
||||
content_text = data.get("result", {}).get("content", [{}])[0].get("text", "")
|
||||
return json.dumps({"answer": content_text[:max_chars]}, ensure_ascii=False)
|
||||
|
||||
else:
|
||||
return json.dumps({"error": f"Unknown action: {action}"}, ensure_ascii=False)
|
||||
@@ -697,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(),
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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]:
|
||||
|
||||
+10
-1
@@ -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("/"):
|
||||
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
@@ -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)
|
||||
|
||||
|
||||
+1
-1
@@ -53,11 +53,11 @@ async def ws_node_endpoint(websocket: WebSocket) -> None:
|
||||
"""Send periodic pings to the host client."""
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
try:
|
||||
await websocket.send_text(encode(Heartbeat(type="ping")))
|
||||
except Exception:
|
||||
break
|
||||
await asyncio.sleep(30)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
+8
-2
@@ -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,13 +35,18 @@ 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,
|
||||
)
|
||||
server = uvicorn.Server(config_obj)
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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: []
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user