diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e001dc3e9..a5002fec5 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -56,7 +56,7 @@ jobs: run: corepack enable && corepack prepare pnpm@latest --activate - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -79,7 +79,7 @@ jobs: run: git tag "${{ steps.version.outputs.version }}" - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser version: ~> v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19c8e5404..2ce341770 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,7 +74,7 @@ jobs: run: corepack enable && corepack prepare pnpm@latest --activate - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -94,7 +94,7 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser version: ~> v2 diff --git a/docs/channels/wecom/wecom_aibot/README.zh.md b/docs/channels/wecom/wecom_aibot/README.zh.md index de4fba445..48a151a25 100644 --- a/docs/channels/wecom/wecom_aibot/README.zh.md +++ b/docs/channels/wecom/wecom_aibot/README.zh.md @@ -1,6 +1,9 @@ # 企业微信智能机器人 (AI Bot) -企业微信智能机器人(AI Bot)是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议。 +企业微信智能机器人(AI Bot)是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议。PicoClaw 当前同时支持两种接入模式: + +- WebSocket 长连接模式:使用 `bot_id` + `secret`,优先级更高,推荐使用 +- Webhook 短连接模式:使用 `token` + `encoding_aes_key`,兼容传统回调,并支持超时后通过 `response_url` 主动推送最终回复 ## 与其他 WeCom 通道的对比 @@ -14,6 +17,8 @@ ## 配置 +### WebSocket 长连接模式(推荐) + ```json { "channels": { @@ -29,22 +34,113 @@ } ``` -| 字段 | 类型 | 必填 | 描述 | -| ---------------- | ------ | ---- | -------------------------------------------------- | -| bot_id | string | 是 | AI Bot 的唯一标识,在 AI Bot 管理页面配置 | -| secret | string | 是 | AI Bot 的密钥,在 AI Bot 管理页面配置 | -| allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 | -| welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 | -| reply_timeout | int | 否 | 回复超时时间(秒,默认:5) | -| max_steps | int | 否 | Agent 最大执行步骤数(默认:10) | +### Webhook 短连接模式 + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "你好!有什么可以帮助你的吗?", + "processing_message": "⏳ Processing, please wait. The results will be sent shortly.", + "max_steps": 10 + } + } +} +``` + +### WebSocket 模式字段 + +| 字段 | 类型 | 必填 | 描述 | +|--------|--------|------|--------------------------------------------| +| bot_id | string | 是 | AI Bot 的唯一标识,在 AI Bot 管理页面配置 | +| secret | string | 是 | AI Bot 的密钥,在 AI Bot 管理页面配置 | + +### Webhook 模式字段 + +| 字段 | 类型 | 必填 | 描述 | +|------------------|--------|------|----------------------------------------------| +| token | string | 是 | 回调验证令牌,在 AI Bot 管理页面配置 | +| encoding_aes_key | string | 是 | 43 字符 AES 密钥,在 AI Bot 管理页面随机生成 | +| webhook_path | string | 否 | Webhook 路径,默认 `/webhook/wecom-aibot` | +| processing_message | string | 否 | 流式超时后返回给用户的提示语 | + +### 通用字段 + +| 字段 | 类型 | 必填 | 描述 | +|-----------------|--------|------|------------------------------------------| +| allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 | +| welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 | +| reply_timeout | int | 否 | 回复超时时间(秒,默认:5) | +| max_steps | int | 否 | Agent 最大执行步骤数(默认:10) | + +## 模式选择 + +- 当 `bot_id` 和 `secret` 同时存在时,PicoClaw 会优先使用 WebSocket 长连接模式 +- 否则,当 `token` 和 `encoding_aes_key` 同时存在时,PicoClaw 会使用 Webhook 短连接模式 ## 设置流程 +### WebSocket 长连接模式 + 1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin) 2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot -3. 在 AI Bot 配置页面,配置Bot的名称、头像等信息,获取 `Bot ID` 和 `Secret` +3. 在 AI Bot 配置页面,配置 Bot 的名称、头像等信息,获取 `Bot ID` 和 `Secret` 4. 在 PicoClaw 配置文件中添加上述配置,重启 PicoClaw +### Webhook 短连接模式 + +1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin) +2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot +3. 在 AI Bot 配置页面,填写"消息接收"信息: + - **URL**:`http://:18791/webhook/wecom-aibot` + - **Token**:随机生成或自定义 + - **EncodingAESKey**:点击"随机生成",得到 43 字符密钥 +4. 将 Token 和 EncodingAESKey 填入 PicoClaw 配置文件,启动服务后回到管理后台保存 + +> [!TIP] +> 服务器需要能被企业微信服务器访问。如在内网或本地开发,可使用 [ngrok](https://ngrok.com) 或 frp 做内网穿透。 + +## Webhook 模式的流式响应协议 + +Webhook 模式使用"流式拉取"协议,区别于普通 Webhook 的一次性回复: + +``` +用户发消息 + │ + ▼ +PicoClaw 立即返回 {finish: false}(Agent 开始处理) + │ + ▼ +企业微信每隔约 1 秒拉取一次 {msgtype: "stream", stream: {id: "..."}} + │ + ├─ Agent 未完成 → 返回 {finish: false}(继续等待) + │ + └─ Agent 完成 → 返回 {finish: true, content: "回答内容"} +``` + +**超时处理**(任务超过约 30 秒): + +若 Agent 处理时间超过轮询窗口,PicoClaw 会: + +1. 立即关闭流,向用户显示 `processing_message` 提示语 +2. Agent 继续在后台运行 +3. Agent 完成后,通过消息中携带的 `response_url` 将最终回复主动推送给用户 + +> `response_url` 由企业微信颁发,有效期 1 小时,只可使用一次,无需加密,直接 POST markdown 消息体即可。 + +## 超时提示语 + +配置 `processing_message` 后,当 Webhook 模式的流式轮询超时并切换到 `response_url` 主动推送模式时,PicoClaw 会先返回这段提示语来结束当前流。 + +```json +"processing_message": "⏳ Processing, please wait. The results will be sent shortly." +``` + ## 欢迎语 配置 `welcome_message` 后,当用户打开与 AI Bot 的聊天窗口时(`enter_chat` 事件),PicoClaw 会自动回复该欢迎语。留空则静默忽略。 @@ -55,12 +151,32 @@ ## 常见问题 +### WebSocket 模式无法连接 + +- 检查 `bot_id` 和 `secret` 是否填写正确 +- 查看日志中是否有 WebSocket 连接或鉴权失败信息 +- 确认服务器可以访问企业微信长连接接口 + +### 回调 URL 验证失败 + +- 确认 `token` 与 `encoding_aes_key` 填写正确 +- 确认服务器防火墙已开放对应端口 +- 检查 PicoClaw 日志是否收到了来自企业微信的验证请求 + ### 消息没有回复 - 检查 `allow_from` 是否意外限制了发送者 - 查看日志中是否出现 `context canceled` 或 Agent 错误 - 确认 Agent 配置(`model_name` 等)正确 +### 超长任务没有收到最终推送 + +- 确认消息回调中携带了 `response_url` +- 确认服务器能主动访问外网 +- 查看日志关键词 `response_url mode` 和 `Sending reply via response_url` + ## 参考文档 - [企业微信 AI Bot 接入文档](https://developer.work.weixin.qq.com/document/path/101463) +- [流式响应协议说明](https://developer.work.weixin.qq.com/document/path/100719) +- [response_url 主动回复](https://developer.work.weixin.qq.com/document/path/101138) diff --git a/docs/chat-apps.md b/docs/chat-apps.md index 05afc7f33..66aa7ea53 100644 --- a/docs/chat-apps.md +++ b/docs/chat-apps.md @@ -414,7 +414,8 @@ picoclaw gateway "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", "allow_from": [], - "welcome_message": "Hello! How can I help you?" + "welcome_message": "Hello! How can I help you?", + "processing_message": "⏳ Processing, please wait. The results will be sent shortly." } } } diff --git a/docs/configuration.md b/docs/configuration.md index 202ad4f59..268de9135 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -71,6 +71,135 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills - Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup. - Unknown slash command (for example `/foo`) passes through to normal LLM processing. - Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing. + +### Agent Bindings (Route messages to specific agents) + +Use `bindings` in `config.json` to route incoming messages to different agents by channel/account/context. + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "gpt-4o-mini" + }, + "list": [ + { "id": "main", "default": true, "name": "Main Assistant" }, + { "id": "support", "name": "Support Assistant" }, + { "id": "sales", "name": "Sales Assistant" } + ] + }, + "bindings": [ + { + "agent_id": "support", + "match": { + "channel": "telegram", + "account_id": "*", + "peer": { "kind": "direct", "id": "user123" } + } + }, + { + "agent_id": "sales", + "match": { + "channel": "discord", + "account_id": "my-discord-bot", + "guild_id": "987654321" + } + } + ] +} +``` + +#### `bindings` fields + +| Field | Required | Description | +|-------|----------|-------------| +| `agent_id` | Yes | Target agent id in `agents.list` | +| `match.channel` | Yes | Channel name (e.g. `telegram`, `discord`) | +| `match.account_id` | No | Channel account filter. Use `"*"` for all accounts of that channel. If omitted, only default account is matched | +| `match.peer.kind` + `match.peer.id` | No | Exact peer match (e.g. direct chat / topic / group id) | +| `match.guild_id` | No | Guild/server-level match | +| `match.team_id` | No | Team/workspace-level match | + +#### Matching priority + +When multiple bindings exist, PicoClaw resolves in this order: + +1. `peer` +2. `parent_peer` (for thread/topic parent contexts) +3. `guild_id` +4. `team_id` +5. `account_id` (non-wildcard) +6. channel wildcard (`account_id: "*"`) +7. default agent + +If a binding points to a missing `agent_id`, PicoClaw falls back to the default agent. + +#### How matching works (step-by-step) + +1. PicoClaw first filters bindings by `match.channel` (must equal current channel). +2. It then filters by `match.account_id`: + - omitted: match only the channel's default account + - `"*"`: match all accounts on this channel + - explicit value: exact account id match (case-insensitive) +3. From the remaining candidates, it applies the priority chain above and stops at the first hit. + +In other words: **channel + account form the candidate set; peer/guild/team then decide final winner**. + +#### Common recipes + +**1) Route one specific DM user to a specialist agent** + +```json +{ + "agent_id": "support", + "match": { + "channel": "telegram", + "account_id": "*", + "peer": { "kind": "direct", "id": "user123" } + } +} +``` + +**2) Route one Discord server (guild) to a dedicated agent** + +```json +{ + "agent_id": "sales", + "match": { + "channel": "discord", + "account_id": "my-discord-bot", + "guild_id": "987654321" + } +} +``` + +**3) Route all remaining traffic of a channel to a fallback agent** + +```json +{ + "agent_id": "main", + "match": { + "channel": "discord", + "account_id": "*" + } +} +``` + +#### Authoring guidelines (important) + +- Keep exactly one clear default agent in `agents.list` (`"default": true`). +- Put specific rules (`peer`, `guild_id`, `team_id`) and broad rules (`account_id: "*"` only) together safely; priority already guarantees specific rules win. +- Avoid duplicate rules with the same specificity and match values. If duplicates exist, the first matching entry in the config array wins. +- Ensure every `agent_id` exists in `agents.list`; unknown IDs silently fall back to default. + +#### Troubleshooting checklist + +- **Rule not taking effect?** Check `match.channel` spelling first (must be exact). +- **Expected account-specific routing but still using default?** Verify `match.account_id` equals actual runtime account id. +- **Wildcard catches too much traffic?** Add more specific `peer/guild/team` rules for critical paths. +- **Unexpected default fallback?** Confirm `agent_id` exists and is not misspelled. + ### 🔒 Security Sandbox PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. diff --git a/docs/fr/chat-apps.md b/docs/fr/chat-apps.md index 03bb6e17b..39026e0df 100644 --- a/docs/fr/chat-apps.md +++ b/docs/fr/chat-apps.md @@ -410,7 +410,8 @@ picoclaw gateway "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", "allow_from": [], - "welcome_message": "Hello! How can I help you?" + "welcome_message": "Hello! How can I help you?", + "processing_message": "⏳ Processing, please wait. The results will be sent shortly." } } } diff --git a/docs/fr/tools_configuration.md b/docs/fr/tools_configuration.md index 15573fc30..f6e1c0374 100644 --- a/docs/fr/tools_configuration.md +++ b/docs/fr/tools_configuration.md @@ -70,9 +70,32 @@ L'outil exec est utilisé pour exécuter des commandes shell. | Config | Type | Par défaut | Description | |------------------------|-------|------------|------------------------------------------------| +| `enabled` | bool | true | Activer l'outil exec | | `enable_deny_patterns` | bool | true | Activer le blocage par défaut des commandes dangereuses | | `custom_deny_patterns` | array | [] | Modèles de refus personnalisés (expressions régulières) | +### Désactivation de l'Outil Exec + +Pour désactiver complètement l'outil `exec`, définissez `enabled` à `false` : + +**Via le fichier de configuration :** +```json +{ + "tools": { + "exec": { + "enabled": false + } + } +} +``` + +**Via la variable d'environnement :** +```bash +PICOCLAW_TOOLS_EXEC_ENABLED=false +``` + +> **Note :** Lorsqu'il est désactivé, l'agent ne pourra pas exécuter de commandes shell. Cela affecte également la capacité de l'outil Cron à exécuter des commandes shell planifiées. + ### Fonctionnalité - **`enable_deny_patterns`** : Définir à `false` pour désactiver complètement les modèles de blocage par défaut des commandes dangereuses @@ -329,6 +352,7 @@ Toutes les options de configuration peuvent être remplacées via des variables Par exemple : - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLED=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` diff --git a/docs/ja/chat-apps.md b/docs/ja/chat-apps.md index 6d01c817b..54c6e4015 100644 --- a/docs/ja/chat-apps.md +++ b/docs/ja/chat-apps.md @@ -510,7 +510,8 @@ picoclaw gateway "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", "allow_from": [], - "welcome_message": "こんにちは!何かお手伝いできますか?" + "welcome_message": "こんにちは!何かお手伝いできますか?", + "processing_message": "⏳ Processing, please wait. The results will be sent shortly." } } } diff --git a/docs/ja/tools_configuration.md b/docs/ja/tools_configuration.md index e4568f6ae..c40e58538 100644 --- a/docs/ja/tools_configuration.md +++ b/docs/ja/tools_configuration.md @@ -70,9 +70,32 @@ Exec ツールはシェルコマンドの実行に使用されます。 | 設定項目 | 型 | デフォルト | 説明 | |------------------------|-------|------------|------------------------------------| +| `enabled` | bool | true | Exec ツールを有効にする | | `enable_deny_patterns` | bool | true | デフォルトの危険コマンドブロックを有効にする | | `custom_deny_patterns` | array | [] | カスタム拒否パターン(正規表現) | +### Exec ツールの無効化 + +`exec` ツールを完全に無効にするには、`enabled` を `false` に設定します: + +**設定ファイル経由:** +```json +{ + "tools": { + "exec": { + "enabled": false + } + } +} +``` + +**環境変数経由:** +```bash +PICOCLAW_TOOLS_EXEC_ENABLED=false +``` + +> **注意:** 無効にすると、エージェントはシェルコマンドを実行できなくなります。これは Cron ツールがスケジュールされたシェルコマンドを実行する能力にも影響します。 + ### 機能 - **`enable_deny_patterns`**:`false` に設定すると、デフォルトの危険コマンドブロックパターンを完全に無効にします @@ -329,6 +352,7 @@ Skills ツールは ClawHub などのレジストリを通じたスキルの発 例: - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLED=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` diff --git a/docs/pt-br/tools_configuration.md b/docs/pt-br/tools_configuration.md index b6f726aa4..2cc4f3999 100644 --- a/docs/pt-br/tools_configuration.md +++ b/docs/pt-br/tools_configuration.md @@ -70,9 +70,32 @@ A ferramenta exec é usada para executar comandos shell. | Config | Tipo | Padrão | Descrição | |------------------------|-------|--------|-------------------------------------------------| +| `enabled` | bool | true | Habilitar a ferramenta exec | | `enable_deny_patterns` | bool | true | Habilitar bloqueio padrão de comandos perigosos | | `custom_deny_patterns` | array | [] | Padrões de negação personalizados (expressões regulares) | +### Desabilitando a Ferramenta Exec + +Para desabilitar completamente a ferramenta `exec`, defina `enabled` como `false`: + +**Via arquivo de configuração:** +```json +{ + "tools": { + "exec": { + "enabled": false + } + } +} +``` + +**Via variável de ambiente:** +```bash +PICOCLAW_TOOLS_EXEC_ENABLED=false +``` + +> **Nota:** Quando desabilitada, o agent não poderá executar comandos shell. Isso também afeta a capacidade da ferramenta Cron de executar comandos shell agendados. + ### Funcionalidade - **`enable_deny_patterns`**: Defina como `false` para desabilitar completamente os padrões de bloqueio de comandos perigosos padrão @@ -329,6 +352,7 @@ Todas as opções de configuração podem ser substituídas via variáveis de am Por exemplo: - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLED=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md index a38f0856f..2e0a22d3b 100644 --- a/docs/tools_configuration.md +++ b/docs/tools_configuration.md @@ -68,9 +68,32 @@ The exec tool is used to execute shell commands. | Config | Type | Default | Description | |------------------------|-------|---------|--------------------------------------------| +| `enabled` | bool | true | Enable the exec tool | | `enable_deny_patterns` | bool | true | Enable default dangerous command blocking | | `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) | +### Disabling the Exec Tool + +To completely disable the `exec` tool, set `enabled` to `false`: + +**Via config file:** +```json +{ + "tools": { + "exec": { + "enabled": false + } + } +} +``` + +**Via environment variable:** +```bash +PICOCLAW_TOOLS_EXEC_ENABLED=false +``` + +> **Note:** When disabled, the agent will not be able to execute shell commands. This also affects the Cron tool's ability to run scheduled shell commands. + ### Functionality - **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns @@ -379,6 +402,7 @@ All configuration options can be overridden via environment variables with the f For example: - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLED=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` diff --git a/docs/vi/chat-apps.md b/docs/vi/chat-apps.md index 1fefa00d3..5f527eabe 100644 --- a/docs/vi/chat-apps.md +++ b/docs/vi/chat-apps.md @@ -410,7 +410,8 @@ picoclaw gateway "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", "allow_from": [], - "welcome_message": "Hello! How can I help you?" + "welcome_message": "Hello! How can I help you?", + "processing_message": "⏳ Processing, please wait. The results will be sent shortly." } } } diff --git a/docs/vi/tools_configuration.md b/docs/vi/tools_configuration.md index 6cc4dc8b6..76a336186 100644 --- a/docs/vi/tools_configuration.md +++ b/docs/vi/tools_configuration.md @@ -70,9 +70,32 @@ Công cụ exec được sử dụng để thực thi các lệnh shell. | Cấu hình | Kiểu | Mặc định | Mô tả | |--------------------------|-------|----------|------------------------------------------------| +| `enabled` | bool | true | Bật công cụ exec | | `enable_deny_patterns` | bool | true | Bật chặn lệnh nguy hiểm mặc định | | `custom_deny_patterns` | array | [] | Mẫu từ chối tùy chỉnh (biểu thức chính quy) | +### Vô hiệu hóa Công cụ Exec + +Để hoàn toàn vô hiệu hóa công cụ `exec`, đặt `enabled` thành `false`: + +**Qua tệp cấu hình:** +```json +{ + "tools": { + "exec": { + "enabled": false + } + } +} +``` + +**Qua biến môi trường:** +```bash +PICOCLAW_TOOLS_EXEC_ENABLED=false +``` + +> **Lưu ý:** Khi bị vô hiệu hóa, agent sẽ không thể thực thi lệnh shell. Điều này cũng ảnh hưởng đến khả năng chạy lệnh shell theo lịch của công cụ Cron. + ### Chức năng - **`enable_deny_patterns`**: Đặt thành `false` để tắt hoàn toàn các mẫu chặn lệnh nguy hiểm mặc định @@ -329,6 +352,7 @@ Tất cả các tùy chọn cấu hình có thể được ghi đè qua biến m Ví dụ: - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLED=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` diff --git a/docs/zh/chat-apps.md b/docs/zh/chat-apps.md index 4957fbcca..f082f7cf0 100644 --- a/docs/zh/chat-apps.md +++ b/docs/zh/chat-apps.md @@ -510,7 +510,8 @@ picoclaw gateway "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", "allow_from": [], - "welcome_message": "你好!有什么可以帮你的?" + "welcome_message": "你好!有什么可以帮你的?", + "processing_message": "⏳ Processing, please wait. The results will be sent shortly." } } } diff --git a/docs/zh/tools_configuration.md b/docs/zh/tools_configuration.md index ff88b6707..e10e3d26a 100644 --- a/docs/zh/tools_configuration.md +++ b/docs/zh/tools_configuration.md @@ -70,9 +70,32 @@ Exec 工具用于执行 shell 命令。 | 配置项 | 类型 | 默认值 | 描述 | |------------------------|-------|--------|--------------------------------| +| `enabled` | bool | true | 启用 exec 工具 | | `enable_deny_patterns` | bool | true | 启用默认的危险命令拦截 | | `custom_deny_patterns` | array | [] | 自定义拒绝模式(正则表达式) | +### 禁用 Exec 工具 + +要完全禁用 `exec` 工具,请将 `enabled` 设置为 `false`: + +**通过配置文件:** +```json +{ + "tools": { + "exec": { + "enabled": false + } + } +} +``` + +**通过环境变量:** +```bash +PICOCLAW_TOOLS_EXEC_ENABLED=false +``` + +> **注意:** 禁用后,代理将无法执行 shell 命令。这也会影响 Cron 工具运行计划 shell 命令的能力。 + ### 功能说明 - **`enable_deny_patterns`**:设为 `false` 可完全禁用默认的危险命令拦截模式 @@ -329,6 +352,7 @@ Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。 例如: - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLED=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 984809e55..be3e8ea2b 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -366,6 +366,11 @@ func (al *AgentLoop) Run(ctx context.Context) error { // Process message func() { + defer func() { + if al.channelManager != nil { + al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID) + } + }() // TODO: Re-enable media cleanup after inbound media is properly consumed by the agent. // Currently disabled because files are deleted before the LLM can access their content. // defer func() { diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 3aea67b12..0341efc70 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "sync" "sync/atomic" @@ -129,6 +130,7 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { } // Send sends a message using Interactive Card format for markdown rendering. +// Falls back to plain text message if card sending fails (e.g., table limit exceeded). func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning @@ -141,9 +143,38 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error // Build interactive card with markdown content cardContent, err := buildMarkdownCard(msg.Content) if err != nil { - return fmt.Errorf("feishu send: card build failed: %w", err) + // If card build fails, fall back to plain text + return c.sendText(ctx, msg.ChatID, msg.Content) } - return c.sendCard(ctx, msg.ChatID, cardContent) + + // First attempt: try sending as interactive card + err = c.sendCard(ctx, msg.ChatID, cardContent) + if err == nil { + return nil + } + + // Check if error is due to card table limit (error code 11310) + // See: https://open.feishu.cn/document/server-docs/im-api/message-content-description/create_json + errMsg := err.Error() + isCardLimitError := strings.Contains(errMsg, "11310") + + if isCardLimitError { + logger.WarnCF("feishu", "Card send failed (table limit), falling back to text message", map[string]any{ + "chat_id": msg.ChatID, + "error": errMsg, + }) + + // Second attempt: fall back to plain text message + textErr := c.sendText(ctx, msg.ChatID, msg.Content) + if textErr == nil { + return nil + } + // If text also fails, return the text error + return textErr + } + + // For other errors, return the original card error + return err } // EditMessage implements channels.MessageEditor. @@ -738,6 +769,35 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string return nil } +// sendText sends a plain text message to a chat (fallback when card fails). +func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error { + content, _ := json.Marshal(map[string]string{"text": text}) + + req := larkim.NewCreateMessageReqBuilder(). + ReceiveIdType(larkim.ReceiveIdTypeChatId). + Body(larkim.NewCreateMessageReqBodyBuilder(). + ReceiveId(chatID). + MsgType(larkim.MsgTypeText). + Content(string(content)). + Build()). + Build() + + resp, err := c.client.Im.V1.Message.Create(ctx, req) + if err != nil { + return fmt.Errorf("feishu send text: %w", channels.ErrTemporary) + } + + if !resp.Success() { + return fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) + } + + logger.DebugCF("feishu", "Feishu text message sent (fallback)", map[string]any{ + "chat_id": chatID, + }) + + return nil +} + // sendImage uploads an image and sends it as a message. func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.File) error { // Upload image to get image_key diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 2e1e12ded..c980daf66 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -136,6 +136,19 @@ func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) { } } +// InvokeTypingStop invokes the registered typing stop function for the given channel and chatID. +// It is safe to call even when no typing indicator is active (no-op). +// Used by the agent loop to stop typing when processing completes (success, error, or panic), +// regardless of whether an outbound message is published. +func (m *Manager) InvokeTypingStop(channel, chatID string) { + key := channel + ":" + chatID + if v, loaded := m.typingStops.LoadAndDelete(key); loaded { + if entry, ok := v.(typingEntry); ok { + entry.stop() + } + } +} + // RecordReactionUndo registers a reaction undo function for later invocation. // Implements PlaceholderRecorder. func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) { diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index e0f55288a..7dfec9ebf 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -511,6 +511,43 @@ func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) { } } +func TestInvokeTypingStop_CallsRegisteredStop(t *testing.T) { + m := newTestManager() + var stopCalled bool + + m.RecordTypingStop("telegram", "chat123", func() { + stopCalled = true + }) + + m.InvokeTypingStop("telegram", "chat123") + + if !stopCalled { + t.Fatal("expected typing stop func to be called") + } +} + +func TestInvokeTypingStop_NoOpWhenNoEntry(t *testing.T) { + m := newTestManager() + // Should not panic + m.InvokeTypingStop("telegram", "nonexistent") +} + +func TestInvokeTypingStop_Idempotent(t *testing.T) { + m := newTestManager() + var callCount int + + m.RecordTypingStop("telegram", "chat123", func() { + callCount++ + }) + + m.InvokeTypingStop("telegram", "chat123") + m.InvokeTypingStop("telegram", "chat123") // Second call: entry already removed, no-op + + if callCount != 1 { + t.Fatalf("expected stop to be called once, got %d", callCount) + } +} + func TestPreSend_TypingStopCalled(t *testing.T) { m := newTestManager() var stopCalled bool diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 9d0325093..2797bdf4a 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -302,10 +302,17 @@ func (c *TelegramChannel) sendChunk( return nil } +// maxTypingDuration limits how long the typing indicator can run. +// Prevents endless typing when the LLM fails/hangs and preSend never invokes cancel. +// Matches channels.Manager's typingStopTTL (5 min) so behavior is consistent. +const maxTypingDuration = 5 * time.Minute + // StartTyping implements channels.TypingCapable. // It sends ChatAction(typing) immediately and then repeats every 4 seconds // (Telegram's typing indicator expires after ~5s) in a background goroutine. // The returned stop function is idempotent and cancels the goroutine. +// The goroutine also exits automatically after maxTypingDuration if cancel is +// never called (e.g. when the LLM fails or times out without publishing). func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { cid, threadID, err := parseTelegramChatID(chatID) if err != nil { @@ -319,12 +326,15 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func( _ = c.bot.SendChatAction(ctx, action) typingCtx, cancel := context.WithCancel(ctx) + // Cap lifetime so the goroutine cannot run indefinitely if cancel is never called + maxCtx, maxCancel := context.WithTimeout(typingCtx, maxTypingDuration) go func() { + defer maxCancel() ticker := time.NewTicker(4 * time.Second) defer ticker.Stop() for { select { - case <-typingCtx.Done(): + case <-maxCtx.Done(): return case <-ticker.C: a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 999f4f13b..2264b8492 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -158,6 +158,9 @@ func NewWeComAIBotChannel( "WeCom AI Bot requires either (bot_id + secret) for WebSocket mode " + "or (token + encoding_aes_key) for webhook mode") } + if cfg.ProcessingMessage == "" { + cfg.ProcessingMessage = config.DefaultWeComAIBotProcessingMessage + } base := channels.NewBaseChannel("wecom_aibot", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(2048), @@ -709,7 +712,7 @@ func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce default: if time.Now().After(task.Deadline) { // Deadline reached: close the stream with a notice, then wait for agent via response_url. - content = "⏳ Processing, please wait. The results will be sent shortly." + content = c.config.ProcessingMessage finish = true closeStreamOnly = true logger.InfoCF( diff --git a/pkg/channels/wecom/aibot_test.go b/pkg/channels/wecom/aibot_test.go index 7c5ae67b1..957b51c38 100644 --- a/pkg/channels/wecom/aibot_test.go +++ b/pkg/channels/wecom/aibot_test.go @@ -2,6 +2,7 @@ package wecom import ( "context" + "encoding/json" "testing" "time" @@ -134,6 +135,87 @@ func TestWeComAIBotChannelWebhookPath(t *testing.T) { }) } +func TestWeComAIBotChannelGetStreamResponseProcessingMessage(t *testing.T) { + validAESKey := "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG" + + t.Run("uses default processing message", func(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + Token: "test_token", + EncodingAESKey: validAESKey, + } + + messageBus := bus.NewMessageBus() + channel, err := NewWeComAIBotChannel(cfg, messageBus) + if err != nil { + t.Fatalf("Failed to create channel: %v", err) + } + ch, ok := channel.(*WeComAIBotChannel) + if !ok { + t.Fatal("Expected webhook mode channel") + } + + task := &streamTask{ + StreamID: "stream-default", + ChatID: "chat-default", + Deadline: time.Now().Add(-time.Second), + } + ch.streamTasks[task.StreamID] = task + ch.chatTasks[task.ChatID] = []*streamTask{task} + + resp := decodeStreamResponse(t, ch, ch.getStreamResponse(task, "1234567890", "nonce")) + + if !resp.Stream.Finish { + t.Fatal("Expected finished stream response after deadline") + } + if resp.Stream.Content != config.DefaultWeComAIBotProcessingMessage { + t.Fatalf("Expected default processing message %q, got %q", + config.DefaultWeComAIBotProcessingMessage, resp.Stream.Content) + } + if !task.StreamClosed { + t.Fatal("Expected task stream to be marked closed") + } + if _, ok := ch.streamTasks[task.StreamID]; ok { + t.Fatal("Expected closed stream task to be removed from streamTasks") + } + if len(ch.chatTasks[task.ChatID]) != 1 { + t.Fatalf("Expected task to remain queued for response_url delivery, got %d entries", + len(ch.chatTasks[task.ChatID])) + } + }) + + t.Run("uses custom processing message", func(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + Token: "test_token", + EncodingAESKey: validAESKey, + ProcessingMessage: "Please wait a moment. The result will be delivered in a follow-up message.", + } + + messageBus := bus.NewMessageBus() + channel, err := NewWeComAIBotChannel(cfg, messageBus) + if err != nil { + t.Fatalf("Failed to create channel: %v", err) + } + ch, ok := channel.(*WeComAIBotChannel) + if !ok { + t.Fatal("Expected webhook mode channel") + } + + task := &streamTask{ + StreamID: "stream-custom", + ChatID: "chat-custom", + Deadline: time.Now().Add(-time.Second), + } + + resp := decodeStreamResponse(t, ch, ch.getStreamResponse(task, "1234567890", "nonce")) + + if resp.Stream.Content != cfg.ProcessingMessage { + t.Fatalf("Expected custom processing message %q, got %q", cfg.ProcessingMessage, resp.Stream.Content) + } + }) +} + func TestGenerateStreamID(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, @@ -208,6 +290,27 @@ func TestGenerateSignature(t *testing.T) { } } +func decodeStreamResponse(t *testing.T, ch *WeComAIBotChannel, encryptedResponse string) WeComAIBotStreamResponse { + t.Helper() + + var wrapped WeComAIBotEncryptedResponse + if err := json.Unmarshal([]byte(encryptedResponse), &wrapped); err != nil { + t.Fatalf("Failed to unmarshal encrypted response: %v", err) + } + + plaintext, err := decryptMessageWithVerify(wrapped.Encrypt, ch.config.EncodingAESKey, "") + if err != nil { + t.Fatalf("Failed to decrypt response: %v", err) + } + + var resp WeComAIBotStreamResponse + if err := json.Unmarshal([]byte(plaintext), &resp); err != nil { + t.Fatalf("Failed to unmarshal decrypted response: %v", err) + } + + return resp +} + // ---- WebSocket long-connection mode tests ---- func TestNewWeComAIBotChannel_WSMode(t *testing.T) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 6fc355818..93ed52ca0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -255,7 +255,10 @@ type AgentDefaults struct { ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` } -const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB +const ( + DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB + DefaultWeComAIBotProcessingMessage = "⏳ Processing, please wait. The results will be sent shortly." +) func (d *AgentDefaults) GetMaxMediaSize() int { if d.MaxMediaSize > 0 { @@ -482,17 +485,18 @@ type WeComAppConfig struct { } type WeComAIBotConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"` - BotID string `json:"bot_id,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_BOT_ID"` - Secret string `json:"secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_SECRET"` - Token string `json:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"` - EncodingAESKey string `json:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"` - WebhookPath string `json:"webhook_path,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"` - ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"` - MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` - WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"` + BotID string `json:"bot_id,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_BOT_ID"` + Secret string `json:"secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_SECRET"` + Token string `json:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"` + EncodingAESKey string `json:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"` + WebhookPath string `json:"webhook_path,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"` + ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"` + MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps + WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome + ProcessingMessage string `json:"processing_message,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_PROCESSING_MESSAGE"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"` } type PicoConfig struct { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 26ade9c1a..1b54e5638 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -164,14 +164,15 @@ func DefaultConfig() *Config { ReplyTimeout: 5, }, WeComAIBot: WeComAIBotConfig{ - Enabled: false, - Token: "", - EncodingAESKey: "", - WebhookPath: "/webhook/wecom-aibot", - AllowFrom: FlexibleStringSlice{}, - ReplyTimeout: 5, - MaxSteps: 10, - WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?", + Enabled: false, + Token: "", + EncodingAESKey: "", + WebhookPath: "/webhook/wecom-aibot", + AllowFrom: FlexibleStringSlice{}, + ReplyTimeout: 5, + MaxSteps: 10, + WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?", + ProcessingMessage: DefaultWeComAIBotProcessingMessage, }, Pico: PicoConfig{ Enabled: false,