Merge branch 'main' into feat/subturn-poc
This commit is contained in:
commit
4f646ef2b8
24 changed files with 673 additions and 43 deletions
4
.github/workflows/nightly.yml
vendored
4
.github/workflows/nightly.yml
vendored
|
|
@ -56,7 +56,7 @@ jobs:
|
||||||
run: corepack enable && corepack prepare pnpm@latest --activate
|
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
@ -79,7 +79,7 @@ jobs:
|
||||||
run: git tag "${{ steps.version.outputs.version }}"
|
run: git tag "${{ steps.version.outputs.version }}"
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v7
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser
|
distribution: goreleaser
|
||||||
version: ~> v2
|
version: ~> v2
|
||||||
|
|
|
||||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -74,7 +74,7 @@ jobs:
|
||||||
run: corepack enable && corepack prepare pnpm@latest --activate
|
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
@ -94,7 +94,7 @@ jobs:
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v7
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser
|
distribution: goreleaser
|
||||||
version: ~> v2
|
version: ~> v2
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
# 企业微信智能机器人 (AI Bot)
|
# 企业微信智能机器人 (AI Bot)
|
||||||
|
|
||||||
企业微信智能机器人(AI Bot)是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议。
|
企业微信智能机器人(AI Bot)是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议。PicoClaw 当前同时支持两种接入模式:
|
||||||
|
|
||||||
|
- WebSocket 长连接模式:使用 `bot_id` + `secret`,优先级更高,推荐使用
|
||||||
|
- Webhook 短连接模式:使用 `token` + `encoding_aes_key`,兼容传统回调,并支持超时后通过 `response_url` 主动推送最终回复
|
||||||
|
|
||||||
## 与其他 WeCom 通道的对比
|
## 与其他 WeCom 通道的对比
|
||||||
|
|
||||||
|
|
@ -14,6 +17,8 @@
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
|
### WebSocket 长连接模式(推荐)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channels": {
|
||||||
|
|
@ -29,22 +34,113 @@
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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 管理页面配置 |
|
| bot_id | string | 是 | AI Bot 的唯一标识,在 AI Bot 管理页面配置 |
|
||||||
| secret | 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 白名单,空数组表示允许所有用户 |
|
| allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 |
|
||||||
| welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 |
|
| welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 |
|
||||||
| reply_timeout | int | 否 | 回复超时时间(秒,默认:5) |
|
| reply_timeout | int | 否 | 回复超时时间(秒,默认:5) |
|
||||||
| max_steps | int | 否 | Agent 最大执行步骤数(默认:10) |
|
| 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)
|
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)
|
||||||
2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot
|
2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot
|
||||||
3. 在 AI Bot 配置页面,配置 Bot 的名称、头像等信息,获取 `Bot ID` 和 `Secret`
|
3. 在 AI Bot 配置页面,配置 Bot 的名称、头像等信息,获取 `Bot ID` 和 `Secret`
|
||||||
4. 在 PicoClaw 配置文件中添加上述配置,重启 PicoClaw
|
4. 在 PicoClaw 配置文件中添加上述配置,重启 PicoClaw
|
||||||
|
|
||||||
|
### Webhook 短连接模式
|
||||||
|
|
||||||
|
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)
|
||||||
|
2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot
|
||||||
|
3. 在 AI Bot 配置页面,填写"消息接收"信息:
|
||||||
|
- **URL**:`http://<your-server-ip>: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 会自动回复该欢迎语。留空则静默忽略。
|
配置 `welcome_message` 后,当用户打开与 AI Bot 的聊天窗口时(`enter_chat` 事件),PicoClaw 会自动回复该欢迎语。留空则静默忽略。
|
||||||
|
|
@ -55,12 +151,32 @@
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
|
### WebSocket 模式无法连接
|
||||||
|
|
||||||
|
- 检查 `bot_id` 和 `secret` 是否填写正确
|
||||||
|
- 查看日志中是否有 WebSocket 连接或鉴权失败信息
|
||||||
|
- 确认服务器可以访问企业微信长连接接口
|
||||||
|
|
||||||
|
### 回调 URL 验证失败
|
||||||
|
|
||||||
|
- 确认 `token` 与 `encoding_aes_key` 填写正确
|
||||||
|
- 确认服务器防火墙已开放对应端口
|
||||||
|
- 检查 PicoClaw 日志是否收到了来自企业微信的验证请求
|
||||||
|
|
||||||
### 消息没有回复
|
### 消息没有回复
|
||||||
|
|
||||||
- 检查 `allow_from` 是否意外限制了发送者
|
- 检查 `allow_from` 是否意外限制了发送者
|
||||||
- 查看日志中是否出现 `context canceled` 或 Agent 错误
|
- 查看日志中是否出现 `context canceled` 或 Agent 错误
|
||||||
- 确认 Agent 配置(`model_name` 等)正确
|
- 确认 Agent 配置(`model_name` 等)正确
|
||||||
|
|
||||||
|
### 超长任务没有收到最终推送
|
||||||
|
|
||||||
|
- 确认消息回调中携带了 `response_url`
|
||||||
|
- 确认服务器能主动访问外网
|
||||||
|
- 查看日志关键词 `response_url mode` 和 `Sending reply via response_url`
|
||||||
|
|
||||||
## 参考文档
|
## 参考文档
|
||||||
|
|
||||||
- [企业微信 AI Bot 接入文档](https://developer.work.weixin.qq.com/document/path/101463)
|
- [企业微信 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)
|
||||||
|
|
|
||||||
|
|
@ -414,7 +414,8 @@ picoclaw gateway
|
||||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
"webhook_path": "/webhook/wecom-aibot",
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
"allow_from": [],
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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.
|
- 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.
|
- 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
|
### 🔒 Security Sandbox
|
||||||
|
|
||||||
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
|
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
|
||||||
|
|
|
||||||
|
|
@ -410,7 +410,8 @@ picoclaw gateway
|
||||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
"webhook_path": "/webhook/wecom-aibot",
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
"allow_from": [],
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,32 @@ L'outil exec est utilisé pour exécuter des commandes shell.
|
||||||
|
|
||||||
| Config | Type | Par défaut | Description |
|
| 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 |
|
| `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) |
|
| `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é
|
### Fonctionnalité
|
||||||
|
|
||||||
- **`enable_deny_patterns`** : Définir à `false` pour désactiver complètement les modèles de blocage par défaut des commandes dangereuses
|
- **`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 :
|
Par exemple :
|
||||||
|
|
||||||
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
|
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
|
||||||
|
- `PICOCLAW_TOOLS_EXEC_ENABLED=false`
|
||||||
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
||||||
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
||||||
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
||||||
|
|
|
||||||
|
|
@ -510,7 +510,8 @@ picoclaw gateway
|
||||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
"webhook_path": "/webhook/wecom-aibot",
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"welcome_message": "こんにちは!何かお手伝いできますか?"
|
"welcome_message": "こんにちは!何かお手伝いできますか?",
|
||||||
|
"processing_message": "⏳ Processing, please wait. The results will be sent shortly."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,32 @@ Exec ツールはシェルコマンドの実行に使用されます。
|
||||||
|
|
||||||
| 設定項目 | 型 | デフォルト | 説明 |
|
| 設定項目 | 型 | デフォルト | 説明 |
|
||||||
|------------------------|-------|------------|------------------------------------|
|
|------------------------|-------|------------|------------------------------------|
|
||||||
|
| `enabled` | bool | true | Exec ツールを有効にする |
|
||||||
| `enable_deny_patterns` | bool | true | デフォルトの危険コマンドブロックを有効にする |
|
| `enable_deny_patterns` | bool | true | デフォルトの危険コマンドブロックを有効にする |
|
||||||
| `custom_deny_patterns` | array | [] | カスタム拒否パターン(正規表現) |
|
| `custom_deny_patterns` | array | [] | カスタム拒否パターン(正規表現) |
|
||||||
|
|
||||||
|
### Exec ツールの無効化
|
||||||
|
|
||||||
|
`exec` ツールを完全に無効にするには、`enabled` を `false` に設定します:
|
||||||
|
|
||||||
|
**設定ファイル経由:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"exec": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**環境変数経由:**
|
||||||
|
```bash
|
||||||
|
PICOCLAW_TOOLS_EXEC_ENABLED=false
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意:** 無効にすると、エージェントはシェルコマンドを実行できなくなります。これは Cron ツールがスケジュールされたシェルコマンドを実行する能力にも影響します。
|
||||||
|
|
||||||
### 機能
|
### 機能
|
||||||
|
|
||||||
- **`enable_deny_patterns`**:`false` に設定すると、デフォルトの危険コマンドブロックパターンを完全に無効にします
|
- **`enable_deny_patterns`**:`false` に設定すると、デフォルトの危険コマンドブロックパターンを完全に無効にします
|
||||||
|
|
@ -329,6 +352,7 @@ Skills ツールは ClawHub などのレジストリを通じたスキルの発
|
||||||
例:
|
例:
|
||||||
|
|
||||||
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
|
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
|
||||||
|
- `PICOCLAW_TOOLS_EXEC_ENABLED=false`
|
||||||
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
||||||
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
||||||
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,32 @@ A ferramenta exec é usada para executar comandos shell.
|
||||||
|
|
||||||
| Config | Tipo | Padrão | Descrição |
|
| 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 |
|
| `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) |
|
| `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
|
### Funcionalidade
|
||||||
|
|
||||||
- **`enable_deny_patterns`**: Defina como `false` para desabilitar completamente os padrões de bloqueio de comandos perigosos padrão
|
- **`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:
|
Por exemplo:
|
||||||
|
|
||||||
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
|
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
|
||||||
|
- `PICOCLAW_TOOLS_EXEC_ENABLED=false`
|
||||||
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
||||||
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
||||||
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
||||||
|
|
|
||||||
|
|
@ -68,9 +68,32 @@ The exec tool is used to execute shell commands.
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
|------------------------|-------|---------|--------------------------------------------|
|
|------------------------|-------|---------|--------------------------------------------|
|
||||||
|
| `enabled` | bool | true | Enable the exec tool |
|
||||||
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
|
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
|
||||||
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
|
| `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
|
### Functionality
|
||||||
|
|
||||||
- **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns
|
- **`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:
|
For example:
|
||||||
|
|
||||||
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
|
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
|
||||||
|
- `PICOCLAW_TOOLS_EXEC_ENABLED=false`
|
||||||
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
||||||
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
||||||
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
||||||
|
|
|
||||||
|
|
@ -410,7 +410,8 @@ picoclaw gateway
|
||||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
"webhook_path": "/webhook/wecom-aibot",
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
"allow_from": [],
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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ả |
|
| 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 |
|
| `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) |
|
| `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
|
### 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
|
- **`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ụ:
|
Ví dụ:
|
||||||
|
|
||||||
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
|
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
|
||||||
|
- `PICOCLAW_TOOLS_EXEC_ENABLED=false`
|
||||||
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
||||||
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
||||||
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
||||||
|
|
|
||||||
|
|
@ -510,7 +510,8 @@ picoclaw gateway
|
||||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
"webhook_path": "/webhook/wecom-aibot",
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"welcome_message": "你好!有什么可以帮你的?"
|
"welcome_message": "你好!有什么可以帮你的?",
|
||||||
|
"processing_message": "⏳ Processing, please wait. The results will be sent shortly."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,32 @@ Exec 工具用于执行 shell 命令。
|
||||||
|
|
||||||
| 配置项 | 类型 | 默认值 | 描述 |
|
| 配置项 | 类型 | 默认值 | 描述 |
|
||||||
|------------------------|-------|--------|--------------------------------|
|
|------------------------|-------|--------|--------------------------------|
|
||||||
|
| `enabled` | bool | true | 启用 exec 工具 |
|
||||||
| `enable_deny_patterns` | bool | true | 启用默认的危险命令拦截 |
|
| `enable_deny_patterns` | bool | true | 启用默认的危险命令拦截 |
|
||||||
| `custom_deny_patterns` | array | [] | 自定义拒绝模式(正则表达式) |
|
| `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` 可完全禁用默认的危险命令拦截模式
|
- **`enable_deny_patterns`**:设为 `false` 可完全禁用默认的危险命令拦截模式
|
||||||
|
|
@ -329,6 +352,7 @@ Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。
|
||||||
例如:
|
例如:
|
||||||
|
|
||||||
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
|
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
|
||||||
|
- `PICOCLAW_TOOLS_EXEC_ENABLED=false`
|
||||||
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
||||||
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
||||||
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,11 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
|
|
||||||
// Process message
|
// Process message
|
||||||
func() {
|
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.
|
// 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.
|
// Currently disabled because files are deleted before the LLM can access their content.
|
||||||
// defer func() {
|
// defer func() {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"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.
|
// 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 {
|
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return channels.ErrNotRunning
|
return channels.ErrNotRunning
|
||||||
|
|
@ -141,9 +143,38 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
// Build interactive card with markdown content
|
// Build interactive card with markdown content
|
||||||
cardContent, err := buildMarkdownCard(msg.Content)
|
cardContent, err := buildMarkdownCard(msg.Content)
|
||||||
if err != nil {
|
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.
|
// EditMessage implements channels.MessageEditor.
|
||||||
|
|
@ -738,6 +769,35 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string
|
||||||
return nil
|
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.
|
// sendImage uploads an image and sends it as a message.
|
||||||
func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.File) error {
|
func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.File) error {
|
||||||
// Upload image to get image_key
|
// Upload image to get image_key
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// RecordReactionUndo registers a reaction undo function for later invocation.
|
||||||
// Implements PlaceholderRecorder.
|
// Implements PlaceholderRecorder.
|
||||||
func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) {
|
func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestPreSend_TypingStopCalled(t *testing.T) {
|
||||||
m := newTestManager()
|
m := newTestManager()
|
||||||
var stopCalled bool
|
var stopCalled bool
|
||||||
|
|
|
||||||
|
|
@ -302,10 +302,17 @@ func (c *TelegramChannel) sendChunk(
|
||||||
return nil
|
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.
|
// StartTyping implements channels.TypingCapable.
|
||||||
// It sends ChatAction(typing) immediately and then repeats every 4 seconds
|
// It sends ChatAction(typing) immediately and then repeats every 4 seconds
|
||||||
// (Telegram's typing indicator expires after ~5s) in a background goroutine.
|
// (Telegram's typing indicator expires after ~5s) in a background goroutine.
|
||||||
// The returned stop function is idempotent and cancels the 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) {
|
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
||||||
cid, threadID, err := parseTelegramChatID(chatID)
|
cid, threadID, err := parseTelegramChatID(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -319,12 +326,15 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
|
||||||
_ = c.bot.SendChatAction(ctx, action)
|
_ = c.bot.SendChatAction(ctx, action)
|
||||||
|
|
||||||
typingCtx, cancel := context.WithCancel(ctx)
|
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() {
|
go func() {
|
||||||
|
defer maxCancel()
|
||||||
ticker := time.NewTicker(4 * time.Second)
|
ticker := time.NewTicker(4 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-typingCtx.Done():
|
case <-maxCtx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
|
a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,9 @@ func NewWeComAIBotChannel(
|
||||||
"WeCom AI Bot requires either (bot_id + secret) for WebSocket mode " +
|
"WeCom AI Bot requires either (bot_id + secret) for WebSocket mode " +
|
||||||
"or (token + encoding_aes_key) for webhook 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,
|
base := channels.NewBaseChannel("wecom_aibot", cfg, messageBus, cfg.AllowFrom,
|
||||||
channels.WithMaxMessageLength(2048),
|
channels.WithMaxMessageLength(2048),
|
||||||
|
|
@ -709,7 +712,7 @@ func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce
|
||||||
default:
|
default:
|
||||||
if time.Now().After(task.Deadline) {
|
if time.Now().After(task.Deadline) {
|
||||||
// Deadline reached: close the stream with a notice, then wait for agent via response_url.
|
// 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
|
finish = true
|
||||||
closeStreamOnly = true
|
closeStreamOnly = true
|
||||||
logger.InfoCF(
|
logger.InfoCF(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package wecom
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestGenerateStreamID(t *testing.T) {
|
||||||
cfg := config.WeComAIBotConfig{
|
cfg := config.WeComAIBotConfig{
|
||||||
Enabled: true,
|
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 ----
|
// ---- WebSocket long-connection mode tests ----
|
||||||
|
|
||||||
func TestNewWeComAIBotChannel_WSMode(t *testing.T) {
|
func TestNewWeComAIBotChannel_WSMode(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,10 @@ type AgentDefaults struct {
|
||||||
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
|
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 {
|
func (d *AgentDefaults) GetMaxMediaSize() int {
|
||||||
if d.MaxMediaSize > 0 {
|
if d.MaxMediaSize > 0 {
|
||||||
|
|
@ -490,8 +493,9 @@ type WeComAIBotConfig struct {
|
||||||
WebhookPath string `json:"webhook_path,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
|
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"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
|
||||||
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
|
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
|
||||||
MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"`
|
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"`
|
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"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@ func DefaultConfig() *Config {
|
||||||
ReplyTimeout: 5,
|
ReplyTimeout: 5,
|
||||||
MaxSteps: 10,
|
MaxSteps: 10,
|
||||||
WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?",
|
WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?",
|
||||||
|
ProcessingMessage: DefaultWeComAIBotProcessingMessage,
|
||||||
},
|
},
|
||||||
Pico: PicoConfig{
|
Pico: PicoConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue