# 插件开发教程

本文档用于说明 Python 插件的基本结构、必要入口函数、`botapi` 能力，以及常见开发写法。

---

## 1. 插件是什么

插件是一个 Python 文件，由平台加载后，根据不同事件调用对应入口函数。

插件主要处理：

- 群聊消息
- 私聊消息
- 按钮事件
- 群成员入群、退群、入群申请事件
- 发送文字、图片、卡片、文件、语音、视频等
- 调用平台 Bot API
- 请求外部接口
- 读取 URL 参数
- 输出插件日志

---

## 2. 最小插件模板

新建插件默认生成下面 7 个入口函数。`has_been_loaded`、`group`、`C2C`、`button` 是基础入口；`member_add`、`member_remove`、`group_join_request` 是可选的群成员事件入口，插件不需要处理对应事件时可以不写。

```python
def has_been_loaded():
    return {
        "plugin_info": {
            "name": "测试插件2",
            "author": "1347118324",
            "version": "0.0.2",
            "desc": "免费插件，请完善这里的说明",
            "command_list": [
                "/2"
            ]
        }
    }

async def group(botapi, app_id, group_id, sender_id, content):
    return

async def C2C(botapi, app_id, group_id, sender_id, content):
    return

async def button(botapi, app_id, group_id, sender_id, content):
    return

async def member_add(botapi, app_id, group_id, user_id):
    return

async def member_remove(botapi, app_id, group_id, user_id):
    return

async def group_join_request(botapi, app_id, group_id, user_id, request):
    return
```

---

## 3. 必要函数说明

### 3.1 has_been_loaded

```python
def has_been_loaded():
    return {
        "plugin_info": {
            "name": "插件名称",
            "author": "作者QQ或名称",
            "version": "版本号",
            "desc": "插件说明",
            "command_list": ["/命令"]
        }
    }
```

作用：返回插件基本信息。

必须包含：

| 字段 | 说明 |
|---|---|
| `name` | 插件名称 |
| `author` | 作者 |
| `version` | 版本号 |
| `desc` | 插件说明 |
| `command_list` | 插件命令列表 |

示例：

```python
def has_been_loaded():
    return {
        "plugin_info": {
            "name": "天气查询",
            "author": "10001",
            "version": "1.0.0",
            "desc": "输入 /天气 城市 查询天气",
            "command_list": ["/天气"]
        }
    }
```

---

### 3.2 group

```python
async def group(botapi, app_id, group_id, sender_id, content):
    return
```

作用：处理群聊消息。

参数说明：

| 参数 | 说明 |
|---|---|
| `botapi` | 平台提供的插件 API 对象 |
| `app_id` | 应用 ID |
| `group_id` | 群号/群 ID |
| `sender_id` | 发送者 ID |
| `content` | 消息内容 |

示例：

```python
async def group(botapi, app_id, group_id, sender_id, content):
    if content == "/hello":
        await botapi.textmsg("群里好！")
    return
```

---

### 3.3 C2C

```python
async def C2C(botapi, app_id, group_id, sender_id, content):
    return
```

作用：处理私聊消息。

示例：

```python
async def C2C(botapi, app_id, group_id, sender_id, content):
    if content == "/hello":
        await botapi.textmsg("你好，这是私聊回复。")
    return
```

> 注意：函数名必须是 `C2C`，大小写不能写错。

---

### 3.4 button

```python
async def button(botapi, app_id, group_id, sender_id, content):
    return
```

作用：处理按钮事件。

示例：

```python
async def button(botapi, app_id, group_id, sender_id, content):
    await botapi.textmsg("你点击了按钮：" + str(content))
    return
```

---

### 3.5 member_add

```python
async def member_add(botapi, app_id, group_id, user_id):
    return
```

作用：群成员加入时触发。

| 参数 | 说明 |
|---|---|
| `botapi` | 平台提供的插件 API 对象 |
| `app_id` | 当前机器人应用 ID |
| `group_id` | 事件中的 `group_openid` |
| `user_id` | 新成员的 `member_openid` |

示例：

```python
async def member_add(botapi, app_id, group_id, user_id):
    await botapi.textmsg("欢迎新成员加入")
    return
```

---

### 3.6 member_remove

```python
async def member_remove(botapi, app_id, group_id, user_id):
    return
```

作用：群成员退出或被移出时触发。参数含义与 `member_add` 相同。

示例：

```python
async def member_remove(botapi, app_id, group_id, user_id):
    await botapi.log("成员已退出：" + user_id)
    return
```

---

### 3.7 group_join_request

```python
async def group_join_request(botapi, app_id, group_id, user_id, request):
    return
```

作用：收到 `GROUP_JOIN_REQUEST` 入群申请事件时触发。机器人必须是群管理员，且已在开放平台订阅对应群事件。

| 参数 | 说明 |
|---|---|
| `group_id` | 事件中的 `group_openid` |
| `user_id` | 申请人的 `member_openid`，是平台 OpenID，不是数字 QQ |
| `request` | 入群申请原始字段，含 `join_request_id`、`apply_at`、`username`、`apply_source`、`verify_info` 等 |

示例：

```python
async def group_join_request(botapi, app_id, group_id, user_id, request):
    request_id = request.get("join_request_id", "")
    apply_at = request.get("apply_at", "")
    await botapi.log(f"收到入群申请：{user_id}，申请时间：{apply_at}")
    # 根据业务规则决定是否审批，不能省略 request_id。
    # await botapi.approve_join_request(user_id, request_id)
    return
```

---

## 4. async / await 规则

`group`、`C2C`、`button`、`member_add`、`member_remove`、`group_join_request` 都必须使用异步函数写法：

```python
async def group(...):
    return
```

调用 `botapi` 的异步方法时，前面要加 `await`。

正确：

```python
await botapi.textmsg("你好")
```

错误：

```python
botapi.textmsg("你好")
```

---

## 5. botapi 方法列表

`botapi` 是平台提供给插件使用的 API 对象。

可用方法如下：

| 方法 | 说明 |
|---|---|
| `textmsg(msg)` | 发送文字消息 |
| `pic_msg(url, text="")` | 发送图片消息 |
| `pic_card(url, wai, bigt, littlet)` | 发送图片大卡片 |
| `small_pic_card(wai, title, meta_desc, img, link)` | 发送缩略图小卡片 |
| `text_card(msg, url_list=None, wai="")` | 发送文字卡片 |
| `bot(address, method, jdata)` | 调用底层 Bot API |
| `markdown_native(content)` | 发送 Markdown 消息 |
| `markdown_native_with_button(content, button)` | 发送带按钮的 Markdown 消息 |
| `video_msg(url)` | 发送视频 |
| `file_msg(url)` | 发送文件 |
| `audio_msg(url)` | 发送音频 |
| `pic_for_fileinfo(fileinfo, content="")` | 复用已上传过的图片 file_info |
| `video_for_fileinfo(fileinfo)` | 复用已上传过的视频 file_info |
| `mute_member(member_openid, seconds)` | 按秒数禁言当前群成员 |
| `unmute_member(member_openid)` | 解除当前群成员禁言 |
| `set_member_mute(member_openid, mute_expire_at, op="add")` | 用 RFC3339 到期时间设置禁言；`op` 支持 `add`、`update`、`del` |
| `get_join_requests(cursor="", limit=20)` | 查询当前群待处理入群申请 |
| `approve_join_request(member_openid, join_request_id="")` | 通过当前群的入群申请 |
| `decline_join_request(member_openid, join_request_id="", reject_reason="", add_to_member_blacklist=False)` | 拒绝当前群的入群申请 |
| `recall_msg(message_id)` | 撤回机器人发过的消息 |
| `getbotinfo()` | 获取机器人自身信息 |
| `curl_to_url(url, method="GET", headers=None, data=None)` | 请求外部 URL |
| `get_body()` | 获取原始消息体 |
| `get_senderqq()` | 获取当前消息发送者QQ，返回文本 |
| `get_url_params(url, name)` | 从 URL 里提取指定参数 |
| `get_params(name="", default="")` | 读取插件管理页保存的设置参数 |
| `log(text)` | 输出插件日志 |
| `is_owner()` | 当前触发者是否为主人（同步方法，不需 await） |

---

## 5.1 API 速查表

> 除 `is_owner()` 外，下面所有 `botapi` 方法都必须使用 `await`。

| 分类 | 方法 | 返回 |
|---|---|---|
| 文本 | `await botapi.textmsg(msg)` | QQ 接口返回的 JSON 字符串或结果 |
| 图片 | `await botapi.pic_msg(url, text="")` | 上传并发送图片后的结果 |
| 大图卡片 | `await botapi.pic_card(url, wai, bigt, littlet)` | ark 卡片发送结果 |
| 小图卡片 | `await botapi.small_pic_card(wai="", title="", meta_desc="", img="", link="")` | ark 卡片发送结果 |
| 文字卡片 | `await botapi.text_card(msg, url_list=None, wai="")` | ark 卡片发送结果 |
| Markdown | `await botapi.markdown_native(content)` | Markdown 消息发送结果 |
| Markdown 按钮 | `await botapi.markdown_native_with_button(content, button)` | Markdown + keyboard 发送结果 |
| 视频 | `await botapi.video_msg(url)` | 上传并发送视频后的结果 |
| 文件 | `await botapi.file_msg(url)` | 上传并发送文件后的结果 |
| 语音 | `await botapi.audio_msg(url)` | 上传并发送语音后的结果 |
| 图片 file_info | `await botapi.pic_for_fileinfo(fileinfo, content="")` | 复用图片 file_info 的发送结果 |
| 视频 file_info | `await botapi.video_for_fileinfo(fileinfo)` | 复用视频 file_info 的发送结果 |
| 禁言 | `await botapi.mute_member(member_openid, seconds)` | 当前群成员禁言结果 |
| 解除禁言 | `await botapi.unmute_member(member_openid)` | 当前群成员解除禁言结果 |
| 设置禁言 | `await botapi.set_member_mute(member_openid, mute_expire_at, op="add")` | 当前群成员禁言状态结果 |
| 入群申请列表 | `await botapi.get_join_requests(cursor="", limit=20)` | 当前群待处理申请 JSON |
| 通过申请 | `await botapi.approve_join_request(member_openid, join_request_id="")` | 当前群入群审批结果 |
| 拒绝申请 | `await botapi.decline_join_request(member_openid, join_request_id="", reject_reason="", add_to_member_blacklist=False)` | 当前群入群审批结果 |
| 撤回 | `await botapi.recall_msg(message_id)` | DELETE 接口返回结果 |
| 机器人信息 | `await botapi.getbotinfo()` | `/users/@me` 的原始 JSON 字符串 |
| 底层接口 | `await botapi.bot(address, method, jdata)` | QQ Bot API 原始 JSON 字符串 |
| 外部请求 | `await botapi.curl_to_url(url, method="GET", headers=None, data=None)` | 平台 curl 结果，一般是 dict |
| 原始事件 | `await botapi.get_body()` | 当前消息事件 body，一般是 dict |
| 发送者QQ | `await botapi.get_senderqq()` | QQ文本；未获取到返回空字符串 |
| URL 参数 | `await botapi.get_url_params(url, name)` | 参数值字符串，不存在返回空字符串 |
| 插件设置 | `await botapi.get_params(name="", default="")` | 不传 name 返回 dict；传 name 返回对应值 |
| 日志 | `await botapi.log(text)` | 写入系统日志，无需依赖返回值 |
| 主人判断 | `botapi.is_owner()` | bool，同步方法，不要 await |

---

## 5.2 返回值说明

发送类 API 通常返回 QQ 接口返回内容，常见是 JSON 字符串。不同消息类型和 QQ 接口状态返回字段可能不同，所以建议按字符串或 JSON 兼容处理。

示例：

```python
import json

ret = await botapi.textmsg("测试消息")
try:
    data = json.loads(ret) if isinstance(ret, str) else ret
    message_id = data.get("id") or data.get("message_id")
except Exception:
    message_id = ""
```

说明：

- 普通发送成功后可忽略返回值
- 需要撤回时，应从发送结果里取 `id` 或 `message_id`
- `curl_to_url` 返回平台 curl 封装结果，通常包含 `status`、`data`、`error`、`time` 等字段，但不要求每次都检查
- `get_body()` 返回当前事件原始结构，可用于读取平台没有单独传入的字段

---

## 5.3 权限判断 is_owner

```python
botapi.is_owner()
```

作用：判断当前消息的触发者是否为该机器人 `app_id` 的「主人」。

返回值：

| 返回 | 含义 |
|---|---|
| `True` | 触发者就是主人 |
| `False` | 不是主人（或 sid/appid 为空时也返回 False） |

注意：

- 这是同步方法，调用时**不需要 `await`**
- 底层走 `data` 表的 `owner` 字段对比，与内置「后端状态」里的主人识别一致

示例：

```python
async def group(botapi, app_id, group_id, sender_id, content):
    if content == "/管理面板":
        if botapi.is_owner():
            await botapi.textmsg("欢迎主人，已进入管理面板")
        else:
            await botapi.textmsg("此命令仅主人可用")
    return
```

常见用法：

```python
async def C2C(botapi, app_id, group_id, sender_id, content):
    if content == "/重启插件":
        if not botapi.is_owner():
            return  # 非主人静默忽略
        await botapi.textmsg("插件已重启")
    return
```

---

## 5.3.1 获取当前发送者QQ get_senderqq

```python
sender_qq = await botapi.get_senderqq()
if sender_qq:
    await botapi.textmsg(f"当前发送者QQ：{sender_qq}")
```

该方法返回文本型 QQ。平台会先按当前机器人 appid 和发送者 openid 查询 `openid_bind`；没有记录时使用当前消息的 `msg_idx` 现场获取，成功后自动写入绑定表。获取失败时返回空字符串。

---

## 5.4 插件设置参数 get_params

插件开发页的“设置页面设计器”可以配置输入框、密码框、多行文本、数字输入、下拉选择、开关等字段。用户在插件管理页为某个机器人保存配置后，插件内通过 `get_params` 读取。

### 读取全部参数

```python
params = await botapi.get_params()
api_key = params.get("api_key", "")
enabled = params.get("enabled", False)
```

### 读取单个参数

```python
api_key = await botapi.get_params("api_key")
region = await botapi.get_params("region", "default")
```

参数说明：

| 参数 | 说明 |
|---|---|
| `name` | 参数名。为空时返回当前机器人对此插件的全部设置字典 |
| `default` | 当参数不存在时返回的默认值 |

规则：

- 参数按 `appid + plugin_localname` 独立保存，不同机器人互不影响
- 开关字段返回布尔值，数字字段返回数字或空字符串，文本类字段返回字符串
- 读取不到配置时返回 `{}` 或 `default`
- `get_params` 会有短时间缓存，刚保存后通常很快生效

完整示例：

```python
def has_been_loaded():
    return {
        "plugin_info": {
            "name": "参数示例",
            "author": "10001",
            "version": "1.0.0",
            "desc": "演示读取插件设置参数",
            "command_list": ["/配置"]
        }
    }

async def group(botapi, app_id, group_id, sender_id, content):
    if content == "/配置":
        keyword = await botapi.get_params("keyword", "未设置")
        enabled = await botapi.get_params("enabled", False)
        await botapi.textmsg(f"keyword={keyword}\nenabled={enabled}")
    return

async def C2C(botapi, app_id, group_id, sender_id, content):
    return

async def button(botapi, app_id, group_id, sender_id, content):
    return
```

---

## 5.5 其他扩展方法

### small_pic_card【缩略图卡片】

```python
await botapi.small_pic_card(wai, title, meta_desc, img, link)
```

| 参数 | 说明 |
|---|---|
| `wai` | 外显/提示文字 |
| `title` | 卡片大标题 |
| `meta_desc` | 卡片描述文字 |
| `img` | 缩略图地址 |
| `link` | 点击跳转链接 |

示例：

```python
await botapi.small_pic_card(
    "点我查看详情",
    "今日头条",
    "AI 领域重大突破",
    "https://example.com/cover.jpg",
    "https://news.example.com/today"
)
```

---

### pic_for_fileinfo 、 video_for_fileinfo【复用已上传媒体】

腾讯主动/被动发图发视频时会给出一个 `file_info` 字符串，同一帐号后续可以直接复用这个 `file_info` 二次发送，不需要重新上传。

```python
await botapi.pic_for_fileinfo(fileinfo, content="")
await botapi.video_for_fileinfo(fileinfo)
```

示例（同一图片多次发出）：

```python
async def C2C(botapi, app_id, group_id, sender_id, content):
    if content == "/复用图":
        # 假设之前调 pic_msg 得到过一个 fileinfo
        fi = "abc..."
        await botapi.pic_for_fileinfo(fi, "同一张图的另一句说明")
    return
```

---

### recall_msg【撤回消息】

```python
await botapi.recall_msg(message_id)
```

- `message_id` 是腾讯返回的消息唯一 id，发送返回中取得
- 只能撤回机器人自己发过的消息

示例：

```python
async def C2C(botapi, app_id, group_id, sender_id, content):
    if content == "/撤回最后一条":
        ret = await botapi.textmsg("这条马上会被撤回")
        # ret 是 JSON 字符串，里面 id 字段就是 message_id
        import json
        try:
            mid = json.loads(ret).get("id")
            if mid:
                await botapi.recall_msg(mid)
        except Exception:
            pass
    return
```

---

### getbotinfo【获取机器人自身信息】

```python
info_json = await botapi.getbotinfo()
```

返回是腾讯 `/users/@me` 接口的原始 JSON 字符串，含机器人昵称、头像、`share_url` 等字段。

示例：

```python
async def C2C(botapi, app_id, group_id, sender_id, content):
    if content == "/机器人资料":
        import json
        try:
            info = json.loads(await botapi.getbotinfo())
            await botapi.textmsg(
                f"昵称：{info.get('username','')}\n"
                f"头像：{info.get('avatar','')}"
            )
        except Exception as e:
            await botapi.textmsg("资料获取失败")
    return
```

---

### 群禁言与入群审批【群管理员能力】

以下方法只作用于当前事件所属群，成员参数必须填写 QQ 平台 OpenID（例如 `{{user_id}}`），不能填写数字 QQ。机器人需要具备群管理员权限。

```python
# 禁言当前消息发送者 10 分钟
await botapi.mute_member(sender_id, 600)

# 解除禁言
await botapi.unmute_member(sender_id)

# 收到 group_join_request 事件后，按申请 ID 通过或拒绝
await botapi.approve_join_request(user_id, request["join_request_id"])
await botapi.decline_join_request(user_id, request["join_request_id"], "暂不符合入群要求")
```

`set_member_mute` 的 `mute_expire_at` 必须是 RFC3339 时间，例如 `2026-08-10T12:30:00Z`。`op="del"` 时会立即解除禁言。`get_join_requests` 可分页读取当前群待处理申请；审批时优先传入事件中的 `join_request_id`，避免处理到其他申请。

---

## 6. 发送文字消息

```python
ret = await botapi.textmsg("你好，这是插件回复")
```

说明：

- `msg` 会作为普通文本发送
- 群聊场景下平台会自动处理必要的回复字段
- 返回值一般是 QQ 接口返回内容；不需要撤回时可以忽略

完整示例：

```python
async def C2C(botapi, app_id, group_id, sender_id, content):
    if content == "/hi":
        await botapi.textmsg("Hi，我是插件。")
    return
```

---

## 7. 发送图片

```python
ret = await botapi.pic_msg("https://example.com/a.jpg", "图片说明")
```

参数：

| 参数 | 说明 |
|---|---|
| `url` | 图片地址 |
| `text` | 可选文字说明 |

说明：

- 图片地址必须是机器人侧可以访问的 `http/https` 地址
- 平台会先上传图片，再发送图片消息
- 同一图片可能会命中平台上传缓存
- 如果后续要复用图片，可从返回结果里取 `file_info` 后使用 `pic_for_fileinfo`

示例：

```python
async def C2C(botapi, app_id, group_id, sender_id, content):
    if content == "/图":
        await botapi.pic_msg("https://example.com/test.jpg", "图片来了")
    return
```

---

## 8. 发送图片卡片

```python
await botapi.pic_card(url, wai, bigt, littlet)
```

参数说明：

| 参数 | 说明 |
|---|---|
| `url` | 图片地址 |
| `wai` | 外链/跳转信息 |
| `bigt` | 大标题 |
| `littlet` | 小标题 |

说明：该方法发送的是 QQ ark 大图卡片，底层模板为图片卡片模板。

示例：

```python
await botapi.pic_card(
    "https://example.com/a.jpg",
    "https://example.com",
    "大标题",
    "小标题"
)
```

---

## 9. 发送文字卡片

```python
await botapi.text_card(msg, url_list=None, wai="")
```

参数说明：

| 参数 | 说明 |
|---|---|
| `msg` | 卡片正文。正文中可以写 `[按钮文字]https://example.com` 生成跳转项 |
| `url_list` | 可选，建议传 JSON 字符串：`[{"text":"官网","url":"https://example.com"}]` |
| `wai` | 外显/提示文字 |

示例：

```python
await botapi.text_card(
    "这是文字卡片内容\n[打开官网]https://example.com",
    None,
    "插件菜单"
)
```

带 `url_list` 示例：

```python
import json

links = json.dumps([
    {"text": "打开官网", "url": "https://example.com"}
], ensure_ascii=False)

await botapi.text_card("请选择操作", links, "插件菜单")
```

---

## 10. 发送 Markdown

### 10.1 普通 Markdown

```python
await botapi.markdown_native("# 标题\n这是内容")
```

### 10.2 带按钮 Markdown

```python
button = {
    "rows": [
        {
            "buttons": [
                {
                    "id": "help",
                    "render_data": {"label": "查看帮助", "style": 1},
                    "action": {
                        "type": 2,
                        "permission": {"type": 2},
                        "data": "/帮助",
                        "enter": True
                    }
                }
            ]
        }
    ]
}

await botapi.markdown_native_with_button("# 标题\n请选择：", button)
```

按钮说明：

- `button` 会写入 QQ `keyboard.content`
- `rows` 是按钮行数组，每行包含 `buttons`
- `action.data` 会在按钮回调里返回，插件的 `button(...)` 入口可读取
- `enter=True` 表示点击后把 `data` 带入输入/发送流程，具体表现取决于 QQ 客户端

按钮回调示例：

```python
async def button(botapi, app_id, group_id, sender_id, content):
    if "帮助" in str(content):
        await botapi.textmsg("这里是帮助内容")
    return
```

---

## 11. 发送视频、文件、音频

### 视频

```python
await botapi.video_msg("https://example.com/a.mp4")
```

### 文件

```python
await botapi.file_msg("https://example.com/a.zip")
```

### 音频

```python
await botapi.audio_msg("https://example.com/a.mp3")
```

说明：

- `video_msg` 如果传入 `http/https` 地址，会先上传视频；如果传入已有 `file_info`，会直接发送
- `file_msg` 和 `audio_msg` 需要传入可访问的文件 URL
- 大文件、慢链接、非直链可能导致上传失败

---

## 12. 调用 bot API

```python
await botapi.bot(address, method, jdata)
```

示例：

```python
import json

result = await botapi.bot(
    "/users/@me",
    "GET",
    ""
)

payload = json.dumps({"content": "测试"}, ensure_ascii=False)
result = await botapi.bot(
    "/v2/groups/123456/messages",
    "POST",
    payload
)
```

注意：

- `botapi.bot(...)` 前面必须加 `await`
- `address` 必须是 QQ Bot API 路径，例如 `/users/@me`
- `method` 通常是 `GET`、`POST`、`DELETE`
- `jdata` 必须传 JSON 字符串；没有请求体时传空字符串 `""`
- 不要用它绕过权限做危险操作
- 不要操作用户隐私或敏感数据

---

## 13. 请求外部接口 curl_to_url

```python
resp = await botapi.curl_to_url(url, method="GET", headers=None, data=None)
```

返回结构：

```json
{
  "status": 200,
  "data": "网页返回",
  "error": "",
  "time": 0
}
```

示例：

```python
async def C2C(botapi, app_id, group_id, sender_id, content):
    if content == "/api":
        resp = await botapi.curl_to_url("https://api.example.com/public")
        await botapi.textmsg("请求完成")
    return
```

说明：

- `curl_to_url` 是允许能力
- 不检查 `resp["status"]` 不扣分
- 不检查 `status` 不影响上架
- 普通联网/普通传输不算风险
- 如果为了稳定性，可以自己判断 `status`

推荐稳定写法：

```python
resp = await botapi.curl_to_url("https://api.example.com/public")

if resp.get("status") == 200:
    data = resp.get("data")
    await botapi.textmsg("请求成功")
else:
    await botapi.textmsg("请求失败：" + str(resp.get("error")))
```

---

## 14. 读取 URL 参数 get_url_params

```python
value = await botapi.get_url_params(url, name)
```

作用：从 URL 里提取指定参数。

示例：

```python
async def C2C(botapi, app_id, group_id, sender_id, content):
    url = "https://example.com/callback?code=123456"
    code = await botapi.get_url_params(url, "code")
    await botapi.textmsg("code=" + str(code))
    return
```

再比如：

```python
token = await botapi.get_url_params(
    "https://a.example.com/callback?token=abc",
    "token"
)
```

说明：

- `get_url_params` 必须加 `await`
- 普通参数读取不算风险
- 读取 `token`、`cookie`、`password`、`key`、`secret`、`sid` 等敏感参数时要谨慎
- 只读取但不外传、不写日志、不威胁用户或服务器，一般不直接拦截
- 如果把敏感参数传输到非同主域名，会被视为高风险

---

## 15. 获取原始消息体 get_body

```python
body = await botapi.get_body()
```

示例：

```python
async def C2C(botapi, app_id, group_id, sender_id, content):
    body = await botapi.get_body()
    await botapi.log("收到一条私聊消息")
    return
```

注意：

- 不要把完整 `body` 直接发送到外部网站
- 不要把包含用户隐私的内容写入日志
- 用多少取多少，避免无意义处理隐私数据

---

## 16. 输出日志 log

```python
await botapi.log("插件运行到这里了")
```

示例：

```python
await botapi.log("开始处理 /天气 命令")
```

注意：

不要输出敏感信息，例如：

- token
- cookie
- password
- key
- secret
- sid
- 私钥
- 用户隐私内容

错误示例：

```python
await botapi.log("用户token=" + token)
```

---

## 17. 同主域名和跨主域名

普通联网不是问题，重点看有没有把敏感信息传到非同主域名。

同主域名示例：

```text
a.example.com -> api.example.com
```

通常都属于：

```text
example.com
```

跨主域名示例：

```text
example.com -> evil.com
example.com -> other-site.net
```

如果插件取得了用户信息、token、cookie、key、secret 等，又传到非同主域名，就会被判为高风险。

---

## 18. 安全规则

插件审核主要看：

1. 会不会威胁用户
2. 会不会威胁服务器
3. 有没有把取得的信息传到非同主域名
4. 有没有执行系统命令
5. 有没有读写敏感文件
6. 有没有后门、远控、下载执行
7. 有没有盗号、钓鱼、诱导授权

---

## 19. 不会直接扣分的情况

这些情况一般只给建议，不扣分：

- 代码风格一般
- 写法不够优雅
- 未使用变量
- 没有判断 `curl_to_url` 的 `status`
- 异常处理不够完善
- 普通 curl/联网请求
- 普通数据传输
- 使用 `get_url_params` 读取普通参数

---

## 20. 会扣分或拒绝上架的情况

这些情况会扣分，严重时拒绝上架：

- 缺少基础入口函数（`has_been_loaded`、`group`、`C2C`、`button`）；可选的 `member_add`、`member_remove`、`group_join_request` 不参与完整性扣分
- 缺少 `plugin_info`
- 执行系统命令
- 读取服务器敏感文件
- 读取环境变量并外传
- 上传 token/cookie/password/key/secret/sid 到非同主域名
- 下载并执行代码
- 动态加载恶意代码
- 后门、远控、挖矿
- 刷请求、资源滥用
- 钓鱼、盗号、诱导用户授权

---

## 21. import 建议

### 21.1 高危 import

以下 import 风险较高，通常不建议使用：

```text
os
subprocess
shutil
socket
ftplib
smtplib
paramiko
importlib
ctypes
marshal
pickle
```

如果插件命中这些，并涉及命令执行、文件读取、动态加载等，通常会被拒绝上架。

---

### 21.2 仅提醒的 import

以下 import 不会仅凭导入就拒绝：

