fix(qqbot): tighten bundled skill guardrails (#98032)

This commit is contained in:
Patrick Erichsen
2026-06-29 22:48:52 -07:00
committed by GitHub
parent 85ee71223f
commit 56c2d637d9
9 changed files with 283 additions and 74 deletions
+54 -41
View File
@@ -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 | 否 | 请求体 JSONPOST/PUT/PATCH 使用) |
| `query` | object | 否 | URL 查询参数键值对,值为字符串类型 |
| 参数 | 类型 | 必填 | 说明 |
| --------------- | ------- | ---- | ---------------------------------------------------------------------------- |
| `method` | string | 是 | HTTP 方法:`GET`, `POST`, `PUT`, `PATCH`, `DELETE` |
| `path` | string | 是 | API 路径(不含域名),如 `/guilds/{guild_id}/channels`,需替换占位符为实际值 |
| `body` | object | 否 | 请求体 JSONPOST/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
![头像]({user.avatar})
```
> **禁止**将头像 URL 作为纯文本或超链接展示(如 `查看头像`),必须用 `![描述](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 图片语法 `![描述](URL)` 展示,禁止作为纯文本或超链接展示
10. **头像/图标展示**:成员 `user.avatar` 和频道 `icon` 等图片 URL 属于资料信息;默认总结必要字段,只在用户明确需要查看图片时用 Markdown 图片语法 `![描述](URL)` 展示
@@ -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}`。
+6 -3
View File
@@ -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 目录中的路径
+15 -14
View File
@@ -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 | " +
+2 -1
View File
@@ -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})`,
};
}