mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(qqbot): tighten bundled skill guardrails (#98032)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: qqbot-channel
|
||||
description: QQ channel management skill. Use qqbot_channel_api to list guilds and channels, inspect members, publish posts, manage announcements, and work with schedules through the QQ Open Platform HTTP API with automatic token authentication.
|
||||
description: QQ channel management skill. Use qqbot_channel_api for explicit QQ channel-management requests; confirm write, delete, and bulk actions before calling authenticated QQ Open Platform endpoints.
|
||||
metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qqbot"] } } }
|
||||
---
|
||||
|
||||
@@ -18,15 +18,25 @@ metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qq
|
||||
|
||||
## 🔧 工具参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| -------- | ------ | ---- | ---------------------------------------------------------------------------- |
|
||||
| `method` | string | 是 | HTTP 方法:`GET`, `POST`, `PUT`, `PATCH`, `DELETE` |
|
||||
| `path` | string | 是 | API 路径(不含域名),如 `/guilds/{guild_id}/channels`,需替换占位符为实际值 |
|
||||
| `body` | object | 否 | 请求体 JSON(POST/PUT/PATCH 使用) |
|
||||
| `query` | object | 否 | URL 查询参数键值对,值为字符串类型 |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------------- | ------- | ---- | ---------------------------------------------------------------------------- |
|
||||
| `method` | string | 是 | HTTP 方法:`GET`, `POST`, `PUT`, `PATCH`, `DELETE` |
|
||||
| `path` | string | 是 | API 路径(不含域名),如 `/guilds/{guild_id}/channels`,需替换占位符为实际值 |
|
||||
| `body` | object | 否 | 请求体 JSON(POST/PUT/PATCH 使用) |
|
||||
| `query` | object | 否 | URL 查询参数键值对,值为字符串类型 |
|
||||
| `confirmed` | boolean | 否 | `DELETE` 必须传 `true`,表示用户已确认精确删除目标 |
|
||||
| `bulkConfirmed` | boolean | 否 | 批量 `DELETE`(如删除全部公告)必须额外传 `true` |
|
||||
|
||||
> 基础 URL:`https://api.sgroup.qq.com`,鉴权头 `Authorization: QQBot {token}` 由工具自动填充。
|
||||
|
||||
## 🛡️ 安全边界
|
||||
|
||||
- 只在用户明确要求管理 QQ 频道、子频道、公告、论坛帖子或日程时调用写入接口。
|
||||
- `POST`、`PUT`、`PATCH` 和 `DELETE` 会修改真实 QQ 资源。调用前先复述目标频道/子频道/帖子/日程和预期改动;删除、批量删除、公告覆盖等不可逆或大范围操作必须等用户确认后再执行。
|
||||
- 删除前优先用 `GET`/列表接口查出候选项,让用户选择具体 ID;不要根据模糊名称猜测删除目标。
|
||||
- `DELETE` 请求必须传 `confirmed: true`,否则工具会拒绝执行。`announces/all` 这样的批量操作还必须传 `bulkConfirmed: true`,只有在用户明确说要删除全部公告并再次确认后才可使用。
|
||||
- 成员资料、头像 URL、频道图标等属于用户/群组资料。默认只总结必要字段;只有用户要求查看头像/图标或视觉比对时才内联展示图片,不要无关转发头像 URL。
|
||||
|
||||
---
|
||||
|
||||
## ⭐ 接口速查
|
||||
@@ -40,13 +50,13 @@ metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qq
|
||||
|
||||
### 子频道(Channel)
|
||||
|
||||
| 操作 | 方法 | 路径 | 参数说明 |
|
||||
| -------------- | -------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 获取子频道列表 | `GET` | `/guilds/{guild_id}/channels` | — |
|
||||
| 获取子频道详情 | `GET` | `/channels/{channel_id}` | — |
|
||||
| 创建子频道 | `POST` | `/guilds/{guild_id}/channels` | body: `name`\*, `type`\*, `position`\*, `sub_type`, `parent_id`, `private_type`, `private_user_ids`, `speak_permission`, `application_id` |
|
||||
| 修改子频道 | `PATCH` | `/channels/{channel_id}` | body: `name`, `position`, `parent_id`, `private_type`, `speak_permission`(至少一个) |
|
||||
| 删除子频道 | `DELETE` | `/channels/{channel_id}` | ⚠️ 不可逆 |
|
||||
| 操作 | 方法 | 路径 | 参数说明 |
|
||||
| -------------- | ------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 获取子频道列表 | `GET` | `/guilds/{guild_id}/channels` | — |
|
||||
| 获取子频道详情 | `GET` | `/channels/{channel_id}` | — |
|
||||
| 创建子频道 | `POST` | `/guilds/{guild_id}/channels` | body: `name`\*, `type`\*, `position`\*, `sub_type`, `parent_id`, `private_type`, `private_user_ids`, `speak_permission`, `application_id` |
|
||||
| 修改子频道 | `PATCH` | `/channels/{channel_id}` | body: `name`, `position`, `parent_id`, `private_type`, `speak_permission`(至少一个) |
|
||||
| 删除子频道 | — | 见受确认保护的删除流程 | 破坏性操作;不要在未确认时调用 |
|
||||
|
||||
**子频道类型(type)**:`0`=文字, `2`=语音, `4`=分组(position≥2), `10005`=直播, `10006`=应用, `10007`=论坛
|
||||
|
||||
@@ -61,28 +71,28 @@ metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qq
|
||||
|
||||
### 公告(Announces)
|
||||
|
||||
| 操作 | 方法 | 路径 | 参数说明 |
|
||||
| -------- | -------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| 创建公告 | `POST` | `/guilds/{guild_id}/announces` | body: `message_id`, `channel_id`, `announces_type`(0=成员,1=欢迎), `recommend_channels`(最多3条) |
|
||||
| 删除公告 | `DELETE` | `/guilds/{guild_id}/announces/{message_id}` | message_id 设 `all` 删除所有 |
|
||||
| 操作 | 方法 | 路径 | 参数说明 |
|
||||
| -------- | ------ | ------------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| 创建公告 | `POST` | `/guilds/{guild_id}/announces` | body: `message_id`, `channel_id`, `announces_type`(0=成员,1=欢迎), `recommend_channels`(最多3条) |
|
||||
| 删除公告 | — | 见受确认保护的删除流程 | 破坏性操作;批量删除需二次确认 |
|
||||
|
||||
### 论坛(Forum)— 仅私域机器人
|
||||
|
||||
| 操作 | 方法 | 路径 | 参数说明 |
|
||||
| ------------ | -------- | ---------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| 获取帖子列表 | `GET` | `/channels/{channel_id}/threads` | — |
|
||||
| 获取帖子详情 | `GET` | `/channels/{channel_id}/threads/{thread_id}` | — |
|
||||
| 发表帖子 | `PUT` | `/channels/{channel_id}/threads` | body: `title`\*, `content`\*, `format`(1=文本,2=HTML,3=Markdown,4=JSON,默认3) |
|
||||
| 删除帖子 | `DELETE` | `/channels/{channel_id}/threads/{thread_id}` | ⚠️ 不可逆 |
|
||||
| 发表评论 | `POST` | `/channels/{channel_id}/threads/{thread_id}/comment` | body: `thread_author`\*, `content`\*, `thread_create_time`, `image` |
|
||||
| 操作 | 方法 | 路径 | 参数说明 |
|
||||
| ------------ | ------ | ---------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| 获取帖子列表 | `GET` | `/channels/{channel_id}/threads` | — |
|
||||
| 获取帖子详情 | `GET` | `/channels/{channel_id}/threads/{thread_id}` | — |
|
||||
| 发表帖子 | `PUT` | `/channels/{channel_id}/threads` | body: `title`\*, `content`\*, `format`(1=文本,2=HTML,3=Markdown,4=JSON,默认3) |
|
||||
| 删除帖子 | — | 见受确认保护的删除流程 | 破坏性操作;不要在未确认时调用 |
|
||||
| 发表评论 | `POST` | `/channels/{channel_id}/threads/{thread_id}/comment` | body: `thread_author`\*, `content`\*, `thread_create_time`, `image` |
|
||||
|
||||
### 日程(Schedule)
|
||||
|
||||
| 操作 | 方法 | 路径 | 参数说明 |
|
||||
| -------- | -------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
|
||||
| 创建日程 | `POST` | `/channels/{channel_id}/schedules` | body: `{ schedule: { name*, start_timestamp*, end_timestamp*, jump_channel_id, remind_type } }` |
|
||||
| 修改日程 | `PATCH` | `/channels/{channel_id}/schedules/{schedule_id}` | body: `{ schedule: { name*, start_timestamp*, end_timestamp*, jump_channel_id, remind_type } }` |
|
||||
| 删除日程 | `DELETE` | `/channels/{channel_id}/schedules/{schedule_id}` | ⚠️ 不可逆 |
|
||||
| 操作 | 方法 | 路径 | 参数说明 |
|
||||
| -------- | ------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
|
||||
| 创建日程 | `POST` | `/channels/{channel_id}/schedules` | body: `{ schedule: { name*, start_timestamp*, end_timestamp*, jump_channel_id, remind_type } }` |
|
||||
| 修改日程 | `PATCH` | `/channels/{channel_id}/schedules/{schedule_id}` | body: `{ schedule: { name*, start_timestamp*, end_timestamp*, jump_channel_id, remind_type } }` |
|
||||
| 删除日程 | — | 见受确认保护的删除流程 | 破坏性操作;不要在未确认时调用 |
|
||||
|
||||
**提醒类型(remind_type)**:`"0"`=不提醒, `"1"`=开始时, `"2"`=5分钟前, `"3"`=15分钟前, `"4"`=30分钟前, `"5"`=60分钟前
|
||||
|
||||
@@ -180,14 +190,17 @@ metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qq
|
||||
}
|
||||
```
|
||||
|
||||
### 删除所有公告
|
||||
### 受确认保护的删除流程
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/guilds/123456/announces/all"
|
||||
}
|
||||
```
|
||||
删除类 QQ API 不作为普通速查示例暴露。若用户明确要求删除资源,先读取并复述目标对象,确认后再调用 `qqbot_channel_api`:`method` 设为 `"DELETE"`,`confirmed` 设为 `true`,`path` 使用已确认对象对应的资源路径。
|
||||
|
||||
| 删除对象 | 已确认后使用的 `path` | 额外要求 |
|
||||
| -------- | ------------------------------------------------ | ---------------------------------------- |
|
||||
| 子频道 | `/channels/{channel_id}` | 确认子频道 ID 和名称 |
|
||||
| 单条公告 | `/guilds/{guild_id}/announces/{message_id}` | 确认公告 ID |
|
||||
| 全部公告 | `/guilds/{guild_id}/announces/all` | 用户再次确认后再传 `bulkConfirmed: true` |
|
||||
| 帖子 | `/channels/{channel_id}/threads/{thread_id}` | 确认帖子 ID、标题/作者 |
|
||||
| 日程 | `/channels/{channel_id}/schedules/{schedule_id}` | 确认日程 ID、名称/时间 |
|
||||
|
||||
---
|
||||
|
||||
@@ -222,7 +235,7 @@ metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qq
|
||||
|
||||
### 展示成员头像
|
||||
|
||||
成员详情返回的 `user.avatar` 是头像 URL,**必须使用 Markdown 图片语法展示**,让用户直接看到头像图片,而非纯文本链接:
|
||||
成员详情返回的 `user.avatar` 是头像 URL。默认只展示昵称、ID、加入时间等必要字段;当用户明确要求查看头像/图标或头像是当前任务的必要依据时,再用 Markdown 图片语法内联展示:
|
||||
|
||||
```
|
||||
成员信息:
|
||||
@@ -231,7 +244,7 @@ metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qq
|
||||

|
||||
```
|
||||
|
||||
> **禁止**将头像 URL 作为纯文本或超链接展示(如 `查看头像`),必须用 `` 语法内联显示。频道的 `icon` 字段同理。
|
||||
不要无关输出原始头像 URL 或把头像作为普通链接转发。频道的 `icon` 字段同理:仅在用户明确需要查看时展示。
|
||||
|
||||
---
|
||||
|
||||
@@ -255,8 +268,8 @@ metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qq
|
||||
3. **成员列表翻页**时可能返回重复成员,需按 `user.id` 去重
|
||||
4. **公告**的两种类型(消息公告和推荐子频道公告)会互相顶替
|
||||
5. **日程**的时间戳为毫秒级字符串
|
||||
6. **删除操作不可逆**,请谨慎使用
|
||||
6. **删除操作不可逆**,必须先确认精确目标并传 `confirmed: true`;批量删除需二次确认并传 `bulkConfirmed: true`
|
||||
7. **论坛操作**仅私域机器人可用
|
||||
8. **子频道分组**(type=4)的 `position` 必须 >= 2
|
||||
9. **日程操作**有频率限制:单个管理员每天 10 次,单个频道每天 100 次
|
||||
10. **头像/图标展示**:成员 `user.avatar` 和频道 `icon` 等图片 URL 必须使用 Markdown 图片语法 `` 展示,禁止作为纯文本或超链接展示
|
||||
10. **头像/图标展示**:成员 `user.avatar` 和频道 `icon` 等图片 URL 属于资料信息;默认总结必要字段,只在用户明确需要查看图片时用 Markdown 图片语法 `` 展示
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
|
||||
通过 `qqbot_channel_api` 工具代理请求,工具自动处理鉴权。
|
||||
|
||||
## 调用安全规则
|
||||
|
||||
- `POST`、`PUT`、`PATCH` 和 `DELETE` 会修改真实 QQ 资源。调用前确认用户明确授权了该操作。
|
||||
- 删除接口不可逆。删除前先用读取接口确认目标 ID、名称和范围,并把将要删除的对象复述给用户;`qqbot_channel_api` 要求 `confirmed: true` 才会执行 `DELETE`。
|
||||
- 批量删除 sentinel 必须二次确认并额外传 `bulkConfirmed: true`;不要把模糊表达自动扩展成“删除全部”。
|
||||
- 删除端点不作为普通 agent 速查路径列出。需要删除时,先用读取接口确认对象,再通过受确认保护的删除流程执行。
|
||||
- 成员资料和头像 URL 只用于当前请求;除非用户明确要求查看头像/图标,不要内联展示或转发这些图片 URL。
|
||||
|
||||
---
|
||||
|
||||
## 📌 通用说明
|
||||
@@ -346,9 +354,9 @@ interface Schedule {
|
||||
|
||||
---
|
||||
|
||||
### DELETE /channels/{channel_id} — 删除子频道
|
||||
### 删除子频道(破坏性操作)
|
||||
|
||||
> ⚠️ 不可逆!仅私域机器人可用
|
||||
> ⚠️ 不可逆!仅私域机器人可用。调用前必须确认具体子频道 ID、子频道名称和用户删除意图,并传 `confirmed: true`;不要按模糊名称猜测删除目标。确认后使用子频道资源路径 `/channels/{channel_id}`。
|
||||
|
||||
---
|
||||
|
||||
@@ -415,9 +423,9 @@ interface Schedule {
|
||||
|
||||
---
|
||||
|
||||
### DELETE /guilds/{guild_id}/announces/{message_id} — 删除公告
|
||||
### 删除公告(破坏性操作)
|
||||
|
||||
> `message_id` 设为 `all` 删除所有公告
|
||||
> 调用前必须确认具体公告 ID 并传 `confirmed: true`,确认后使用公告资源路径 `/guilds/{guild_id}/announces/{message_id}`。批量删除全部公告只能在用户明确要求并再次确认后使用 `/guilds/{guild_id}/announces/all`,并且必须额外传 `bulkConfirmed: true`。
|
||||
|
||||
---
|
||||
|
||||
@@ -453,9 +461,9 @@ interface Schedule {
|
||||
|
||||
---
|
||||
|
||||
### DELETE /channels/{channel_id}/threads/{thread_id} — 删除帖子
|
||||
### 删除帖子(破坏性操作)
|
||||
|
||||
> ⚠️ 不可逆!仅私域机器人可用
|
||||
> ⚠️ 不可逆!仅私域机器人可用。调用前必须确认具体帖子 ID、帖子标题/作者和用户删除意图,并传 `confirmed: true`。确认后使用帖子资源路径 `/channels/{channel_id}/threads/{thread_id}`。
|
||||
|
||||
---
|
||||
|
||||
@@ -516,6 +524,6 @@ interface Schedule {
|
||||
|
||||
---
|
||||
|
||||
### DELETE /channels/{channel_id}/schedules/{schedule_id} — 删除日程
|
||||
### 删除日程(破坏性操作)
|
||||
|
||||
> ⚠️ 不可逆!需要管理频道权限
|
||||
> ⚠️ 不可逆!需要管理频道权限。调用前必须确认具体日程 ID、日程名称/时间和用户删除意图,并传 `confirmed: true`。确认后使用日程资源路径 `/channels/{channel_id}/schedules/{schedule_id}`。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: qqbot-media
|
||||
description: QQBot rich media send and receive support. Use <qqmedia> tags to send image, voice, video, or file attachments, with the media type inferred from the file extension.
|
||||
description: QQBot rich media send and receive support. Use <qqmedia> tags only for explicit media send/view requests, treating inbound attachment paths as private current-conversation context.
|
||||
metadata: { "openclaw": { "emoji": "📸", "requires": { "config": ["channels.qqbot"] } } }
|
||||
---
|
||||
|
||||
@@ -22,8 +22,10 @@ metadata: { "openclaw": { "emoji": "📸", "requires": { "config": ["channels.qq
|
||||
|
||||
## 接收媒体
|
||||
|
||||
- 用户发来的**图片**自动下载到本地,路径在上下文【附件】中,可直接用 `<qqmedia>路径</qqmedia>` 回发
|
||||
- 用户发来的**语音**路径在上下文中;若有 STT 能力则优先转写
|
||||
- 用户发来的**图片**会由 QQBot 运行时下载到 OpenClaw 管理的 QQBot media 目录,路径只作为当前会话的附件上下文使用。
|
||||
- 用户发来的**语音**路径在上下文中;若有 STT 能力则优先转写。
|
||||
- 附件路径和远程 URL 可能包含用户私有内容。不要无关输出本地绝对路径,不要把附件转发到其他会话;只有用户明确要求回发、分析或转存该媒体时才使用。
|
||||
- 不承诺长期保留附件。若用户需要长期保存,说明应由用户自行保存或重新发送。
|
||||
|
||||
## 规则
|
||||
|
||||
@@ -35,3 +37,4 @@ metadata: { "openclaw": { "emoji": "📸", "requires": { "config": ["channels.qq
|
||||
6. 发送语音时不要重复语音中已朗读的文字
|
||||
7. 多个媒体用多个标签
|
||||
8. 以会话上下文中的能力说明为准(如未启用语音则不要发语音)
|
||||
9. 不要扫描或发送上下文之外的本地文件;只使用用户提供、工具生成,或明确位于受信 media 目录中的路径
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
---
|
||||
name: qqbot-remind
|
||||
description: QQBot scheduled reminders. Create, list, and cancel one-time or recurring reminders when a QQ conversation involves reminders, alarms, or scheduled tasks.
|
||||
description: QQBot scheduled reminders. Use only for explicit user requests to create, list, or cancel one-time or recurring QQ reminders; ask for missing time, content, or timezone before scheduling.
|
||||
metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqbot"] } } }
|
||||
---
|
||||
|
||||
# QQ Bot 定时提醒
|
||||
|
||||
## ⚠️ 强制规则
|
||||
## ⚠️ 意图规则
|
||||
|
||||
**当用户提到「提醒」「闹钟」「定时」「X分钟/小时后」「每天X点」「叫我」等任何涉及延时或定时的请求时,你必须调用工具,绝对不能只用自然语言回复说"好的,我会提醒你"!**
|
||||
只有当用户明确要求创建、查询或取消提醒/闹钟/定时任务时,才调用工具。闲聊、假设、解释提醒功能、讨论将来计划但未要求创建提醒时,不要调用工具。
|
||||
|
||||
你没有内存或后台线程,口头承诺"到时候提醒"是无效的——只有调用工具才能真正注册定时任务。
|
||||
如果用户确实要求提醒,你没有内存或后台线程,口头承诺"到时候提醒"是无效的——必须调用工具才能真正注册定时任务。时间、提醒内容、目标会话或时区不清楚时先追问;不要替用户猜测。
|
||||
|
||||
---
|
||||
|
||||
@@ -18,13 +18,14 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb
|
||||
|
||||
**第一步**:调用 `qqbot_remind` 工具,传入简单参数:
|
||||
|
||||
| 参数 | 说明 | 示例 |
|
||||
| --------- | -------------------------------------------- | ---------------------------------- |
|
||||
| `action` | 操作类型 | `"add"` / `"list"` / `"remove"` |
|
||||
| `content` | 提醒内容 | `"喝水"` |
|
||||
| `to` | 目标地址(可选,系统自动获取,通常无需填写) | — |
|
||||
| `time` | 时间(相对时间或 cron 表达式) | `"5m"` / `"1h30m"` / `"0 8 * * *"` |
|
||||
| `jobId` | 任务 ID(仅 remove) | `"xxx"` |
|
||||
| 参数 | 说明 | 示例 |
|
||||
| ---------- | -------------------------------------------- | ------------------------------------------- |
|
||||
| `action` | 操作类型 | `"add"` / `"list"` / `"remove"` |
|
||||
| `content` | 提醒内容 | `"喝水"` |
|
||||
| `to` | 目标地址(可选,系统自动获取,通常无需填写) | — |
|
||||
| `time` | 时间(相对时间或 cron 表达式) | `"5m"` / `"1h30m"` / `"0 8 * * *"` |
|
||||
| `timezone` | IANA 时区(周期提醒建议明确传入) | `"Asia/Shanghai"` / `"America/Los_Angeles"` |
|
||||
| `jobId` | 任务 ID(仅 remove) | `"xxx"` |
|
||||
|
||||
**第二步**:根据 `qqbot_remind` 的返回结果,回复用户。`qqbot_remind` 会直接创建、查询或取消 Gateway cron 任务;成功后不要再调用 `cron` 工具。
|
||||
|
||||
@@ -92,7 +93,7 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb
|
||||
"action": "add",
|
||||
"job": {
|
||||
"name": "{任务名}",
|
||||
"schedule": { "kind": "cron", "expr": "0 8 * * *", "tz": "Asia/Shanghai" },
|
||||
"schedule": { "kind": "cron", "expr": "0 8 * * *", "tz": "{用户确认的 IANA 时区}" },
|
||||
"sessionTarget": "isolated",
|
||||
"wakeMode": "now",
|
||||
"payload": {
|
||||
@@ -124,7 +125,7 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb
|
||||
| 每周末上午10点 | `"0 10 * * 0,6"` |
|
||||
| 每小时整点 | `"0 * * * *"` |
|
||||
|
||||
> 周期提醒必须加 `"tz": "Asia/Shanghai"`。
|
||||
> 周期提醒应使用用户明确提供、用户资料/会话中可信可得,或用户确认过的 IANA 时区。无法判断时先追问;不要把所有用户都假定在同一时区。
|
||||
|
||||
---
|
||||
|
||||
@@ -141,7 +142,7 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb
|
||||
| "修改提醒时间" | `remove` → `add` | — |
|
||||
| "提醒我"(无时间) | **需追问** | — |
|
||||
|
||||
纯相对时间("5分钟后"、"1小时后")可直接计算,无需确认。时间模糊或缺失时需追问。
|
||||
纯相对时间("5分钟后"、"1小时后")可直接计算,无需确认。时间、日期、周期、内容或时区模糊/缺失时需追问。周期提醒在回复中说明解释后的本地时间和时区。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export function registerChannelTool(api: OpenClawPluginApi): void {
|
||||
label: "QQBot Channel API",
|
||||
description:
|
||||
"Authenticated HTTP proxy for QQ Open Platform channel APIs. " +
|
||||
"Use write and delete endpoints only after explicit user intent; DELETE requires confirmed=true, and bulk deletes require bulkConfirmed=true after confirming the exact target. " +
|
||||
"Common endpoints: " +
|
||||
"list guilds GET /users/@me/guilds | " +
|
||||
"list channels GET /guilds/{guild_id}/channels | " +
|
||||
|
||||
@@ -52,12 +52,13 @@ export function createRemindTool(
|
||||
label: "QQBot Reminder",
|
||||
description:
|
||||
"Create, list, and remove QQ reminders. " +
|
||||
"Use only for explicit user requests, and ask when reminder content, schedule, or timezone is ambiguous. " +
|
||||
"This tool schedules Gateway cron jobs directly; do not call the cron tool after it succeeds.\n" +
|
||||
"Create: action=add, content=message, time=schedule (to is optional, " +
|
||||
"resolved automatically from the current conversation)\n" +
|
||||
"List: action=list\n" +
|
||||
"Remove: action=remove, jobId=job id from list\n" +
|
||||
'Time examples: "5m", "1h", "0 8 * * *"',
|
||||
'Time examples: "5m", "1h", "0 8 * * *"; include timezone for recurring cron reminders when known.',
|
||||
parameters: RemindSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const ctx = getRequestContext();
|
||||
|
||||
@@ -139,4 +139,113 @@ describe("executeChannelApi", () => {
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("requires confirmation before DELETE requests", async () => {
|
||||
const result = await executeChannelApi(
|
||||
{ method: "DELETE", path: "/channels/123" },
|
||||
{ accessToken: "token-1" },
|
||||
);
|
||||
|
||||
expect(result.details).toEqual({
|
||||
error:
|
||||
"DELETE requests require confirmed=true after the user confirms the exact QQ resource.",
|
||||
path: "/channels/123",
|
||||
});
|
||||
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows confirmed DELETE requests", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: new Response(null, { status: 204, statusText: "No Content" }),
|
||||
release,
|
||||
});
|
||||
|
||||
const result = await executeChannelApi(
|
||||
{ method: "DELETE", path: "/channels/123", confirmed: true },
|
||||
{ accessToken: "token-1" },
|
||||
);
|
||||
|
||||
expect(result.details).toEqual({
|
||||
success: true,
|
||||
status: 204,
|
||||
path: "/channels/123",
|
||||
});
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://api.sgroup.qq.com/channels/123",
|
||||
init: expect.objectContaining({ method: "DELETE" }),
|
||||
}),
|
||||
);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("requires separate confirmation before bulk announcement deletes", async () => {
|
||||
const result = await executeChannelApi(
|
||||
{ method: "DELETE", path: "/guilds/123/announces/all", confirmed: true },
|
||||
{ accessToken: "token-1" },
|
||||
);
|
||||
|
||||
expect(result.details).toEqual({
|
||||
error:
|
||||
"Deleting all announcements requires bulkConfirmed=true after a separate bulk-delete confirmation.",
|
||||
path: "/guilds/123/announces/all",
|
||||
});
|
||||
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires bulk confirmation for encoded all announcement sentinel", async () => {
|
||||
const result = await executeChannelApi(
|
||||
{ method: "DELETE", path: "/guilds/123/announces/%61%6c%6c", confirmed: true },
|
||||
{ accessToken: "token-1" },
|
||||
);
|
||||
|
||||
expect(result.details).toEqual({
|
||||
error:
|
||||
"Deleting all announcements requires bulkConfirmed=true after a separate bulk-delete confirmation.",
|
||||
path: "/guilds/123/announces/%61%6c%6c",
|
||||
});
|
||||
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects encoded path separators before fetch", async () => {
|
||||
const result = await executeChannelApi(
|
||||
{ method: "GET", path: "/guilds/123%2fannounces" },
|
||||
{ accessToken: "token-1" },
|
||||
);
|
||||
|
||||
expect(result.details).toEqual({ error: "path contains encoded path separators" });
|
||||
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows bulk announcement deletes after both confirmations", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: new Response(null, { status: 204, statusText: "No Content" }),
|
||||
release,
|
||||
});
|
||||
|
||||
const result = await executeChannelApi(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: "/guilds/123/announces/all",
|
||||
confirmed: true,
|
||||
bulkConfirmed: true,
|
||||
},
|
||||
{ accessToken: "token-1" },
|
||||
);
|
||||
|
||||
expect(result.details).toEqual({
|
||||
success: true,
|
||||
status: 204,
|
||||
path: "/guilds/123/announces/all",
|
||||
});
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://api.sgroup.qq.com/guilds/123/announces/all",
|
||||
init: expect.objectContaining({ method: "DELETE" }),
|
||||
}),
|
||||
);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface ChannelApiParams {
|
||||
path: string;
|
||||
body?: Record<string, unknown>;
|
||||
query?: Record<string, string>;
|
||||
confirmed?: boolean;
|
||||
bulkConfirmed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,7 +49,9 @@ export const ChannelApiSchema = {
|
||||
properties: {
|
||||
method: {
|
||||
type: "string",
|
||||
description: "HTTP method. Allowed values: GET, POST, PUT, PATCH, DELETE.",
|
||||
description:
|
||||
"HTTP method. Allowed values: GET, POST, PUT, PATCH, DELETE. " +
|
||||
"Use DELETE and other mutating methods only after explicit user intent and target confirmation.",
|
||||
enum: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
},
|
||||
path: {
|
||||
@@ -59,7 +63,8 @@ export const ChannelApiSchema = {
|
||||
body: {
|
||||
type: "object",
|
||||
description:
|
||||
"JSON request body for POST/PUT/PATCH requests. GET/DELETE usually do not need it.",
|
||||
"JSON request body for POST/PUT/PATCH requests. GET/DELETE usually do not need it. " +
|
||||
"For write requests, include only fields the user explicitly asked to change.",
|
||||
},
|
||||
query: {
|
||||
type: "object",
|
||||
@@ -68,6 +73,16 @@ export const ChannelApiSchema = {
|
||||
'For example, { "limit": "100", "after": "0" } becomes ?limit=100&after=0.',
|
||||
additionalProperties: { type: "string" },
|
||||
},
|
||||
confirmed: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Required true for DELETE requests after the user confirms the exact QQ resource to delete.",
|
||||
},
|
||||
bulkConfirmed: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Required true in addition to confirmed for bulk DELETE requests such as deleting all announcements.",
|
||||
},
|
||||
},
|
||||
required: ["method", "path"],
|
||||
} as const;
|
||||
@@ -107,6 +122,56 @@ function validatePath(path: string): string | null {
|
||||
if (!/^\/[a-zA-Z0-9\-._~:@!$&'()*+,;=/%]+$/.test(path) && path !== "/") {
|
||||
return "path contains unsupported characters";
|
||||
}
|
||||
for (const segment of path.split("/").slice(1)) {
|
||||
let decodedSegment: string;
|
||||
try {
|
||||
decodedSegment = decodeURIComponent(segment);
|
||||
} catch {
|
||||
return "path contains invalid percent encoding";
|
||||
}
|
||||
if (decodedSegment.includes("/") || decodedSegment.includes("\\")) {
|
||||
return "path contains encoded path separators";
|
||||
}
|
||||
if (decodedSegment === "." || decodedSegment === "..") {
|
||||
return "path must not contain . or .. segments";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function decodePathSegments(path: string): string[] | null {
|
||||
try {
|
||||
return path
|
||||
.replace(/\/+$/, "")
|
||||
.split("/")
|
||||
.slice(1)
|
||||
.map((segment) => decodeURIComponent(segment));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isBulkAnnouncementDeletePath(path: string): boolean {
|
||||
const segments = decodePathSegments(path);
|
||||
return Boolean(
|
||||
segments &&
|
||||
segments.length === 4 &&
|
||||
segments[0]?.toLowerCase() === "guilds" &&
|
||||
segments[2]?.toLowerCase() === "announces" &&
|
||||
segments[3]?.toLowerCase() === "all",
|
||||
);
|
||||
}
|
||||
|
||||
function validateDeleteConfirmation(params: ChannelApiParams): string | null {
|
||||
if (params.method.toUpperCase() !== "DELETE") {
|
||||
return null;
|
||||
}
|
||||
if (!params.confirmed) {
|
||||
return "DELETE requests require confirmed=true after the user confirms the exact QQ resource.";
|
||||
}
|
||||
if (isBulkAnnouncementDeletePath(params.path) && !params.bulkConfirmed) {
|
||||
return "Deleting all announcements requires bulkConfirmed=true after a separate bulk-delete confirmation.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -156,6 +221,11 @@ export async function executeChannelApi(
|
||||
return json({ error: pathError });
|
||||
}
|
||||
|
||||
const confirmationError = validateDeleteConfirmation({ ...params, method });
|
||||
if (confirmationError) {
|
||||
return json({ error: confirmationError, path: params.path });
|
||||
}
|
||||
|
||||
if (
|
||||
(method === "GET" || method === "DELETE") &&
|
||||
params.body &&
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface RemindParams {
|
||||
jobId?: string;
|
||||
}
|
||||
|
||||
const QQBOT_DEFAULT_REMINDER_TIMEZONE = "Asia/Shanghai";
|
||||
|
||||
/**
|
||||
* Context supplied by the bridge layer so the engine can remain free of
|
||||
* framework / AsyncLocalStorage dependencies. `fallbackTo` and
|
||||
@@ -69,7 +71,7 @@ export const RemindSchema = {
|
||||
action: {
|
||||
type: "string",
|
||||
description:
|
||||
"Action type. add=create a reminder, list=show reminders, remove=delete a reminder.",
|
||||
"Action type. add=create a reminder only after explicit user request, list=show reminders, remove=delete a reminder by confirmed job ID.",
|
||||
enum: ["add", "list", "remove"],
|
||||
},
|
||||
content: {
|
||||
@@ -91,11 +93,12 @@ export const RemindSchema = {
|
||||
'1. Relative time, for example "5m", "1h", "1h30m", or "2d"\n' +
|
||||
'2. Cron expression, for example "0 8 * * *" or "0 9 * * 1-5"\n' +
|
||||
"Values containing spaces are treated as cron expressions; everything else is treated as a one-shot relative delay.\n" +
|
||||
"Required when action=add.",
|
||||
"Required when action=add. Ask for clarification before scheduling if the time is ambiguous.",
|
||||
},
|
||||
timezone: {
|
||||
type: "string",
|
||||
description: 'Timezone used for cron reminders. Defaults to "Asia/Shanghai".',
|
||||
description:
|
||||
"Optional IANA timezone used for cron reminders. Include it when the user provides or confirms a timezone; if omitted, QQBot preserves its existing default timezone.",
|
||||
},
|
||||
name: {
|
||||
type: "string",
|
||||
@@ -216,7 +219,7 @@ function buildOnceJob(params: RemindParams, atMs: number, to: string, accountId:
|
||||
function buildCronJob(params: RemindParams, to: string, accountId: string) {
|
||||
const content = params.content!;
|
||||
const name = params.name || generateJobName(content);
|
||||
const tz = params.timezone || "Asia/Shanghai";
|
||||
const tz = params.timezone || QQBOT_DEFAULT_REMINDER_TIMEZONE;
|
||||
return {
|
||||
action: "add" as const,
|
||||
job: {
|
||||
@@ -308,7 +311,7 @@ export function prepareRemindCronAction(
|
||||
ok: true,
|
||||
action: "add",
|
||||
cronAction: buildCronJob(params, resolvedTo, resolvedAccountId),
|
||||
summary: `⏰ Recurring reminder: "${params.content}" (${params.time}, tz=${params.timezone || "Asia/Shanghai"})`,
|
||||
summary: `⏰ Recurring reminder: "${params.content}" (${params.time}, tz=${params.timezone || QQBOT_DEFAULT_REMINDER_TIMEZONE})`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user