```text
requests
urllib
httpx
aiohttp
pathlib
glob
sys
base64
```

但如果它们配合敏感信息外传、下载执行、混淆执行等，也会被判为风险。

---

## 22. 完整示例：文字回复插件

```python
def has_been_loaded():
    return {
        "plugin_info": {
            "name": "问候插件",
            "author": "10001",
            "version": "1.0.0",
            "desc": "输入 /hello 回复问候语",
            "command_list": ["/hello"]
        }
    }

async def group(botapi, app_id, group_id, sender_id, content):
    if content == "/hello":
        await botapi.textmsg("大家好！")
    return

async def C2C(botapi, app_id, group_id, sender_id, content):
    if content == "/hello":
        await botapi.textmsg("你好呀！")
    return

async def button(botapi, app_id, group_id, sender_id, content):
    return
```

---

## 23. 完整示例：请求接口插件

```python
def has_been_loaded():
    return {
        "plugin_info": {
            "name": "接口测试插件",
            "author": "10001",
            "version": "1.0.0",
            "desc": "输入 /api 请求公开接口",
            "command_list": ["/api"]
        }
    }

async def group(botapi, app_id, group_id, sender_id, content):
    return

async def C2C(botapi, app_id, group_id, sender_id, content):
    if content == "/api":
        resp = await botapi.curl_to_url("https://api.example.com/public")
        await botapi.textmsg("请求完成")
    return

async def button(botapi, app_id, group_id, sender_id, content):
    return
```

---

## 24. 完整示例：读取 URL 参数

```python
def has_been_loaded():
    return {
        "plugin_info": {
            "name": "URL参数插件",
            "author": "10001",
            "version": "1.0.0",
            "desc": "输入 /param 测试 URL 参数读取",
            "command_list": ["/param"]
        }
    }

async def group(botapi, app_id, group_id, sender_id, content):
    return

async def C2C(botapi, app_id, group_id, sender_id, content):
    if content == "/param":
        url = "https://example.com/callback?code=123456"
        code = await botapi.get_url_params(url, "code")
        await botapi.textmsg("读取到 code=" + str(code))
    return

async def button(botapi, app_id, group_id, sender_id, content):
    return
```

---

## 25. 完整示例：仅主人可用的插件

```python
def has_been_loaded():
    return {
        "plugin_info": {
            "name": "主人面板",
            "author": "10001",
            "version": "1.0.0",
            "desc": "输入 /管理面板 查看状态（仅主人可用）",
            "command_list": ["/管理面板"]
        }
    }

async def group(botapi, app_id, group_id, sender_id, content):
    if content == "/管理面板":
        if botapi.is_owner():
            await botapi.textmsg("主人你好，当前一切正常。")
        else:
            await botapi.textmsg("此命令仅主人可用")
    return

async def C2C(botapi, app_id, group_id, sender_id, content):
    if content == "/管理面板" and botapi.is_owner():
        await botapi.textmsg("私聊面板已打开")
    return

async def button(botapi, app_id, group_id, sender_id, content):
    return
```

---

## 26. 开发建议

1. 所有 `botapi` 异步方法都加 `await`，但 `is_owner()` 是同步的，不需 `await`
2. 插件必须保留 4 个必要函数
3. `C2C` 大小写不要写错
4. 不要读取服务器敏感文件
5. 不要执行系统命令
6. 不要把用户隐私传到非同主域名
7. 不要把 token/cookie/password/key/secret/sid 写入日志
8. 普通联网请求可以用 `botapi.curl_to_url`
9. URL 参数读取可以用 `botapi.get_url_params`
10. 插件管理页参数读取可以用 `botapi.get_params`
11. 需要主人专属的命令可以用 `botapi.is_owner()` 判断
12. 功能说明写清楚，方便用户和审核系统理解

---

## 27. 一句话总结

插件开发核心就是：

```text
写好基础入口函数，需要群成员事件时再实现 `member_add`、`member_remove`、`group_join_request`；群禁言和入群审批使用当前群的 OpenID 与申请 ID；用 await 调 botapi，通过 get_params 读取插件配置，不威胁用户，不威胁服务器，不把敏感信息传到非同主域名。
```
