Merge branch 'main' into feature/custom-headers-266912103052723498
This commit is contained in:
commit
537804e678
18 changed files with 877 additions and 180 deletions
104
docs/channels/wecom/README.md
Normal file
104
docs/channels/wecom/README.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
> Back to [README](../../../README.md)
|
||||
|
||||
# WeCom
|
||||
|
||||
PicoClaw now exposes WeCom as a single `channels.wecom` channel built on the official WeCom AI Bot WebSocket API.
|
||||
This replaces the legacy `wecom`, `wecom_app`, and `wecom_aibot` split with one configuration model.
|
||||
|
||||
## What This Channel Supports
|
||||
|
||||
- Direct chat and group chat delivery
|
||||
- Channel-side streaming replies over WeCom's AI Bot protocol
|
||||
- Incoming text, voice, image, file, video, and mixed messages
|
||||
- Outbound text and media replies (`image`, `file`, `voice`, `video`)
|
||||
- QR-based CLI onboarding with `picoclaw auth wecom`
|
||||
- Shared allowlist and `reasoning_channel_id` routing
|
||||
|
||||
> No public webhook callback URL is required for this channel. PicoClaw opens an outbound WebSocket connection to WeCom.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: QR Login From CLI
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
picoclaw auth wecom
|
||||
```
|
||||
|
||||
The command prints a QR code in the terminal, waits for confirmation in WeCom, and then writes the resulting
|
||||
`bot_id` and `secret` into `channels.wecom`.
|
||||
|
||||
Use `--timeout` if you want to wait longer:
|
||||
|
||||
```bash
|
||||
picoclaw auth wecom --timeout 10m
|
||||
```
|
||||
|
||||
### Option 2: Configure Manually
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom": {
|
||||
"enabled": true,
|
||||
"bot_id": "YOUR_BOT_ID",
|
||||
"secret": "YOUR_SECRET",
|
||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||
"send_thinking_message": true,
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ----- | ---- | -------- | ----------- |
|
||||
| `enabled` | bool | No | Enables the WeCom channel. |
|
||||
| `bot_id` | string | Yes | WeCom AI Bot identifier. Required when the channel is enabled. |
|
||||
| `secret` | string | Yes | WeCom AI Bot secret. Required when the channel is enabled. |
|
||||
| `websocket_url` | string | No | WebSocket endpoint. Defaults to `wss://openws.work.weixin.qq.com`. |
|
||||
| `send_thinking_message` | bool | No | Sends an initial `Processing...` chunk before the final streamed reply. Defaults to `true`. |
|
||||
| `allow_from` | array | No | Sender allowlist. Empty means allow all senders. |
|
||||
| `reasoning_channel_id` | string | No | Optional destination for reasoning/thinking output. |
|
||||
|
||||
## Runtime Behavior
|
||||
|
||||
- PicoClaw keeps the active WeCom turn so normal replies can continue the same stream when possible.
|
||||
- If streaming is no longer available, replies fall back to active push delivery to the resolved chat route.
|
||||
- Incoming media is downloaded into the media store before being handed to the agent.
|
||||
- Outbound media is uploaded to WeCom in temporary chunks and then sent as a regular media message.
|
||||
|
||||
## Migration Notes
|
||||
|
||||
This branch removes the old multi-channel WeCom model.
|
||||
|
||||
| Previous config | Now |
|
||||
| --------------- | --- |
|
||||
| `channels.wecom` webhook bot | Replace with `channels.wecom` using `bot_id` + `secret`. |
|
||||
| `channels.wecom_app` | Remove it and use `channels.wecom`. |
|
||||
| `channels.wecom_aibot` | Move the config to `channels.wecom`. |
|
||||
| `token`, `encoding_aes_key`, `webhook_url`, `webhook_path` | No longer used by the WeCom channel. |
|
||||
| `corp_id`, `corp_secret`, `agent_id` | No longer used by the WeCom channel. |
|
||||
| `welcome_message`, `processing_message`, `max_steps` under WeCom | No longer part of the WeCom channel config. |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `picoclaw auth wecom` times out
|
||||
|
||||
- Re-run with a larger `--timeout`.
|
||||
- Make sure the QR code was confirmed inside WeCom, not only scanned.
|
||||
|
||||
### WebSocket connection fails
|
||||
|
||||
- Verify `bot_id` and `secret`.
|
||||
- Confirm the host can reach `wss://openws.work.weixin.qq.com`.
|
||||
|
||||
### Replies do not arrive
|
||||
|
||||
- Check whether `allow_from` blocks the sender.
|
||||
- Check launcher or startup validation for missing `channels.wecom.bot_id` / `channels.wecom.secret`.
|
||||
|
||||
104
docs/channels/wecom/README.zh.md
Normal file
104
docs/channels/wecom/README.zh.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
> 返回 [README](../../../README.zh.md)
|
||||
|
||||
# 企业微信
|
||||
|
||||
PicoClaw 现在将企业微信统一为一个 `channels.wecom` 渠道,并基于企业微信官方 AI Bot WebSocket 协议实现。
|
||||
这取代了旧的 `wecom`、`wecom_app`、`wecom_aibot` 三套配置模型。
|
||||
|
||||
## 当前渠道能力
|
||||
|
||||
- 支持私聊和群聊
|
||||
- 支持企业微信侧流式回复
|
||||
- 支持接收文本、语音、图片、文件、视频和 mixed 消息
|
||||
- 支持发送文本与媒体消息(`image`、`file`、`voice`、`video`)
|
||||
- 支持通过 `picoclaw auth wecom` 扫码写入配置
|
||||
- 支持统一白名单与 `reasoning_channel_id`
|
||||
|
||||
> 这个渠道不再需要公网 webhook 回调地址。PicoClaw 会主动向企业微信发起 WebSocket 连接。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 方式 1:命令行扫码登录
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
picoclaw auth wecom
|
||||
```
|
||||
|
||||
该命令会在终端打印二维码,等待你在企业微信中确认,然后把生成的 `bot_id` 和 `secret` 写入
|
||||
`channels.wecom`。
|
||||
|
||||
如果需要更长等待时间,可以加 `--timeout`:
|
||||
|
||||
```bash
|
||||
picoclaw auth wecom --timeout 10m
|
||||
```
|
||||
|
||||
### 方式 2:手动配置
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom": {
|
||||
"enabled": true,
|
||||
"bot_id": "YOUR_BOT_ID",
|
||||
"secret": "YOUR_SECRET",
|
||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||
"send_thinking_message": true,
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 配置字段
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ---- | ---- | ---- | ---- |
|
||||
| `enabled` | bool | 否 | 是否启用企业微信渠道。 |
|
||||
| `bot_id` | string | 是 | 企业微信 AI Bot 标识。渠道启用时必填。 |
|
||||
| `secret` | string | 是 | 企业微信 AI Bot 密钥。渠道启用时必填。 |
|
||||
| `websocket_url` | string | 否 | WebSocket 地址,默认 `wss://openws.work.weixin.qq.com`。 |
|
||||
| `send_thinking_message` | bool | 否 | 是否在流式最终回复前先发送一段 `Processing...` 开场消息,默认 `true`。 |
|
||||
| `allow_from` | array | 否 | 发送者白名单;空数组表示允许所有发送者。 |
|
||||
| `reasoning_channel_id` | string | 否 | 可选的 reasoning/thinking 输出目标。 |
|
||||
|
||||
## 运行时行为
|
||||
|
||||
- PicoClaw 会保留当前会话对应的企业微信 turn,优先继续同一个流式回复。
|
||||
- 如果流式上下文已经失效,回复会自动回退到主动推送消息。
|
||||
- 收到的媒体会先下载到 media store,再交给 Agent 处理。
|
||||
- 发出的媒体会先按分片上传到企业微信,再作为普通媒体消息发送。
|
||||
|
||||
## 迁移说明
|
||||
|
||||
这个分支移除了旧的多通道企业微信模型。
|
||||
|
||||
| 旧配置 | 现在怎么做 |
|
||||
| ------ | ---------- |
|
||||
| `channels.wecom` webhook 机器人 | 改为使用 `bot_id` + `secret` 的 `channels.wecom`。 |
|
||||
| `channels.wecom_app` | 删除,统一迁移到 `channels.wecom`。 |
|
||||
| `channels.wecom_aibot` | 配置迁移到 `channels.wecom`。 |
|
||||
| `token`、`encoding_aes_key`、`webhook_url`、`webhook_path` | 企业微信渠道不再使用这些字段。 |
|
||||
| `corp_id`、`corp_secret`、`agent_id` | 企业微信渠道不再使用这些字段。 |
|
||||
| 企业微信下的 `welcome_message`、`processing_message`、`max_steps` | 不再属于企业微信渠道配置。 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### `picoclaw auth wecom` 超时
|
||||
|
||||
- 用更大的 `--timeout` 重新执行。
|
||||
- 确认是在企业微信里完成了确认,而不只是扫描二维码。
|
||||
|
||||
### WebSocket 连接失败
|
||||
|
||||
- 检查 `bot_id` 和 `secret` 是否正确。
|
||||
- 确认运行环境可以访问 `wss://openws.work.weixin.qq.com`。
|
||||
|
||||
### 消息没有回到企业微信
|
||||
|
||||
- 检查 `allow_from` 是否拦截了发送者。
|
||||
- 检查启动日志或 launcher 校验,确认 `channels.wecom.bot_id` / `channels.wecom.secret` 已填写。
|
||||
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MaixCam, or Pico (native protocol)
|
||||
|
||||
> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
|
||||
> **Note**: Channels that rely on HTTP callbacks share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Socket/stream-based channels such as Feishu, DingTalk, and WeCom do not rely on the shared webhook server for inbound delivery.
|
||||
|
||||
| Channel | Difficulty | Description | Documentation |
|
||||
| -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
|
|
@ -19,7 +19,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
|
|||
| **QQ** | ⭐⭐ Medium | Official bot API, Chinese community | [Docs](channels/qq/README.md) |
|
||||
| **DingTalk** | ⭐⭐ Medium | Stream mode (no public IP needed), enterprise | [Docs](channels/dingtalk/README.md) |
|
||||
| **LINE** | ⭐⭐⭐ Advanced | HTTPS Webhook required | [Docs](channels/line/README.md) |
|
||||
| **WeCom (企业微信)** | ⭐⭐⭐ Advanced | Group Bot (Webhook), custom App (API), AI Bot | [Bot](channels/wecom/wecom_bot/README.md) / [App](channels/wecom/wecom_app/README.md) / [AI Bot](channels/wecom/wecom_aibot/README.md) |
|
||||
| **WeCom (企业微信)** | ⭐⭐⭐ Advanced | Official AI Bot over WebSocket, streaming + media | [Docs](channels/wecom/README.md) |
|
||||
| **Feishu (飞书)** | ⭐⭐⭐ Advanced | Enterprise collaboration, feature-rich | [Docs](channels/feishu/README.md) |
|
||||
| **IRC** | ⭐⭐ Medium | Server + TLS configuration | [Docs](#irc) |
|
||||
| **OneBot** | ⭐⭐ Medium | NapCat/Go-CQHTTP compatible, community ecosystem | [Docs](channels/onebot/README.md) |
|
||||
|
|
@ -380,102 +380,34 @@ picoclaw gateway
|
|||
<details>
|
||||
<summary><b>WeCom (企业微信)</b></summary>
|
||||
|
||||
PicoClaw supports three types of WeCom integration:
|
||||
PicoClaw now exposes WeCom as a single AI Bot channel over WebSocket.
|
||||
No public webhook callback URL is required.
|
||||
|
||||
**Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats
|
||||
**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only
|
||||
**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat
|
||||
See [WeCom Configuration Guide](channels/wecom/README.md) for the full configuration reference and migration notes.
|
||||
|
||||
See [WeCom AI Bot Configuration Guide](channels/wecom/wecom_aibot/README.md) for detailed setup instructions.
|
||||
**Quick Setup - Recommended**
|
||||
|
||||
**Quick Setup - WeCom Bot:**
|
||||
**1. Authenticate**
|
||||
|
||||
**1. Create a bot**
|
||||
```bash
|
||||
picoclaw auth wecom
|
||||
```
|
||||
|
||||
* Go to WeCom Admin Console → Group Chat → Add Group Bot
|
||||
* Copy the webhook URL (format: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
|
||||
This command shows a QR code, waits for approval in WeCom, and writes `bot_id` + `secret` into `channels.wecom`.
|
||||
|
||||
**2. Configure**
|
||||
**2. Configure manually if needed**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||
"webhook_path": "/webhook/wecom",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> WeCom webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).
|
||||
|
||||
**Quick Setup - WeCom App:**
|
||||
|
||||
**1. Create an app**
|
||||
|
||||
* Go to WeCom Admin Console → App Management → Create App
|
||||
* Copy **AgentId** and **Secret**
|
||||
* Go to "My Company" page, copy **CorpID**
|
||||
|
||||
**2. Configure receive message**
|
||||
|
||||
* In App details, click "Receive Message" → "Set API"
|
||||
* Set URL to `http://your-server:18790/webhook/wecom-app`
|
||||
* Generate **Token** and **EncodingAESKey**
|
||||
|
||||
**3. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom_app": {
|
||||
"enabled": true,
|
||||
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
||||
"corp_secret": "YOUR_CORP_SECRET",
|
||||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**4. Run**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
> **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS.
|
||||
|
||||
**Quick Setup - WeCom AI Bot:**
|
||||
|
||||
**1. Create an AI Bot**
|
||||
|
||||
* Go to WeCom Admin Console → App Management → AI Bot
|
||||
* In the AI Bot settings, configure callback URL: `http://your-server:18790/webhook/wecom-aibot`
|
||||
* Copy **Token** and click "Random Generate" for **EncodingAESKey**
|
||||
|
||||
**2. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom_aibot": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||
"webhook_path": "/webhook/wecom-aibot",
|
||||
"bot_id": "YOUR_BOT_ID",
|
||||
"secret": "YOUR_SECRET",
|
||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||
"send_thinking_message": true,
|
||||
"allow_from": [],
|
||||
"welcome_message": "Hello! How can I help you?",
|
||||
"processing_message": "⏳ Processing, please wait. The results will be sent shortly."
|
||||
"reasoning_channel_id": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -487,7 +419,7 @@ picoclaw gateway
|
|||
picoclaw gateway
|
||||
```
|
||||
|
||||
> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.
|
||||
> Legacy `wecom_app` and `wecom_aibot` entries are replaced by the unified `channels.wecom` config in this branch.
|
||||
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
|
||||
|
||||
> **注意**: 所有 Webhook 类渠道(LINE、WeCom 等)均挂载在同一个 Gateway HTTP 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。注意:飞书(Feishu)使用 WebSocket/SDK 模式,不通过该共享 HTTP webhook 服务器接收消息。
|
||||
> **注意**: 依赖 HTTP 回调的渠道共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。飞书、钉钉、企业微信这类 Socket/Stream 模式渠道不依赖共享 webhook 服务器来接收入站消息。
|
||||
|
||||
### 核心渠道
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
|||
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](../channels/qq/README.zh.md) |
|
||||
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](../channels/dingtalk/README.zh.md) |
|
||||
| **LINE** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](../channels/line/README.zh.md) |
|
||||
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](../channels/wecom/wecom_bot/README.zh.md) / [App 文档](../channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](../channels/wecom/wecom_aibot/README.zh.md) |
|
||||
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 官方 AI Bot WebSocket 接入,支持流式回复和媒体消息 | [查看文档](../channels/wecom/README.zh.md) |
|
||||
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](../channels/feishu/README.zh.md) |
|
||||
| **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | [查看文档](#irc) |
|
||||
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](../channels/onebot/README.zh.md) |
|
||||
|
|
@ -492,102 +492,34 @@ picoclaw gateway
|
|||
<details>
|
||||
<summary><b>企业微信 (WeCom)</b></summary>
|
||||
|
||||
PicoClaw 支持三种企业微信集成方式:
|
||||
PicoClaw 现在将企业微信统一为一个基于 WebSocket 的 AI Bot 渠道。
|
||||
它不再需要公网 webhook 回调地址。
|
||||
|
||||
**方式 1: 群机器人 (Bot)** — 设置简单,支持群聊
|
||||
**方式 2: 自建应用 (App)** — 功能更多,支持主动推送,仅私聊
|
||||
**方式 3: 智能机器人 (AI Bot)** — 官方 AI Bot,流式回复,支持群聊和私聊
|
||||
完整配置说明和迁移说明请参考 [企业微信配置指南](../channels/wecom/README.zh.md)。
|
||||
|
||||
详细设置请参考 [企业微信 AI Bot 配置指南](../channels/wecom/wecom_aibot/README.zh.md)。
|
||||
**推荐快速接入**
|
||||
|
||||
**快速设置 — 群机器人:**
|
||||
**1. 认证**
|
||||
|
||||
**1. 创建 Bot**
|
||||
```bash
|
||||
picoclaw auth wecom
|
||||
```
|
||||
|
||||
* 企业微信管理后台 → 群聊 → 添加群机器人
|
||||
* 复制 Webhook URL(格式:`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
|
||||
该命令会显示二维码,等待你在企业微信里确认,然后把 `bot_id` 和 `secret` 写入 `channels.wecom`。
|
||||
|
||||
**2. 配置**
|
||||
**2. 如需手动配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||
"webhook_path": "/webhook/wecom",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> WeCom Webhook 挂载在共享 Gateway 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。
|
||||
|
||||
**快速设置 — 自建应用:**
|
||||
|
||||
**1. 创建应用**
|
||||
|
||||
* 企业微信管理后台 → 应用管理 → 创建应用
|
||||
* 复制 **AgentId** 和 **Secret**
|
||||
* 前往"我的企业"页面,复制 **CorpID**
|
||||
|
||||
**2. 配置接收消息**
|
||||
|
||||
* 在应用详情中,点击"接收消息" → "设置 API"
|
||||
* 设置 URL 为 `http://your-server:18790/webhook/wecom-app`
|
||||
* 生成 **Token** 和 **EncodingAESKey**
|
||||
|
||||
**3. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom_app": {
|
||||
"enabled": true,
|
||||
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
||||
"corp_secret": "YOUR_CORP_SECRET",
|
||||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**4. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
> **注意**: WeCom Webhook 回调挂载在 Gateway 端口(默认 18790)。使用反向代理配置 HTTPS。
|
||||
|
||||
**快速设置 — 智能机器人 (AI Bot):**
|
||||
|
||||
**1. 创建 AI Bot**
|
||||
|
||||
* 企业微信管理后台 → 应用管理 → AI Bot
|
||||
* 在 AI Bot 设置中配置回调 URL:`http://your-server:18790/webhook/wecom-aibot`
|
||||
* 复制 **Token** 并点击"随机生成" **EncodingAESKey**
|
||||
|
||||
**2. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom_aibot": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||
"webhook_path": "/webhook/wecom-aibot",
|
||||
"bot_id": "YOUR_BOT_ID",
|
||||
"secret": "YOUR_SECRET",
|
||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||
"send_thinking_message": true,
|
||||
"allow_from": [],
|
||||
"welcome_message": "你好!有什么可以帮你的?",
|
||||
"processing_message": "⏳ Processing, please wait. The results will be sent shortly."
|
||||
"reasoning_channel_id": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -599,7 +531,7 @@ picoclaw gateway
|
|||
picoclaw gateway
|
||||
```
|
||||
|
||||
> **注意**: 企业微信 AI Bot 使用流式拉取协议,无回复超时问题。长任务(>30 秒)会自动切换到 `response_url` 推送投递。
|
||||
> 这个分支中旧的 `wecom_app` 和 `wecom_aibot` 配置已经被统一的 `channels.wecom` 替代。
|
||||
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -381,7 +381,7 @@ type TypingConfig struct {
|
|||
|
||||
// PlaceholderConfig controls placeholder message behavior (Phase 10).
|
||||
type PlaceholderConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -862,6 +862,10 @@ type ModelConfig struct {
|
|||
secModelName string
|
||||
apiKeys []string
|
||||
secDirty bool
|
||||
|
||||
// isVirtual marks this model as a virtual model generated from multi-key expansion.
|
||||
// Virtual models should not be persisted to config files.
|
||||
isVirtual bool
|
||||
}
|
||||
|
||||
// APIKey returns the first API key from apiKeys
|
||||
|
|
@ -872,6 +876,11 @@ func (c *ModelConfig) APIKey() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// IsVirtual returns true if this model was generated from multi-key expansion.
|
||||
func (c *ModelConfig) IsVirtual() bool {
|
||||
return c.isVirtual
|
||||
}
|
||||
|
||||
// Validate checks if the ModelConfig has all required fields.
|
||||
func (c *ModelConfig) Validate() error {
|
||||
if c.ModelName == "" {
|
||||
|
|
@ -1805,7 +1814,20 @@ func SaveConfig(path string, cfg *Config) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Filter out virtual models before serializing to config file
|
||||
nonVirtualModels := make([]*ModelConfig, 0, len(cfg.ModelList))
|
||||
for _, m := range cfg.ModelList {
|
||||
if !m.isVirtual {
|
||||
nonVirtualModels = append(nonVirtualModels, m)
|
||||
}
|
||||
}
|
||||
// Temporarily replace ModelList with filtered version for serialization
|
||||
originalModelList := cfg.ModelList
|
||||
cfg.ModelList = nonVirtualModels
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
// Restore original ModelList after serialization
|
||||
cfg.ModelList = originalModelList
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -2025,6 +2047,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig {
|
|||
RequestTimeout: m.RequestTimeout,
|
||||
ThinkingLevel: m.ThinkingLevel,
|
||||
ExtraBody: m.ExtraBody,
|
||||
isVirtual: true,
|
||||
}
|
||||
expanded = append(expanded, additionalEntry)
|
||||
fallbackNames = append(fallbackNames, expandedName)
|
||||
|
|
|
|||
|
|
@ -360,6 +360,96 @@ func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSaveConfig_PreservesDisabledTelegramPlaceholder(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Channels.Telegram.Placeholder.Enabled = false
|
||||
|
||||
if err := SaveConfig(path, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig failed: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"placeholder": {`) {
|
||||
t.Fatalf("saved config should include telegram placeholder config, got: %s", string(data))
|
||||
}
|
||||
if !strings.Contains(string(data), `"enabled": false`) {
|
||||
t.Fatalf("saved config should persist placeholder.enabled=false, got: %s", string(data))
|
||||
}
|
||||
|
||||
loaded, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig failed: %v", err)
|
||||
}
|
||||
if loaded.Channels.Telegram.Placeholder.Enabled {
|
||||
t.Fatal("telegram placeholder should remain disabled after SaveConfig/LoadConfig round-trip")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveConfig_FiltersVirtualModels verifies that SaveConfig does not write
|
||||
// virtual models (generated by expandMultiKeyModels) to the config file.
|
||||
func TestSaveConfig_FiltersVirtualModels(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
cfg := DefaultConfig()
|
||||
|
||||
// Manually add a virtual model to ModelList (simulating what expandMultiKeyModels does)
|
||||
primaryModel := &ModelConfig{
|
||||
ModelName: "gpt-4",
|
||||
Model: "openai/gpt-4o",
|
||||
apiKeys: []string{"key1"},
|
||||
}
|
||||
virtualModel := &ModelConfig{
|
||||
ModelName: "gpt-4__key_1",
|
||||
Model: "openai/gpt-4o",
|
||||
apiKeys: []string{"key2"},
|
||||
isVirtual: true,
|
||||
}
|
||||
cfg.ModelList = []*ModelConfig{primaryModel, virtualModel}
|
||||
|
||||
// SaveConfig should filter out virtual models
|
||||
if err := SaveConfig(path, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// Reload and verify
|
||||
reloaded, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// Should only have the primary model, not the virtual one
|
||||
if len(reloaded.ModelList) != 1 {
|
||||
t.Fatalf("expected 1 model after reload, got %d", len(reloaded.ModelList))
|
||||
}
|
||||
|
||||
if reloaded.ModelList[0].ModelName != "gpt-4" {
|
||||
t.Errorf("expected model_name 'gpt-4', got %q", reloaded.ModelList[0].ModelName)
|
||||
}
|
||||
|
||||
// Verify virtual model was not persisted
|
||||
for _, m := range reloaded.ModelList {
|
||||
if m.ModelName == "gpt-4__key_1" {
|
||||
t.Errorf("virtual model gpt-4__key_1 should not have been saved")
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the saved file does not contain the virtual model name
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile failed: %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), "gpt-4__key_1") {
|
||||
t.Errorf("saved config should not contain virtual model name 'gpt-4__key_1'")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfig_Complete verifies all config fields are set
|
||||
func TestConfig_Complete(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
|
|
|||
|
|
@ -232,6 +232,78 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestExpandMultiKeyModels_IsVirtualFlag(t *testing.T) {
|
||||
models := []*ModelConfig{
|
||||
{
|
||||
ModelName: "gpt-4",
|
||||
Model: "openai/gpt-4o",
|
||||
apiKeys: []string{"key1", "key2", "key3"},
|
||||
},
|
||||
}
|
||||
|
||||
result := expandMultiKeyModels(models)
|
||||
|
||||
// Should expand to 3 models
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 models, got %d", len(result))
|
||||
}
|
||||
|
||||
// Primary model should NOT be virtual
|
||||
primary := result[2]
|
||||
if primary.isVirtual {
|
||||
t.Errorf("primary model should not be virtual")
|
||||
}
|
||||
if primary.ModelName != "gpt-4" {
|
||||
t.Errorf("expected primary model_name 'gpt-4', got %q", primary.ModelName)
|
||||
}
|
||||
|
||||
// Virtual models should have isVirtual = true
|
||||
virtual1 := result[0]
|
||||
if !virtual1.isVirtual {
|
||||
t.Errorf("gpt-4__key_1 should be virtual")
|
||||
}
|
||||
if virtual1.ModelName != "gpt-4__key_1" {
|
||||
t.Errorf("expected virtual model_name 'gpt-4__key_1', got %q", virtual1.ModelName)
|
||||
}
|
||||
|
||||
virtual2 := result[1]
|
||||
if !virtual2.isVirtual {
|
||||
t.Errorf("gpt-4__key_2 should be virtual")
|
||||
}
|
||||
if virtual2.ModelName != "gpt-4__key_2" {
|
||||
t.Errorf("expected virtual model_name 'gpt-4__key_2', got %q", virtual2.ModelName)
|
||||
}
|
||||
|
||||
// IsVirtual() method should work
|
||||
if !virtual1.IsVirtual() {
|
||||
t.Errorf("IsVirtual() should return true for virtual model")
|
||||
}
|
||||
if primary.IsVirtual() {
|
||||
t.Errorf("IsVirtual() should return false for primary model")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandMultiKeyModels_SingleKey_NotVirtual(t *testing.T) {
|
||||
models := []*ModelConfig{
|
||||
{
|
||||
ModelName: "gpt-4",
|
||||
Model: "openai/gpt-4o",
|
||||
apiKeys: []string{"single-key"},
|
||||
},
|
||||
}
|
||||
|
||||
result := expandMultiKeyModels(models)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 model, got %d", len(result))
|
||||
}
|
||||
|
||||
// Single key model should NOT be virtual
|
||||
if result[0].isVirtual {
|
||||
t.Errorf("single key model should not be virtual")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeAPIKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
|
|
@ -16,6 +17,7 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
|
|||
mux.HandleFunc("GET /api/config", h.handleGetConfig)
|
||||
mux.HandleFunc("PUT /api/config", h.handleUpdateConfig)
|
||||
mux.HandleFunc("PATCH /api/config", h.handlePatchConfig)
|
||||
mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns)
|
||||
}
|
||||
|
||||
// handleGetConfig returns the complete system configuration.
|
||||
|
|
@ -179,6 +181,70 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
|||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleTestCommandPatterns tests a command against whitelist and blacklist patterns.
|
||||
//
|
||||
// POST /api/config/test-command-patterns
|
||||
func (h *Handler) handleTestCommandPatterns(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var req struct {
|
||||
AllowPatterns []string `json:"allow_patterns"`
|
||||
DenyPatterns []string `json:"deny_patterns"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
lower := strings.ToLower(strings.TrimSpace(req.Command))
|
||||
|
||||
type result struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
Blocked bool `json:"blocked"`
|
||||
MatchedWhitelist *string `json:"matched_whitelist,omitempty"`
|
||||
MatchedBlacklist *string `json:"matched_blacklist,omitempty"`
|
||||
}
|
||||
|
||||
resp := result{Allowed: false, Blocked: false}
|
||||
|
||||
// Check whitelist first
|
||||
for _, pattern := range req.AllowPatterns {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
continue // skip invalid patterns
|
||||
}
|
||||
if re.MatchString(lower) {
|
||||
resp.Allowed = true
|
||||
resp.MatchedWhitelist = &pattern
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check blacklist
|
||||
for _, pattern := range req.DenyPatterns {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if re.MatchString(lower) {
|
||||
resp.Blocked = true
|
||||
resp.MatchedBlacklist = &pattern
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// validateConfig checks the config for common errors before saving.
|
||||
// Returns a list of human-readable error strings; empty means valid.
|
||||
func validateConfig(cfg *config.Config) []string {
|
||||
|
|
|
|||
|
|
@ -282,3 +282,170 @@ func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisable
|
|||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// testCommandPatterns is a helper that sets up a handler and sends a test-command-patterns request.
|
||||
func testCommandPatterns(t *testing.T, configPath string, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/config/test-command-patterns", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestHandleTestCommandPatterns_MatchesWhitelist(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
rec := testCommandPatterns(t, configPath, `{
|
||||
"allow_patterns": ["^echo\\s+hello"],
|
||||
"deny_patterns": ["^rm\\s+-rf"],
|
||||
"command": "echo hello world"
|
||||
}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
|
||||
t.Fatalf("expected allowed=true, body=%s", rec.Body.String())
|
||||
}
|
||||
if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
|
||||
t.Fatalf("expected blocked=false when whitelist matches, body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTestCommandPatterns_MatchesBlacklistNotWhitelist(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
rec := testCommandPatterns(t, configPath, `{
|
||||
"allow_patterns": ["^echo\\s+hello"],
|
||||
"deny_patterns": ["^rm\\s+-rf"],
|
||||
"command": "rm -rf /tmp"
|
||||
}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
|
||||
t.Fatalf("expected blocked=true, body=%s", rec.Body.String())
|
||||
}
|
||||
if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
|
||||
t.Fatalf("expected allowed=false when blacklist matches but not whitelist, body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTestCommandPatterns_MatchesNeither(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
rec := testCommandPatterns(t, configPath, `{
|
||||
"allow_patterns": ["^echo\\s+hello"],
|
||||
"deny_patterns": ["^rm\\s+-rf"],
|
||||
"command": "ls -la"
|
||||
}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
|
||||
t.Fatalf("expected allowed=false, body=%s", rec.Body.String())
|
||||
}
|
||||
if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
|
||||
t.Fatalf("expected blocked=false, body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTestCommandPatterns_CaseInsensitiveWithGoFlag(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
rec := testCommandPatterns(t, configPath, `{
|
||||
"allow_patterns": ["(?i)^ECHO"],
|
||||
"deny_patterns": [],
|
||||
"command": "echo hello"
|
||||
}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
|
||||
t.Fatalf("expected allowed=true with Go (?i) flag, body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTestCommandPatterns_EmptyPatterns(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
rec := testCommandPatterns(t, configPath, `{
|
||||
"allow_patterns": [],
|
||||
"deny_patterns": [],
|
||||
"command": "rm -rf /tmp"
|
||||
}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
|
||||
t.Fatalf("expected allowed=false with empty patterns, body=%s", rec.Body.String())
|
||||
}
|
||||
if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
|
||||
t.Fatalf("expected blocked=false with empty patterns, body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTestCommandPatterns_InvalidRegexSkipped(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
rec := testCommandPatterns(t, configPath, `{
|
||||
"allow_patterns": ["([[", "^echo"],
|
||||
"deny_patterns": [],
|
||||
"command": "echo hello"
|
||||
}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
|
||||
t.Fatalf("expected allowed=true, invalid pattern skipped and valid one matched, body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTestCommandPatterns_ReturnsMatchedPattern(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
rec := testCommandPatterns(t, configPath, `{
|
||||
"allow_patterns": [],
|
||||
"deny_patterns": ["\\$(?i)[a-zA-Z_]*(SECRET|KEY|PASSWORD|TOKEN|AUTH)[a-zA-Z0-9_]*"],
|
||||
"command": "echo $GITHUB_API_KEY"
|
||||
}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
|
||||
t.Fatalf("expected blocked=true, body=%s", rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte(`matched_blacklist`)) {
|
||||
t.Fatalf("expected matched_blacklist field, body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTestCommandPatterns_InvalidJSON(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/config/test-command-patterns",
|
||||
bytes.NewBufferString(`{invalid json}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ type modelResponse struct {
|
|||
// Meta
|
||||
Configured bool `json:"configured"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsVirtual bool `json:"is_virtual"`
|
||||
}
|
||||
|
||||
// handleListModels returns all model_list entries with masked API keys.
|
||||
|
|
@ -88,6 +89,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
|||
ExtraHeaders: m.ExtraHeaders,
|
||||
Configured: configured[i],
|
||||
IsDefault: m.ModelName == defaultModel,
|
||||
IsVirtual: m.IsVirtual(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -204,8 +206,13 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
|
|||
} else {
|
||||
mc.ModelConfig.SetAPIKey(mc.APIKey)
|
||||
}
|
||||
// Preserve existing ExtraBody when omitted (nil), but clear it when
|
||||
// the frontend sends an empty object {} to indicate the field should
|
||||
// be removed.
|
||||
if mc.ExtraBody == nil {
|
||||
mc.ExtraBody = cfg.ModelList[idx].ExtraBody
|
||||
} else if len(mc.ExtraBody) == 0 {
|
||||
mc.ExtraBody = nil
|
||||
}
|
||||
if mc.ExtraHeaders == nil {
|
||||
mc.ExtraHeaders = cfg.ModelList[idx].ExtraHeaders
|
||||
|
|
@ -293,11 +300,13 @@ func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request)
|
|||
return
|
||||
}
|
||||
|
||||
// Verify the model_name exists in model_list
|
||||
// Verify the model_name exists in model_list and is not a virtual model
|
||||
found := false
|
||||
isVirtual := false
|
||||
for _, m := range cfg.ModelList {
|
||||
if m.ModelName == req.ModelName {
|
||||
found = true
|
||||
isVirtual = m.IsVirtual()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
@ -305,6 +314,10 @@ func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request)
|
|||
http.Error(w, fmt.Sprintf("Model %q not found in model_list", req.ModelName), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if isVirtual {
|
||||
http.Error(w, fmt.Sprintf("Cannot set virtual model %q as default", req.ModelName), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Agents.Defaults.ModelName = req.ModelName
|
||||
|
||||
|
|
|
|||
|
|
@ -356,6 +356,46 @@ func TestHandleAddModel_PersistsAPIKey(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestHandleSetDefaultModel_RejectsNonexistentModel tests that setting a non-existent
|
||||
// model as default returns 404. This covers the case where virtual models (which are
|
||||
// filtered by SaveConfig) cannot be set as default.
|
||||
func TestHandleSetDefaultModel_RejectsNonexistentModel(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
// First save a valid config with a primary model
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []*config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4o"},
|
||||
}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
// Try to set a non-existent model (like a virtual model name) as default
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/models/default", bytes.NewBufferString(`{
|
||||
"model_name": "gpt-4__key_1"
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
// Should return 404 because the virtual model doesn't exist in the persisted config
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "not found") {
|
||||
t.Fatalf("error message should mention 'not found', got: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskAPIKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export interface ModelInfo {
|
|||
// Meta
|
||||
configured: boolean
|
||||
is_default: boolean
|
||||
is_virtual: boolean
|
||||
}
|
||||
|
||||
interface ModelsListResponse {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState } from "react"
|
||||
import type { ReactNode } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
|
|
@ -7,6 +8,7 @@ import {
|
|||
type LauncherForm,
|
||||
} from "@/components/config/form-model"
|
||||
import { Field, SwitchCardField } from "@/components/shared-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -201,6 +203,56 @@ interface ExecSectionProps {
|
|||
|
||||
export function ExecSection({ form, onFieldChange }: ExecSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const [testCommand, setTestCommand] = useState("")
|
||||
const [testResult, setTestResult] = useState<{
|
||||
allowed: boolean
|
||||
blocked: boolean
|
||||
matchedWhitelist: string | null
|
||||
matchedBlacklist: string | null
|
||||
} | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const testPatterns = async () => {
|
||||
if (!testCommand.trim()) {
|
||||
setTestResult(null)
|
||||
return
|
||||
}
|
||||
|
||||
const allowPatterns = form.customAllowPatternsText
|
||||
.split("\n")
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0)
|
||||
const denyPatterns = form.enableDenyPatterns
|
||||
? form.customDenyPatternsText
|
||||
.split("\n")
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0)
|
||||
: []
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch("/api/config/test-command-patterns", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
allow_patterns: allowPatterns,
|
||||
deny_patterns: denyPatterns,
|
||||
command: testCommand,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
setTestResult({
|
||||
allowed: data.allowed,
|
||||
blocked: data.blocked,
|
||||
matchedWhitelist: data.matched_whitelist ?? null,
|
||||
matchedBlacklist: data.matched_blacklist ?? null,
|
||||
})
|
||||
} catch {
|
||||
setTestResult(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigSectionCard title={t("pages.config.sections.exec")}>
|
||||
|
|
@ -266,6 +318,50 @@ export function ExecSection({ form, onFieldChange }: ExecSectionProps) {
|
|||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.pattern_detector_title")}
|
||||
hint={t("pages.config.pattern_detector_hint")}
|
||||
layout="setting-row"
|
||||
controlClassName="md:max-w-md"
|
||||
>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={testCommand}
|
||||
placeholder={t(
|
||||
"pages.config.pattern_detector_input_placeholder",
|
||||
)}
|
||||
onChange={(e) => setTestCommand(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
testPatterns()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={testPatterns} disabled={isLoading}>
|
||||
{t("pages.config.pattern_detector_test_button")}
|
||||
</Button>
|
||||
</div>
|
||||
{testResult && (
|
||||
<div
|
||||
className={`rounded-md p-2 text-sm ${
|
||||
testResult.allowed
|
||||
? "bg-green-500/10 text-green-600"
|
||||
: testResult.blocked
|
||||
? "bg-red-500/10 text-red-600"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{testResult.allowed
|
||||
? `${t("pages.config.pattern_detector_result_allowed")}${testResult.matchedWhitelist ? ` (${testResult.matchedWhitelist})` : ""}`
|
||||
: testResult.blocked
|
||||
? `${t("pages.config.pattern_detector_result_blocked")}${testResult.matchedBlacklist ? ` (${testResult.matchedBlacklist})` : ""}`
|
||||
: t("pages.config.pattern_detector_result_no_match")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.exec_timeout_seconds")}
|
||||
hint={t("pages.config.exec_timeout_seconds_hint")}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from "@/components/shared-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
|
|
@ -35,6 +36,7 @@ interface AddForm {
|
|||
requestTimeout: string
|
||||
thinkingLevel: string
|
||||
extraHeaders: string
|
||||
extraBody: string
|
||||
}
|
||||
|
||||
const EMPTY_ADD_FORM: AddForm = {
|
||||
|
|
@ -51,6 +53,7 @@ const EMPTY_ADD_FORM: AddForm = {
|
|||
requestTimeout: "",
|
||||
thinkingLevel: "",
|
||||
extraHeaders: "",
|
||||
extraBody: "",
|
||||
}
|
||||
|
||||
interface AddModelSheetProps {
|
||||
|
|
@ -112,7 +115,7 @@ export function AddModelSheet({
|
|||
}
|
||||
|
||||
const setField =
|
||||
(key: keyof AddForm) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
(key: keyof AddForm) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setForm((f) => ({ ...f, [key]: e.target.value }))
|
||||
if (fieldErrors[key]) {
|
||||
setFieldErrors((prev) => ({ ...prev, [key]: undefined }))
|
||||
|
|
@ -142,6 +145,9 @@ export function AddModelSheet({
|
|||
: undefined,
|
||||
thinking_level: form.thinkingLevel.trim() || undefined,
|
||||
extra_headers: form.extraHeaders.trim() ? JSON.parse(form.extraHeaders.trim()) : undefined,
|
||||
extra_body: form.extraBody.trim()
|
||||
? JSON.parse(form.extraBody.trim())
|
||||
: undefined,
|
||||
})
|
||||
if (setAsDefault) {
|
||||
await setDefaultModel(modelName)
|
||||
|
|
@ -332,6 +338,15 @@ export function AddModelSheet({
|
|||
{fieldErrors.extraHeaders && (
|
||||
<p className="text-destructive text-xs">{fieldErrors.extraHeaders}</p>
|
||||
)}
|
||||
label={t("models.field.extraBody")}
|
||||
hint={t("models.field.extraBodyHint")}
|
||||
>
|
||||
<Textarea
|
||||
value={form.extraBody}
|
||||
onChange={setField("extraBody")}
|
||||
placeholder='{"key": "value"}'
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
</AdvancedSection>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from "@/components/shared-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
|
|
@ -33,6 +34,7 @@ interface EditForm {
|
|||
requestTimeout: string
|
||||
thinkingLevel: string
|
||||
extraHeaders: string
|
||||
extraBody: string
|
||||
}
|
||||
|
||||
interface EditModelSheetProps {
|
||||
|
|
@ -61,6 +63,7 @@ export function EditModelSheet({
|
|||
requestTimeout: "",
|
||||
thinkingLevel: "",
|
||||
extraHeaders: "",
|
||||
extraBody: "",
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [setAsDefault, setSetAsDefault] = useState(false)
|
||||
|
|
@ -82,6 +85,9 @@ export function EditModelSheet({
|
|||
: "",
|
||||
thinkingLevel: model.thinking_level ?? "",
|
||||
extraHeaders: model.extra_headers ? JSON.stringify(model.extra_headers) : "",
|
||||
extraBody: model.extra_body
|
||||
? JSON.stringify(model.extra_body, null, 2)
|
||||
: "",
|
||||
})
|
||||
setSetAsDefault(model.is_default)
|
||||
setError("")
|
||||
|
|
@ -89,7 +95,7 @@ export function EditModelSheet({
|
|||
}, [model])
|
||||
|
||||
const setField =
|
||||
(key: keyof EditForm) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
(key: keyof EditForm) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
setForm((f) => ({ ...f, [key]: e.target.value }))
|
||||
|
||||
const handleSave = async () => {
|
||||
|
|
@ -125,6 +131,9 @@ export function EditModelSheet({
|
|||
: undefined,
|
||||
thinking_level: form.thinkingLevel || undefined,
|
||||
extra_headers: form.extraHeaders.trim() ? JSON.parse(form.extraHeaders.trim()) : undefined,
|
||||
extra_body: form.extraBody.trim()
|
||||
? JSON.parse(form.extraBody.trim())
|
||||
: {},
|
||||
})
|
||||
if (setAsDefault && !model.is_default) {
|
||||
await setDefaultModel(model.model_name)
|
||||
|
|
@ -298,6 +307,14 @@ export function EditModelSheet({
|
|||
value={form.extraHeaders}
|
||||
onChange={setField("extraHeaders")}
|
||||
placeholder='{"X-My-Header": "value"}'
|
||||
label={t("models.field.extraBody")}
|
||||
hint={t("models.field.extraBodyHint")}
|
||||
>
|
||||
<Textarea
|
||||
value={form.extraBody}
|
||||
onChange={setField("extraBody")}
|
||||
placeholder='{"key": "value"}'
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
</AdvancedSection>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function ModelCard({
|
|||
}: ModelCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const isOAuth = model.auth_method === "oauth"
|
||||
const canSetDefault = model.configured && !model.is_default
|
||||
const canSetDefault = model.configured && !model.is_default && !model.is_virtual
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -64,6 +64,11 @@ export function ModelCard({
|
|||
{t("models.badge.default")}
|
||||
</span>
|
||||
)}
|
||||
{model.is_virtual && (
|
||||
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-[10px] leading-none font-medium">
|
||||
{t("models.badge.virtual")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
|
|
|
|||
|
|
@ -154,7 +154,8 @@
|
|||
"unconfigured": "Not configured"
|
||||
},
|
||||
"badge": {
|
||||
"default": "Default"
|
||||
"default": "Default",
|
||||
"virtual": "Virtual"
|
||||
},
|
||||
"action": {
|
||||
"edit": "Edit API key",
|
||||
|
|
@ -211,6 +212,8 @@
|
|||
"maxTokensFieldHint": "Override the request field name for max tokens, e.g. max_completion_tokens.",
|
||||
"extraHeaders": "Extra Headers",
|
||||
"extraHeadersHint": "Custom HTTP headers in JSON format, e.g. {\"X-My-Header\": \"value\"}"
|
||||
"extraBody": "Extra Body",
|
||||
"extraBodyHint": "Additional JSON fields to inject into the request body, e.g. {\"reasoning_split\": true}."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Configure {{name}}",
|
||||
|
|
@ -434,6 +437,13 @@
|
|||
"custom_allow_patterns": "Command Whitelist",
|
||||
"custom_allow_patterns_hint": "Add extra command-allow rules, one regular expression per line. A command matching any rule here skips blacklist matching, but other safety limits still apply.",
|
||||
"custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
|
||||
"pattern_detector_title": "Pattern Detection Tool",
|
||||
"pattern_detector_hint": "Enter a command to test if it matches any blacklist or whitelist patterns.",
|
||||
"pattern_detector_input_placeholder": "Enter a command to test, e.g., rm -rf /tmp",
|
||||
"pattern_detector_test_button": "Test",
|
||||
"pattern_detector_result_allowed": "Allowed (matches whitelist)",
|
||||
"pattern_detector_result_blocked": "Blocked (matches blacklist)",
|
||||
"pattern_detector_result_no_match": "No match (will use default rules)",
|
||||
"allow_shell_execution": "Allow Scheduled Commands",
|
||||
"allow_shell_execution_hint": "Allow scheduled tasks to run commands by default. When disabled, users must pass command_confirm=true to schedule a command task.",
|
||||
"cron_exec_timeout": "Scheduled Command Timeout (minutes)",
|
||||
|
|
|
|||
|
|
@ -154,7 +154,8 @@
|
|||
"unconfigured": "未配置"
|
||||
},
|
||||
"badge": {
|
||||
"default": "默认"
|
||||
"default": "默认",
|
||||
"virtual": "虚拟"
|
||||
},
|
||||
"action": {
|
||||
"edit": "编辑 API Key",
|
||||
|
|
@ -208,7 +209,9 @@
|
|||
"thinkingLevel": "思考级别",
|
||||
"thinkingLevelHint": "扩展思考预算:off、low、medium、high、xhigh、adaptive。",
|
||||
"maxTokensField": "Max Tokens 字段名",
|
||||
"maxTokensFieldHint": "覆盖请求中 max_tokens 的字段名,例如 max_completion_tokens。"
|
||||
"maxTokensFieldHint": "覆盖请求中 max_tokens 的字段名,例如 max_completion_tokens。",
|
||||
"extraBody": "Extra Body",
|
||||
"extraBodyHint": "要注入到请求体中的额外 JSON 字段,例如 {\"reasoning_split\": true}。"
|
||||
},
|
||||
"edit": {
|
||||
"title": "配置 {{name}}",
|
||||
|
|
@ -432,6 +435,13 @@
|
|||
"custom_allow_patterns": "命令白名单",
|
||||
"custom_allow_patterns_hint": "用于补充额外的命令放行规则,每行一个正则表达式。命中任意一条规则的命令会跳过黑名单检查,但仍受其他安全限制约束。",
|
||||
"custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
|
||||
"pattern_detector_title": "规则检测工具",
|
||||
"pattern_detector_hint": "输入命令以检测其是否匹配黑名单或白名单规则。",
|
||||
"pattern_detector_input_placeholder": "输入要检测的命令,例如 rm -rf /tmp",
|
||||
"pattern_detector_test_button": "检测",
|
||||
"pattern_detector_result_allowed": "允许(匹配白名单)",
|
||||
"pattern_detector_result_blocked": "阻止(匹配黑名单)",
|
||||
"pattern_detector_result_no_match": "无匹配(将使用默认规则)",
|
||||
"allow_shell_execution": "允许定时任务运行命令",
|
||||
"allow_shell_execution_hint": "开启后,定时任务默认允许运行命令。关闭后,必须显式传入 command_confirm=true 才能创建运行命令的定时任务。",
|
||||
"cron_exec_timeout": "定时命令超时(分钟)",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue