feat: OpenAPI 全量对齐——对照新旧两版官方文档补齐全部接口与事件

- 新增 panel 域:指令面板(斜线指令)增删改查/关联对象 + /v2/menu 全局自定义菜单
- 新增 group 域:群信息/成员/机器人群内状态、入群申请列表与审批、群黑名单、
  群禁言状态、入群自动审批策略(增删改查/执行/白名单)
- v2 补充:流式单聊消息、群/单聊大文件分片上传(预上传+分片确认)、分享链接
- guild 补充:子频道增删改、机器人频道列表、身份组 CRUD 与成员授予/收回、
  成员列表/踢出、全员与批量禁言、接口权限、消息频率设置
- channel 补充:子频道用户/身份组权限、频道私信(建会话/发信/撤回)
- 新增 audio 域(播放控制、上/下麦)与 forum 域(帖子增删查)
- 事件补齐 GROUP_MEMBER_ADD/REMOVE、GROUP_JOIN_REQUEST、SUBSCRIBE_MESSAGE_STATUS;
  Intent 与 intents.toml 补 GROUP_MEMBER_EVENT (1<<24) 分组(默认 false)
This commit is contained in:
snowy-yang committed 2026-09-23 17:46:37 +08:00
1 parent 13a1d971be
commit 15e1f5e303
15 files changed
+1677 -16

No files matched your search

+6
View File
@@ -7,6 +7,12 @@
### 新增 ### 新增
- **旧版(频道侧)OpenAPI 对齐(对照旧版 wiki 全集核对)**:私信(`create_dms` 建会话、`post_dms_message` 发信、`withdraw_dms_message` 撤回);身份组增删改与成员授予/收回;频道成员列表/踢出(可拉黑+撤回历史消息)/身份组成员列表/子频道在线成员数;全员与批量成员禁言;子频道用户/身份组权限查改;频道消息频率设置;接口权限列表与授权链接申请;新增 `audio` 域(播放控制、上/下麦)与 `forum` 域(帖子列表/详情/发帖/删帖)组合进 `BotApi`(`AUDIO_STATUS_*`、`FORUM_FORMAT_*` 常量随模块导出)
- **事件全量对齐**:新增 `GROUP_MEMBER_ADD`/`GROUP_MEMBER_REMOVE`(共用 `GroupMemberChange`)、`GROUP_JOIN_REQUEST`(入群申请,含验证问答与自动审批命中信息,仅机器人是群管理员时推送)、`SUBSCRIBE_MESSAGE_STATUS`(订阅消息授权状态)四个 payload;`Intent` 枚举与 intents.toml 补上此前缺失的 `GROUP_MEMBER_EVENT (1<<24)` 分组(默认 false,需订阅请手动开启)
- **v2 OpenAPI 全量对齐(对照官方 2026-09 新版文档站逐页核对)**:新增 `group` 域方法集组合进 `BotApi`——群信息/成员列表/成员详情/机器人群内状态、入群申请列表与审批、群黑名单增删查、群成员批量移除、群禁言状态查询、入群自动审批策略(增删改查/执行/白名单,机器人维度全局配置)
- v2 消息扩展:`post_c2c_stream_message` 流式分批发送单聊消息(AI 逐段下发,`STREAM_*` 常量取值);`prepare_group_upload`/`finish_group_upload_part` 与 `prepare_c2c_upload`/`finish_c2c_upload_part` 大文件分片上传(预上传拿预签名 URL,分片 PUT 后逐片确认,最后携 `upload_id` 走原上传接口合并);`generate_url_link` 生成机器人分享链接(`callback_data` 透传)
- 频道域补充:`create_channel`/`update_channel`/`delete_channel` 子频道增删改(私域接口)、`get_me_guilds` 机器人加入的频道列表(分页)
- **斜线指令管理**:新增 `panel` 域方法集组合进 `BotApi`——指令面板(聊天输入框输入 `/` 唤起的指令列表)的增删改查与关联对象管理(`create_panel`/`get_panels`/`get_panel`/`update_panel`/`delete_panel`/`update_panel_targets`,支持 c2c/group/channel/dm 四场景,c2c/group 可按指定对象生效);附全局自定义菜单 `get_menu`/`update_menu`(仅 C2C,`send_message` 类型点击后把 `/指令` 填入输入框);`SCOPE_*`/`TARGET_*`/`ITEM_*` 等取值常量随模块导出
- Markdown 与内嵌按钮一等支持:`post_group_message`/`post_c2c_message` 新增 `markdown`/`keyboard` 参数(自动置 msg_type=2,与 content 互斥);新增 `respond_interaction()` 回应按钮回调;`Interaction` payload 补齐 `chat_type`/`scene`/`group_openid` 等官方字段 - Markdown 与内嵌按钮一等支持:`post_group_message`/`post_c2c_message` 新增 `markdown`/`keyboard` 参数(自动置 msg_type=2,与 content 互斥);新增 `respond_interaction()` 回应按钮回调;`Interaction` payload 补齐 `chat_type`/`scene`/`group_openid` 等官方字段
- `Event` 新增 `event_id` 属性(推送信封顶层的事件 id,与 op/t/d 平级):非消息事件(GROUP_ADD_ROBOT 等)的被动回复不再需要手动 `event.raw["id"]` - `Event` 新增 `event_id` 属性(推送信封顶层的事件 id,与 op/t/d 平级):非消息事件(GROUP_ADD_ROBOT 等)的被动回复不再需要手动 `event.raw["id"]`
- 按钮快捷构造与点击处理:`button()`/`keyboard()` 生成内嵌按钮(免手写嵌套 dict);`Interaction` 新增 `button_data` 属性(被点按钮的 action.data);examples/group_bot 补 `/btn` 卡片与 `INTERACTION_CREATE` 回调示例 - 按钮快捷构造与点击处理:`button()`/`keyboard()` 生成内嵌按钮(免手写嵌套 dict);`Interaction` 新增 `button_data` 属性(被点按钮的 action.data);examples/group_bot 补 `/btn` 卡片与 `INTERACTION_CREATE` 回调示例
+86 -2
View File
@@ -97,7 +97,7 @@ async def slow(...): ...
`from qqbotsdk.api import BotApi`。经参数注入(`api: BotApi`)。自动附带 `Authorization: QQBot <token>` 鉴权头并刷新 token;HTTP ≥400 抛 `ApiError`。 `from qqbotsdk.api import BotApi`。经参数注入(`api: BotApi`)。自动附带 `Authorization: QQBot <token>` 鉴权头并刷新 token;HTTP ≥400 抛 `ApiError`。
实现按域拆在 `qqbotsdk/api/` 包内(`core` 请求内核 + `v2`/`channel`/`guild` 方法集组合成 `BotApi`),方法名与下表一致;429 限流自动重试(最多 3 次,优先 `Retry-After`,退避封顶 30s),401 强制刷新 token 后重试一次。 实现按域拆在 `qqbotsdk/api/` 包内(`core` 请求内核 + `v2`/`channel`/`guild`/`panel`/`group`/`audio`/`forum` 方法集组合成 `BotApi`),方法名与下表一致;429 限流自动重试(最多 3 次,优先 `Retry-After`,退避封顶 30s),401 强制刷新 token 后重试一次。
### 通用 ### 通用
@@ -106,9 +106,10 @@ async def slow(...): ...
| `request(method, path, **kwargs)` | 通用请求,kwargs 透传 aiohttp(`json=`/`params=` 等) | | `request(method, path, **kwargs)` | 通用请求,kwargs 透传 aiohttp(`json=`/`params=` 等) |
| `get` / `post` / `delete` / `put` / `patch`(path, **kwargs) | `request` 快捷方式 | | `get` / `post` / `delete` / `put` / `patch`(path, **kwargs) | `request` 快捷方式 |
| `me()` | 机器人自身信息(GET /users/@me) | | `me()` | 机器人自身信息(GET /users/@me) |
| `generate_url_link(callback_data=None)` | 生成机器人分享链接(POST /v2/generate_url_link),返回 `{"data": {"url": …}}`;用户经链接添加时 `callback_data`(≤32 字符)透传给开发者 |
| `respond_interaction(interaction_id, code=0)` | 回应互动事件(PUT /interactions/{id});按钮点击(INTERACTION_CREATE type=11)收到后必须回应,否则客户端持续 loading,同一 id 仅可回应一次 | | `respond_interaction(interaction_id, code=0)` | 回应互动事件(PUT /interactions/{id});按钮点击(INTERACTION_CREATE type=11)收到后必须回应,否则客户端持续 loading,同一 id 仅可回应一次 |
常量:`MSG_TYPE_TEXT=0`、`MSG_TYPE_MARKDOWN=2`、`MSG_TYPE_MEDIA=7`;`FILE_TYPE_IMAGE=1`、`FILE_TYPE_VIDEO=2`、`FILE_TYPE_VOICE=3`、`FILE_TYPE_FILE=4`。 常量:`MSG_TYPE_TEXT=0`、`MSG_TYPE_MARKDOWN=2`、`MSG_TYPE_MEDIA=7`;`FILE_TYPE_IMAGE=1`、`FILE_TYPE_VIDEO=2`、`FILE_TYPE_VOICE=3`、`FILE_TYPE_FILE=4`;流式消息 `STREAM_INPUT_APPEND`/`STREAM_INPUT_REPLACE`、`STREAM_GENERATING=1`/`STREAM_FINISHED=10`、`STREAM_CONTENT_TEXT`/`STREAM_CONTENT_MARKDOWN`;音频 `AUDIO_STATUS_START=0`/`PAUSE=1`/`RESUME=2`/`STOP=3`;论坛 `FORUM_FORMAT_TEXT=1`/`HTML=2`/`MARKDOWN=3`/`JSON=4`。
按钮构造:`button(label, data, *, type=1, permission=2, style=1)` 生成单个按钮 dict,`keyboard(*rows)` 组装为 `keyboard=` 参数(单按钮独占一行,按钮列表同行并排)。点击回调经 `INTERACTION_CREATE` 事件处理:`Interaction.button_data` 取被点按钮的 `data`,`Interaction.id` 用于 `respond_interaction`,**被动回复的 `event_id` 用 `Event.event_id`(网关帧最外层 id),不能用 `Interaction.id`**。 按钮构造:`button(label, data, *, type=1, permission=2, style=1)` 生成单个按钮 dict,`keyboard(*rows)` 组装为 `keyboard=` 参数(单按钮独占一行,按钮列表同行并排)。点击回调经 `INTERACTION_CREATE` 事件处理:`Interaction.button_data` 取被点按钮的 `data`,`Interaction.id` 用于 `respond_interaction`,**被动回复的 `event_id` 用 `Event.event_id`(网关帧最外层 id),不能用 `Interaction.id`**。
@@ -120,6 +121,8 @@ async def slow(...): ...
| `upload_group_file(group_openid, file_type, url, srv_send_msg=False)` | POST /v2/groups/{group_openid}/files | 富媒体上传,返回含 `file_info`;`srv_send_msg=True` 上传即发送 | | `upload_group_file(group_openid, file_type, url, srv_send_msg=False)` | POST /v2/groups/{group_openid}/files | 富媒体上传,返回含 `file_info`;`srv_send_msg=True` 上传即发送 |
| `withdraw_group_message(group_openid, message_id)` | DELETE /v2/groups/{group_openid}/messages/{message_id} | 发出 2 分钟内可撤回 | | `withdraw_group_message(group_openid, message_id)` | DELETE /v2/groups/{group_openid}/messages/{message_id} | 发出 2 分钟内可撤回 |
| `mute_group_member(group_openid, mute_expire_at)` | POST /v2/groups/{group_openid}/restrict_chat_setting | `mute_expire_at` 为 RFC3339 到期时间;传早于当前的时间即解禁 | | `mute_group_member(group_openid, mute_expire_at)` | POST /v2/groups/{group_openid}/restrict_chat_setting | `mute_expire_at` 为 RFC3339 到期时间;传早于当前的时间即解禁 |
| `prepare_group_upload(group_openid, file_type, file_size, file_name, md5, sha1, md5_10m)` | POST /v2/groups/{group_openid}/upload_prepare | 大文件分片预上传,返回 `upload_id`/`block_size`/各分片预签名 `parts`;逐片 PUT 后调 `finish_group_upload_part`,全部完成后携 `upload_id` 调 `upload_group_file` 合并;`md5_10m` 为文件前约 10MB 的 MD5 |
| `finish_group_upload_part(group_openid, upload_id, part_index, block_size=None, md5=None)` | POST …/upload_part_finish | 每个分片 PUT 成功后调用确认 |
### v2 单聊(用户 openid) ### v2 单聊(用户 openid)
@@ -128,6 +131,31 @@ async def slow(...): ...
| `post_c2c_message(openid, content="", msg_id=None, msg_seq=1, media=None, event_id=None, markdown=None, keyboard=None, **extra)` | POST /v2/users/{openid}/messages | 同 `post_group_message` | | `post_c2c_message(openid, content="", msg_id=None, msg_seq=1, media=None, event_id=None, markdown=None, keyboard=None, **extra)` | POST /v2/users/{openid}/messages | 同 `post_group_message` |
| `upload_c2c_file(openid, file_type, url, srv_send_msg=False)` | POST /v2/users/{openid}/files | 同 `upload_group_file` | | `upload_c2c_file(openid, file_type, url, srv_send_msg=False)` | POST /v2/users/{openid}/files | 同 `upload_group_file` |
| `withdraw_c2c_message(openid, message_id)` | DELETE /v2/users/{openid}/messages/{message_id} | 2 分钟内可撤回 | | `withdraw_c2c_message(openid, message_id)` | DELETE /v2/users/{openid}/messages/{message_id} | 2 分钟内可撤回 |
| `post_c2c_stream_message(openid, content_raw="", *, stream_msg_id=None, index=0, input_state=STREAM_GENERATING, input_mode=None, content_type=STREAM_CONTENT_MARKDOWN, msg_id=None, event_id=None, msg_seq=None, is_wakeup=None)` | POST /v2/users/{openid}/stream_messages | 流式分批发送(AI 回复逐段下发):首片不传 `stream_msg_id`,响应 `id` 即后续分片要携带的值,`index` 从 0 递增,以 `input_state=STREAM_FINISHED` 收尾;`input_mode="replace"` 时 `content_raw` 须以上游已下发前缀开头;`is_wakeup=True` 为召回消息,不校验被动回复有效期 |
| `prepare_c2c_upload(openid, …)` / `finish_c2c_upload_part(openid, …)` | POST /v2/users/{openid}/upload_prepare · upload_part_finish | 单聊分片上传,用法同群聊 `prepare_group_upload`/`finish_group_upload_part` |
### 群管理(群 openid)
多数接口要求机器人拥有群管理员身份;成员/黑名单/批量移除等能力官方标注"内邀接入中",未开通时服务端返回业务码 11253。常量:`APPROVE`/`DECLINE`(审批动作)、`OP_ADD`/`OP_DEL`(黑名单/白名单操作)。
| 方法 | 端点 | 说明 |
|---|---|---|
| `get_group_info(group_openid)` | GET /v2/groups/{group_openid}/info | 群名称/简介/分类/标签/成员数 |
| `get_group_members(group_openid, cursor=None)` | GET …/members | 成员列表,每页最多 30 条,`next_cursor` 翻页(空串=末页) |
| `get_group_member(group_openid, member_openid)` | GET …/members/{member_openid} | 成员昵称/角色(member/owner/admin)/入群时间 |
| `get_group_bot_state(group_openid)` | GET …/bot_state | 机器人自身状态:入群时间、是否允许主动推送、群内消息接收设置、角色 |
| `remove_group_members(group_openid, member_openids, add_to_member_blacklist=False)` | POST …/batch_remove_members | 批量移除,单次最多 20 个,可选拉黑 |
| `get_group_join_requests(group_openid, cursor=None, limit=None)` | GET …/join_request_list | 入群申请列表(含验证问答),需群管理员;limit 默认 20 最大 50 |
| `review_group_join_request(group_openid, member_openid, op, join_request_id=None, reject_reason=None, add_to_member_blacklist=None)` | POST …/approval_join_request/{member_openid} | `op`:`APPROVE`/`DECLINE`;拒绝时可附理由并拉黑 |
| `get_group_mute_state(group_openid)` | GET …/restrict_chat_setting | 禁言状态:全员禁言规则(定时/周期)与被禁言成员列表 |
| `get_group_blacklist(group_openid, cursor=None, limit=None)` | GET …/member_blacklist | 黑名单列表,limit 默认 20 最大 100 |
| `update_group_blacklist(group_openid, op, member_openids)` | POST …/member_blacklist | `op`:`OP_ADD`/`OP_DEL`,单次最多 20 个;成员在群中时无法拉黑 |
| `get_join_approval_strategies(cursor=None, limit=None)` | GET /v2/groups/join_approval_strategy | 自动审批策略列表(机器人维度全局配置),按创建时间倒序 |
| `create_join_approval_strategy(*, group_openids=None, group_ids=None, is_enable=None, expire_at=None, remark=None)` | POST /v2/groups/join_approval_strategy | 关联群两种标识二选一(互斥,最多 100 个);`is_enable` "on"/"off";默认一年过期;仅机器人有群管理员身份时生效;返回 `strategy_id` |
| `update_join_approval_strategy(strategy_id, *, is_enable=None, expire_at=None, group_action=None, remark=None)` | PATCH …/{strategy_id} | 只传变更项;`group_action` 为 `{"op": "add"/"del", "group_openids": […]}`,群标识形式须与创建时一致 |
| `delete_join_approval_strategy(strategy_id)` | DELETE …/{strategy_id} | 删除策略 |
| `execute_join_approval_strategy(strategy_id)` | POST …/{strategy_id}/execute | 全量扫描关联群并自动通过白名单申请;异步约 10 分钟 |
| `update_join_approval_strategy_whitelist(strategy_id, op, whitelist_users)` | POST …/{strategy_id}/whitelist_users | QQ 号码字符串列表,单次最多 10000、上限 10 万 |
### 频道消息(channel_id / guild_id) ### 频道消息(channel_id / guild_id)
@@ -137,12 +165,20 @@ async def slow(...): ...
| `withdraw_channel_message(channel_id, message_id, hide_tip=False)` | DELETE /channels/{channel_id}/messages/{message_id} | **仅私域机器人**;`hide_tip` 对应官方 `hidetip` | | `withdraw_channel_message(channel_id, message_id, hide_tip=False)` | DELETE /channels/{channel_id}/messages/{message_id} | **仅私域机器人**;`hide_tip` 对应官方 `hidetip` |
| `put_channel_reaction(channel_id, message_id, emoji_type, emoji_id)` | PUT …/reactions/{type}/{emoji_id} | `emoji_type=1` 系统表情,`emoji_id` 为序号字符串 | | `put_channel_reaction(channel_id, message_id, emoji_type, emoji_id)` | PUT …/reactions/{type}/{emoji_id} | `emoji_type=1` 系统表情,`emoji_id` 为序号字符串 |
| `delete_channel_reaction(channel_id, message_id, emoji_type, emoji_id)` | DELETE …/reactions/{type}/{emoji_id} | 删除自己的表态 | | `delete_channel_reaction(channel_id, message_id, emoji_type, emoji_id)` | DELETE …/reactions/{type}/{emoji_id} | 删除自己的表态 |
| `create_dms(recipient_id, source_guild_id)` | POST /users/@me/dms | 创建私信会话(需与用户同频道),返回私信的 `guild_id`/`channel_id` |
| `post_dms_message(guild_id, content="", msg_id=None, msg_seq=1, image=None, **extra)` | POST /dms/{guild_id}/messages | 发私信,参数同 `post_channel_message`;主动消息每天单用户 2 条、累计 200 条,被动不限 |
| `withdraw_dms_message(guild_id, message_id, hide_tip=False)` | DELETE /dms/{guild_id}/messages/{message_id} | 撤回自己发的私信,**仅私域** |
| `get_channel_permissions(channel_id, user_id)` / `get_channel_role_permissions(channel_id, role_id)` | GET …/members/{user_id}/permissions · …/roles/{role_id}/permissions | 子频道权限位图(字符串) |
| `update_channel_permissions(channel_id, user_id, *, add=None, remove=None)` / `update_channel_role_permissions(channel_id, role_id, …)` | PUT 同路径 | `add`/`remove` 为字符串权限位图,至少传一个,需管理员权限 |
### 频道管理 ### 频道管理
| 方法 | 端点 | 说明 | | 方法 | 端点 | 说明 |
|---|---|---| |---|---|---|
| `create_guild_announce(guild_id, channel_id, message_id, announces_type=0, **extra)` | POST /guilds/{guild_id}/announces | `announces_type`:0 成员公告 / 1 欢迎公告;推荐频道公告经 extra(与消息公告互斥) | | `create_guild_announce(guild_id, channel_id, message_id, announces_type=0, **extra)` | POST /guilds/{guild_id}/announces | `announces_type`:0 成员公告 / 1 欢迎公告;推荐频道公告经 extra(与消息公告互斥) |
| `create_channel(guild_id, name, **fields)` | POST /guilds/{guild_id}/channels | 创建子频道,**私域**需管理员权限;`fields` 透传 type/sub_type/position/parent_id/private_type/speak_permission 等;触发 CHANNEL_CREATE |
| `update_channel(channel_id, **fields)` | PATCH /channels/{channel_id} | 修改子频道,**私域**;只传变更项;触发 CHANNEL_UPDATE |
| `delete_channel(channel_id)` | DELETE /channels/{channel_id} | 删除子频道,**私域**;不可恢复;触发 CHANNEL_DELETE |
| `delete_guild_announce(guild_id, message_id)` | DELETE /guilds/{guild_id}/announces/{message_id} | 推荐频道公告传 `message_id="all"` | | `delete_guild_announce(guild_id, message_id)` | DELETE /guilds/{guild_id}/announces/{message_id} | 推荐频道公告传 `message_id="all"` |
| `pin_channel_message(channel_id, message_id)` | PUT /channels/{channel_id}/pins/{message_id} | 添加精华消息 | | `pin_channel_message(channel_id, message_id)` | PUT /channels/{channel_id}/pins/{message_id} | 添加精华消息 |
| `unpin_channel_message(channel_id, message_id)` | DELETE /channels/{channel_id}/pins/{message_id} | 移出精华消息 | | `unpin_channel_message(channel_id, message_id)` | DELETE /channels/{channel_id}/pins/{message_id} | 移出精华消息 |
@@ -152,6 +188,14 @@ async def slow(...): ...
| `create_channel_schedule(channel_id, name, start_timestamp, end_timestamp, jump_channel_id, remind_type="0", description="")` | POST /channels/{channel_id}/schedules | 时间为毫秒时间戳字符串;`remind_type` "0" 不提醒 / "1" 开始时;请求体自动包在 `schedule` 字段;单管理员每天限 10 次 | | `create_channel_schedule(channel_id, name, start_timestamp, end_timestamp, jump_channel_id, remind_type="0", description="")` | POST /channels/{channel_id}/schedules | 时间为毫秒时间戳字符串;`remind_type` "0" 不提醒 / "1" 开始时;请求体自动包在 `schedule` 字段;单管理员每天限 10 次 |
| `update_channel_schedule(channel_id, schedule_id, **fields)` | PATCH /channels/{channel_id}/schedules/{schedule_id} | `fields` 为 schedule 内字段透传 | | `update_channel_schedule(channel_id, schedule_id, **fields)` | PATCH /channels/{channel_id}/schedules/{schedule_id} | `fields` 为 schedule 内字段透传 |
| `delete_channel_schedule(channel_id, schedule_id)` | DELETE /channels/{channel_id}/schedules/{schedule_id} | 删除日程 | | `delete_channel_schedule(channel_id, schedule_id)` | DELETE /channels/{channel_id}/schedules/{schedule_id} | 删除日程 |
| `create_guild_role(guild_id, name="", color=None, hoist=None)` | POST /guilds/{guild_id}/roles | 创建身份组,返回 `{"role_id", "role"}`;`color` 为 ARGB 十进制 |
| `update_guild_role(guild_id, role_id, name=None, color=None, hoist=None)` | PATCH /guilds/{guild_id}/roles/{role_id} | 只传变更项;系统默认身份组不可改 |
| `delete_guild_role(guild_id, role_id)` | DELETE /guilds/{guild_id}/roles/{role_id} | 只能删除自建身份组 |
| `add_guild_member_role(guild_id, user_id, role_id)` / `remove_guild_member_role(…)` | PUT / DELETE /guilds/{guild_id}/members/{user_id}/roles/{role_id} | 授予/收回成员身份组 |
| `remove_guild_member(guild_id, user_id, add_blacklist=False, delete_history_msg_days=0)` | DELETE /guilds/{guild_id}/members/{user_id} | 踢人,**私域**需踢人权限;撤回消息仅支持 3/7/15/30 或 -1 全部 |
| `mute_guild(guild_id, mute_end_timestamp=None, mute_seconds=None)` | PATCH /guilds/{guild_id}/mute | 全员禁言,两个时间字段二选一;解除都传 "0" |
| `mute_guild_members(guild_id, user_ids, …)` | PATCH /guilds/{guild_id}/mute | 批量禁言(带 `user_ids`),单次最多 20 人 |
| `create_api_permission_demand(guild_id, channel_id, path, method, desc)` | POST /guilds/{guild_id}/api_permission/demand | 生成接口权限授权链接发到子频道,由频道管理员授权 |
| `mute_guild_member(guild_id, user_id, mute_seconds=0)` | PATCH /guilds/{guild_id}/members/{user_id}/mute | 禁言秒数(上限 28 天),0 解禁 | | `mute_guild_member(guild_id, user_id, mute_seconds=0)` | PATCH /guilds/{guild_id}/members/{user_id}/mute | 禁言秒数(上限 28 天),0 解禁 |
### 频道查询 ### 频道查询
@@ -159,11 +203,45 @@ async def slow(...): ...
| 方法 | 端点 | | 方法 | 端点 |
|---|---| |---|---|
| `get_guild(guild_id)` | GET /guilds/{guild_id} | | `get_guild(guild_id)` | GET /guilds/{guild_id} |
| `get_me_guilds(before=None, after=None, limit=None)` | GET /users/@me/guilds |
| `get_guild_channels(guild_id)` | GET /channels/{guild_id}/channels | | `get_guild_channels(guild_id)` | GET /channels/{guild_id}/channels |
| `get_guild_members(guild_id)` | GET /guilds/{guild_id}/members |
| `get_role_members(guild_id, role_id)` | GET /guilds/{guild_id}/roles/{role_id}/members |
| `get_channel_online_nums(channel_id)` | GET /channels/{channel_id}/online_nums |
| `get_guild_message_setting(guild_id)` | GET /guilds/{guild_id}/message/setting |
| `get_guild_api_permissions(guild_id)` | GET /guilds/{guild_id}/api_permission |
| `get_channel(channel_id)` | GET /channels/{channel_id} | | `get_channel(channel_id)` | GET /channels/{channel_id} |
| `get_guild_roles(guild_id)` | GET /guilds/{guild_id}/roles | | `get_guild_roles(guild_id)` | GET /guilds/{guild_id}/roles |
| `get_guild_member(guild_id, user_id)` | GET /guilds/{guild_id}/members/{user_id} | | `get_guild_member(guild_id, user_id)` | GET /guilds/{guild_id}/members/{user_id} |
### 指令面板(斜线指令)与全局菜单
斜线指令面板即用户在聊天输入框输入 `/` 唤起的指令列表,支持 c2c(单聊)、group(群聊)、channel(文字子频道)、dm(频道私信)四种场景;c2c/group 支持 `target_type="specific"` 按指定对象生效(关联对象每次最多 20 个,详情最多查 1000 个),channel/dm 仅全局生效。一个机器人最多 20 个面板,每面板最多 20 个元素(`name` ≤14 字符、`desc` ≤30 字符)。常量:`SCOPE_C2C`/`SCOPE_GROUP`/`SCOPE_CHANNEL`/`SCOPE_DM`、`TARGET_ALL`/`TARGET_SPECIFIC`、`ITEM_COMMAND`/`ITEM_LINK`、`TARGET_OP_ADD`/`TARGET_OP_DEL`。
| 方法 | 端点 | 说明 |
|---|---|---|
| `create_panel(scope, panel, *, target_type=None, user_openids=None, group_openids=None, **extra)` | POST /v2/panels | 返回 `{"panel_id": …}`;`panel` 为 `{"items": [{"type": "command"/"link", "name", "desc", "only_admin", "link"}], "remark"}`,command 类型点击后 `name` 填入输入框,link 类型点击跳转 |
| `get_panels(scope, cursor=None, limit=None)` | GET /v2/panels | 分页列表(按设置时间倒序),返回 `records`/`next_cursor`(空串=末页)/`is_end`;`limit` 默认 20、最大 50 |
| `get_panel(panel_id)` | GET /v2/panels/{panel_id} | 面板详情,specific 面板另带关联的 `user_openids`/`group_openids` |
| `update_panel(panel_id, panel)` | PUT /v2/panels/{panel_id} | 整体覆盖元素与备注,不影响已关联对象;返回 `{"version": …}` |
| `delete_panel(panel_id)` | DELETE /v2/panels/{panel_id} | 删除面板,删除后对所有对象失效 |
| `update_panel_targets(panel_id, op, user_openids=None, group_openids=None)` | PUT /v2/panels/{panel_id}/target | `op`:`"add"`/`"del"`;`user_openids` 仅 c2c、`group_openids` 仅 group;channel/dm 与全局面板不支持 |
| `get_menu()` | GET /v2/menu | 查询全局自定义菜单(仅 C2C、对所有用户生效),返回 `{"version", "menu"}`;未设置过时 `menu` 为空 |
| `update_menu(menu=None)` | PUT /v2/menu | 整体覆盖菜单(≤10 项,子菜单 ≤5 且不可再嵌套);`send_message` 类型点击后把文本(如 `/help`)填入输入框,常与斜线指令配合 |
### 音频与论坛(私域)
音频接口仅音频类机器人可用(需联系平台开通);论坛仅论坛子频道,发帖/删帖为异步任务(返回 `task_id`,结果经 FORUM_* 事件推送)。
| 方法 | 端点 | 说明 |
|---|---|---|
| `control_channel_audio(channel_id, audio_url=None, text=None, status=AUDIO_STATUS_START)` | POST /channels/{channel_id}/audio | 播放控制(START/PAUSE/RESUME/STOP);`audio_url`/`text` 仅 START 时传 |
| `put_channel_mic(channel_id)` / `delete_channel_mic(channel_id)` | PUT / DELETE /channels/{channel_id}/mic | 机器人上麦 / 下麦 |
| `get_channel_threads(channel_id)` | GET /channels/{channel_id}/threads | 帖子列表 `{"threads", "is_finish"}` |
| `get_channel_thread(channel_id, thread_id)` | GET …/threads/{thread_id} | 帖子详情 |
| `put_channel_thread(channel_id, title, content, format=FORUM_FORMAT_TEXT)` | PUT /channels/{channel_id}/threads | 发帖,`format` 为 FORUM_FORMAT_* 常量;返回 `task_id` |
| `delete_channel_thread(channel_id, thread_id)` | DELETE …/threads/{thread_id} | 删帖,返回 `task_id` |
### ApiError ### ApiError
`ApiError(RuntimeError)`,重试后仍失败(HTTP ≥400)时抛出: `ApiError(RuntimeError)`,重试后仍失败(HTTP ≥400)时抛出:
@@ -192,6 +270,9 @@ async def slow(...): ...
| | `GROUP_ADD_ROBOT` / `GROUP_DEL_ROBOT` | `GroupAddRobot` / `GroupDelRobot` | | | `GROUP_ADD_ROBOT` / `GROUP_DEL_ROBOT` | `GroupAddRobot` / `GroupDelRobot` |
| | `C2C_MSG_REJECT` / `C2C_MSG_RECEIVE` | `C2CMsgReject` / `C2CMsgReceive` | | | `C2C_MSG_REJECT` / `C2C_MSG_RECEIVE` | `C2CMsgReject` / `C2CMsgReceive` |
| | `GROUP_MSG_REJECT` / `GROUP_MSG_RECEIVE` | `GroupMsgReject` / `GroupMsgReceive` | | | `GROUP_MSG_REJECT` / `GROUP_MSG_RECEIVE` | `GroupMsgReject` / `GroupMsgReceive` |
| | `SUBSCRIBE_MESSAGE_STATUS` | `SubscribeMessageStatus` |
| GROUP_MEMBER_EVENT | `GROUP_MEMBER_ADD` / `GROUP_MEMBER_REMOVE` | `GroupMemberChange` |
| | `GROUP_JOIN_REQUEST`(仅机器人是群管理员时推送) | `GroupJoinRequest` |
| PUBLIC_GUILD_MESSAGES | `AT_MESSAGE_CREATE` | `AtMessage` | | PUBLIC_GUILD_MESSAGES | `AT_MESSAGE_CREATE` | `AtMessage` |
| | `PUBLIC_MESSAGE_DELETE` | `MessageDelete` | | | `PUBLIC_MESSAGE_DELETE` | `MessageDelete` |
| GUILD_MESSAGES(私域) | `MESSAGE_DELETE` | `MessageDelete` | | GUILD_MESSAGES(私域) | `MESSAGE_DELETE` | `MessageDelete` |
@@ -220,6 +301,9 @@ GroupAtMessage # id, content, group_openid, author, timestamp,
C2CMessage # id, content, author, attachments, timestamp C2CMessage # id, content, author, attachments, timestamp
AtMessage # id, content, channel_id, guild_id, author, member, timestamp AtMessage # id, content, channel_id, guild_id, author, member, timestamp
DirectMessage # 同 AtMessage DirectMessage # 同 AtMessage
GroupJoinRequest # group_openid, join_request_id, member_openid, username, risk_tips,
# apply_at, apply_source, invited_by, verify_info, auto_approved, …
GroupMemberChange # timestamp, group_openid, member_openid, user_openid
``` ```
- `GroupAtMessage.user_openid` / `C2CMessage.user_openid`:便利属性,从 `author.member_openid`/`author.user_openid` 取发言人标识。 - `GroupAtMessage.user_openid` / `C2CMessage.user_openid`:便利属性,从 `author.member_openid`/`author.user_openid` 取发言人标识。
+2 -1
View File
@@ -10,7 +10,8 @@ GUILD_MEMBERS = false # (1 << 1) 频道成员增删改
GUILD_MESSAGES = false # (1 << 9) 频道全量消息,仅 *私域* 机器人 GUILD_MESSAGES = false # (1 << 9) 频道全量消息,仅 *私域* 机器人
GUILD_MESSAGE_REACTIONS = false # (1 << 10) 频道表情表态 GUILD_MESSAGE_REACTIONS = false # (1 << 10) 频道表情表态
DIRECT_MESSAGE = false # (1 << 12) 频道私信 DIRECT_MESSAGE = false # (1 << 12) 频道私信
GROUP_AND_C2C_EVENT = true # (1 << 25) 群/单聊消息、关注与主动消息开关事件 GROUP_MEMBER_EVENT = false # (1 << 24) 群成员加入/退出、用户申请加群(后者仅机器人是群管理员时推送)
GROUP_AND_C2C_EVENT = true # (1 << 25) 群/单聊消息、关注与主动消息开关、订阅消息授权状态事件
INTERACTION = true # (1 << 26) 互动事件(卡片按钮回调) INTERACTION = true # (1 << 26) 互动事件(卡片按钮回调)
MESSAGE_AUDIT = false # (1 << 27) 消息审核结果 MESSAGE_AUDIT = false # (1 << 27) 消息审核结果
FORUMS_EVENT = false # (1 << 28) 论坛事件,仅 *私域* 机器人 FORUMS_EVENT = false # (1 << 28) 论坛事件,仅 *私域* 机器人
+11 -2
View File
@@ -1,17 +1,22 @@
"""OpenAPI 客户端:BotApi 按域组合——v2 群/单聊、频道消息、频道管理。 """OpenAPI 客户端:BotApi 按域组合——v2 群/单聊、频道消息、频道管理、
指令面板(斜线指令)与全局菜单、群管理、音频、论坛。
各域模块是方法集(继承 BaseApi 共享请求内核:鉴权头注入、429 自动 各域模块是方法集(继承 BaseApi 共享请求内核:鉴权头注入、429 自动
重试、401 刷新 token 重试、统一 ApiError),方法名与拆分前完全一致。 重试、401 刷新 token 重试、统一 ApiError),方法名与拆分前完全一致。
""" """
from .audio import AudioApi
from .channel import ChannelApi from .channel import ChannelApi
from .core import ApiError from .core import ApiError
from .core import BaseApi as BaseApi from .core import BaseApi as BaseApi
from .forum import ForumApi
from .group import GroupApi
from .guild import GuildApi from .guild import GuildApi
from .panel import PanelApi
from .v2 import V2Api, button, keyboard from .v2 import V2Api, button, keyboard
class BotApi(V2Api, ChannelApi, GuildApi): class BotApi(V2Api, ChannelApi, GuildApi, PanelApi, GroupApi, AudioApi, ForumApi):
"""通用 REST 封装;具体业务接口见各域方法集。 """通用 REST 封装;具体业务接口见各域方法集。
BaseApi(请求内核)经域方法集继承自然落在 MRO 末尾, BaseApi(请求内核)经域方法集继承自然落在 MRO 末尾,
@@ -21,9 +26,13 @@ class BotApi(V2Api, ChannelApi, GuildApi):
__all__ = [ __all__ = [
"ApiError", "ApiError",
"AudioApi",
"BotApi", "BotApi",
"ChannelApi", "ChannelApi",
"ForumApi",
"GroupApi",
"GuildApi", "GuildApi",
"PanelApi",
"V2Api", "V2Api",
"button", "button",
"keyboard", "keyboard",
+45
View File
@@ -0,0 +1,45 @@
"""音频方法集(仅音频类机器人):子频道播放控制与上/下麦。
需联系平台开通权限后调用;控制与上/下麦均触发 AUDIO_ACTION 事件。
"""
from typing import Any
from .core import BaseApi
# 播放状态(AudioControl.status)
AUDIO_STATUS_START = 0
AUDIO_STATUS_PAUSE = 1
AUDIO_STATUS_RESUME = 2
AUDIO_STATUS_STOP = 3
class AudioApi(BaseApi):
"""音频方法集;请求内核(鉴权/重试/错误)由 BaseApi 提供。"""
async def control_channel_audio(
self,
channel_id: str,
audio_url: str | None = None,
text: str | None = None,
status: int = AUDIO_STATUS_START,
) -> dict:
"""控制子频道音频播放(POST /channels/{channel_id}/audio)。
status 为 START/PAUSE/RESUME/STOP(AUDIO_STATUS_* 常量);
audio_url 与状态文本 text 仅 START 时传。
"""
body: dict[str, Any] = {"status": status}
if audio_url is not None:
body["audio_url"] = audio_url
if text is not None:
body["text"] = text
return await self.post(f"/channels/{channel_id}/audio", json=body)
async def put_channel_mic(self, channel_id: str) -> dict:
"""机器人上麦(PUT /channels/{channel_id}/mic)。"""
return await self.put(f"/channels/{channel_id}/mic")
async def delete_channel_mic(self, channel_id: str) -> dict:
"""机器人下麦(DELETE /channels/{channel_id}/mic)。"""
return await self.delete(f"/channels/{channel_id}/mic")
+120 -9
View File
@@ -1,10 +1,30 @@
"""频道消息方法集(channel_id 接入面):发送、撤回与表情表态。""" """频道消息方法集(channel_id 接入面):发送、撤回、表情表态、
子频道权限与频道私信。"""
from typing import Any from typing import Any
from .core import BaseApi from .core import BaseApi
def _message_body(
content: str,
msg_id: str | None,
msg_seq: int,
image: str | None,
extra: dict[str, Any],
) -> dict[str, Any]:
"""频道系消息(频道/私信)的请求体:msg_seq 必带,其余按需。"""
body: dict[str, Any] = {"msg_seq": msg_seq}
if content:
body["content"] = content
if msg_id:
body["msg_id"] = msg_id
if image:
body["image"] = image
body.update(extra)
return body
class ChannelApi(BaseApi): class ChannelApi(BaseApi):
"""频道消息方法集;请求内核(鉴权/重试/错误)由 BaseApi 提供。""" """频道消息方法集;请求内核(鉴权/重试/错误)由 BaseApi 提供。"""
@@ -21,14 +41,7 @@ class ChannelApi(BaseApi):
image 为图片 URL;其余字段(embed/ark/markdown 等)经 extra 透传。 image 为图片 URL;其余字段(embed/ark/markdown 等)经 extra 透传。
""" """
body: dict[str, Any] = {"msg_seq": msg_seq} body = _message_body(content, msg_id, msg_seq, image, extra)
if content:
body["content"] = content
if msg_id:
body["msg_id"] = msg_id
if image:
body["image"] = image
body.update(extra)
return await self.post(f"/channels/{channel_id}/messages", json=body) return await self.post(f"/channels/{channel_id}/messages", json=body)
async def withdraw_channel_message( async def withdraw_channel_message(
@@ -64,3 +77,101 @@ class ChannelApi(BaseApi):
f"/channels/{channel_id}/messages/{message_id}" f"/channels/{channel_id}/messages/{message_id}"
f"/reactions/{emoji_type}/{emoji_id}" f"/reactions/{emoji_type}/{emoji_id}"
) )
# ---------- 子频道权限 ----------
async def get_channel_permissions(self, channel_id: str, user_id: str) -> dict:
"""获取子频道指定用户的权限(GET .../members/{user_id}/permissions)。
返回 permissions 字符串形式的权限位图(各权限位含义见官方文档)。
"""
return await self.get(f"/channels/{channel_id}/members/{user_id}/permissions")
async def get_channel_role_permissions(
self, channel_id: str, role_id: str
) -> dict:
"""获取子频道指定身份组的权限(GET .../roles/{role_id}/permissions)。"""
return await self.get(f"/channels/{channel_id}/roles/{role_id}/permissions")
async def update_channel_permissions(
self,
channel_id: str,
user_id: str,
*,
add: str | None = None,
remove: str | None = None,
) -> dict:
"""修改子频道指定用户的权限(PUT .../members/{user_id}/permissions)。
add/remove 为字符串形式的权限位图(要赋予/删除的权限位),
至少传一个;需要管理员权限。
"""
body: dict[str, Any] = {}
if add is not None:
body["add"] = add
if remove is not None:
body["remove"] = remove
return await self.put(
f"/channels/{channel_id}/members/{user_id}/permissions", json=body
)
async def update_channel_role_permissions(
self,
channel_id: str,
role_id: str,
*,
add: str | None = None,
remove: str | None = None,
) -> dict:
"""修改子频道指定身份组的权限(PUT .../roles/{role_id}/permissions)。"""
body: dict[str, Any] = {}
if add is not None:
body["add"] = add
if remove is not None:
body["remove"] = remove
return await self.put(
f"/channels/{channel_id}/roles/{role_id}/permissions", json=body
)
# ---------- 频道私信 ----------
async def create_dms(self, recipient_id: str, source_guild_id: str) -> dict:
"""创建私信会话(POST /users/@me/dms)。
机器人和用户存在共同频道才能创建;返回私信的 guild_id 与
channel_id,后续发/撤私信用它作路径参数。
"""
return await self.post(
"/users/@me/dms",
json={"recipient_id": recipient_id, "source_guild_id": source_guild_id},
)
async def post_dms_message(
self,
guild_id: str,
content: str = "",
msg_id: str | None = None,
msg_seq: int = 1,
image: str | None = None,
**extra: Any,
) -> dict:
"""发私信消息(POST /dms/{guild_id}/messages)。
guild_id 为 create_dms 返回(或 DIRECT_MESSAGE_CREATE 事件携带)
的私信频道 id;参数与 post_channel_message 一致。私信每天可对单
用户发 2 条主动消息、累计 200 条,被动消息不限。
"""
body = _message_body(content, msg_id, msg_seq, image, extra)
return await self.post(f"/dms/{guild_id}/messages", json=body)
async def withdraw_dms_message(
self, guild_id: str, message_id: str, hide_tip: bool = False
) -> dict:
"""撤回私信消息(DELETE /dms/{guild_id}/messages/{message_id})。私域接口。
只能撤回机器人自己发送的私信;hide_tip 隐藏撤回提示小灰条。
"""
return await self.delete(
f"/dms/{guild_id}/messages/{message_id}",
params={"hidetip": str(hide_tip).lower()},
)
+59
View File
@@ -0,0 +1,59 @@
"""论坛方法集(私域,仅论坛子频道):帖子列表、详情、发帖与删帖。
发帖/删帖为异步任务(返回 task_id),结果经 FORUM_* 事件推送;
content 为 FORMAT_JSON 时参照官方 RichText 结构。仅私域机器人可用。
"""
from typing import Any
from .core import BaseApi
# 帖子内容格式(format)
FORUM_FORMAT_TEXT = 1
FORUM_FORMAT_HTML = 2
FORUM_FORMAT_MARKDOWN = 3
FORUM_FORMAT_JSON = 4
class ForumApi(BaseApi):
"""论坛方法集;请求内核(鉴权/重试/错误)由 BaseApi 提供。"""
async def get_channel_threads(self, channel_id: str) -> dict:
"""获取帖子列表(GET /channels/{channel_id}/threads)。
返回 {"threads": [...], "is_finish": ...}(1 表示已拉取完毕);
threads 内 content 为 RichText 结构。
"""
return await self.get(f"/channels/{channel_id}/threads")
async def get_channel_thread(self, channel_id: str, thread_id: str) -> dict:
"""获取帖子详情(GET .../threads/{thread_id}),含首楼与回复。"""
return await self.get(f"/channels/{channel_id}/threads/{thread_id}")
async def put_channel_thread(
self,
channel_id: str,
title: str,
content: str,
format: int = FORUM_FORMAT_TEXT,
**extra: Any,
) -> dict:
"""发表帖子(PUT /channels/{channel_id}/threads)。
format 为 FORUM_FORMAT_* 常量;返回 {"task_id": ...},发表结果
经 FORUM_THREAD_CREATE 事件推送。
"""
body: dict[str, Any] = {
"title": title,
"content": content,
"format": format,
}
body.update(extra)
return await self.put(f"/channels/{channel_id}/threads", json=body)
async def delete_channel_thread(self, channel_id: str, thread_id: str) -> dict:
"""删除帖子(DELETE .../threads/{thread_id})。
返回 {"task_id": ...},删除结果经 FORUM_THREAD_DELETE 事件推送。
"""
return await self.delete(f"/channels/{channel_id}/threads/{thread_id}")
+263
View File
@@ -0,0 +1,263 @@
"""群管理方法集(群 openid 接入面):群信息、成员、机器人群内状态、
入群申请审批、群黑名单、禁言查询与入群自动审批策略。
多数接口要求机器人拥有群管理员身份;成员/黑名单/批量移除等能力
(官方标注"内邀接入中")与群信息等接口仅白名单机器人可用,未开通时
服务端返回 11253(ApiError.code)。入群自动审批策略是机器人维度的
全局配置(路径不含群 openid),关联群时 group_openids 与 group_ids
二选一(后者为 QQ 群号),最多 100 个,一个机器人最多 20 个策略。
"""
from typing import Any
from .core import BaseApi
# 审批/黑名单/白名单的操作取值
OP_ADD = "add"
OP_DEL = "del"
APPROVE = "approve"
DECLINE = "decline"
class GroupApi(BaseApi):
"""群管理方法集;请求内核(鉴权/重试/错误)由 BaseApi 提供。"""
# ---------- 群信息与成员 ----------
async def get_group_info(self, group_openid: str) -> dict:
"""获取群基本信息(GET /v2/groups/{group_openid}/info)。
返回群名称/简介/分类/标签/成员数等;仅白名单机器人可用。
"""
return await self.get(f"/v2/groups/{group_openid}/info")
async def get_group_members(self, group_openid: str, cursor: str | None = None) -> dict:
"""获取群成员列表(GET /v2/groups/{group_openid}/members)。
每次最多返回 30 条,翻页透传上次响应的 next_cursor(空串=末页)。
"""
params = {"cursor": cursor} if cursor else None
return await self.get(f"/v2/groups/{group_openid}/members", params=params)
async def get_group_member(self, group_openid: str, member_openid: str) -> dict:
"""获取指定群成员信息(GET .../members/{member_openid})。
返回昵称、角色(member/owner/admin)、入群时间等。
"""
return await self.get(
f"/v2/groups/{group_openid}/members/{member_openid}"
)
async def get_group_bot_state(self, group_openid: str) -> dict:
"""获取机器人在指定群内的状态(GET .../bot_state)。
返回机器人入群时间、是否允许主动推送(allow_proactive_msg)、
群内消息接收设置(recv_msg_setting)与自身角色。
"""
return await self.get(f"/v2/groups/{group_openid}/bot_state")
async def remove_group_members(
self,
group_openid: str,
member_openids: list[str],
add_to_member_blacklist: bool = False,
) -> dict:
"""批量移除群成员(POST .../batch_remove_members),单次最多 20 个。
add_to_member_blacklist=True 同时加入群黑名单;返回含拉黑失败
的 openid 列表。
"""
body: dict[str, Any] = {"member_openids": member_openids}
if add_to_member_blacklist:
body["add_to_member_blacklist"] = True
return await self.post(
f"/v2/groups/{group_openid}/batch_remove_members", json=body
)
# ---------- 入群申请 ----------
async def get_group_join_requests(
self, group_openid: str, cursor: str | None = None, limit: int | None = None
) -> dict:
"""拉取入群申请列表(GET .../join_request_list),需群管理员身份。
返回 {"list": [...], "next_cursor": ...};申请含 join_request_id
(审批时回传)、验证方式与问答内容。limit 默认 20、最大 50。
"""
params: dict[str, Any] = {}
if cursor:
params["cursor"] = cursor
if limit is not None:
params["limit"] = limit
return await self.get(
f"/v2/groups/{group_openid}/join_request_list",
params=params or None,
)
async def review_group_join_request(
self,
group_openid: str,
member_openid: str,
op: str,
join_request_id: str | None = None,
reject_reason: str | None = None,
add_to_member_blacklist: bool | None = None,
) -> dict:
"""审批入群申请(POST .../approval_join_request/{member_openid})。
op 为 APPROVE(通过)或 DECLINE(拒绝),需群管理员身份;
拒绝时可附 reject_reason 并以 add_to_member_blacklist=True
同时加入群黑名单。
"""
body: dict[str, Any] = {"op": op}
if join_request_id:
body["join_request_id"] = join_request_id
if reject_reason:
body["reject_reason"] = reject_reason
if add_to_member_blacklist is not None:
body["add_to_member_blacklist"] = add_to_member_blacklist
return await self.post(
f"/v2/groups/{group_openid}/approval_join_request/{member_openid}",
json=body,
)
# ---------- 群黑名单与禁言查询 ----------
async def get_group_blacklist(
self, group_openid: str, cursor: str | None = None, limit: int | None = None
) -> dict:
"""查询群黑名单列表(GET .../member_blacklist),支持分页。
limit 默认 20、最大 100;返回 {"users": [...], "next_cursor": ...}。
"""
params: dict[str, Any] = {}
if cursor:
params["cursor"] = cursor
if limit is not None:
params["limit"] = limit
return await self.get(
f"/v2/groups/{group_openid}/member_blacklist", params=params or None
)
async def update_group_blacklist(
self, group_openid: str, op: str, member_openids: list[str]
) -> dict:
"""群黑名单增删(POST .../member_blacklist),单次最多 20 个。
op 为 OP_ADD(加入)或 OP_DEL(移出);目标成员在群中时无法
加入黑名单(先移除再加)。返回失败的 openid 列表。
"""
return await self.post(
f"/v2/groups/{group_openid}/member_blacklist",
json={"op": op, "member_openids": member_openids},
)
async def get_group_mute_state(self, group_openid: str) -> dict:
"""查询群禁言状态(GET .../restrict_chat_setting),需群管理员身份。
返回全员禁言规则(global_rule:定时/周期禁言)与当前被禁言的
成员列表(members,不含已过期)。
"""
return await self.get(f"/v2/groups/{group_openid}/restrict_chat_setting")
# ---------- 入群自动审批策略(机器人维度全局配置) ----------
async def get_join_approval_strategies(
self, cursor: str | None = None, limit: int | None = None
) -> dict:
"""查询入群自动审批策略列表(GET /v2/groups/join_approval_strategy)。
按创建时间倒序,返回 {"strategies": [...], "next_cursor": ...};
limit 默认 20、最大 50。
"""
params: dict[str, Any] = {}
if cursor:
params["cursor"] = cursor
if limit is not None:
params["limit"] = limit
return await self.get(
"/v2/groups/join_approval_strategy", params=params or None
)
async def create_join_approval_strategy(
self,
*,
group_openids: list[str] | None = None,
group_ids: list[str] | None = None,
is_enable: str | None = None,
expire_at: str | None = None,
remark: str | None = None,
**extra: Any,
) -> dict:
"""创建入群自动审批策略(POST /v2/groups/join_approval_strategy)。
group_openids 与 group_ids(QQ 群号,字符串避免 JS 精度问题)
二选一必填,互斥,最多 100 个;is_enable "on"/"off"(默认 on);
expire_at 为 RFC3339 过期时间(默认一年);策略仅当机器人拥有
群管理员身份时生效。返回服务端生成的 strategy_id。
"""
body: dict[str, Any] = {}
if group_openids is not None:
body["group_openids"] = group_openids
if group_ids is not None:
body["group_ids"] = group_ids
if is_enable is not None:
body["is_enable"] = is_enable
if expire_at is not None:
body["expire_at"] = expire_at
if remark is not None:
body["remark"] = remark
body.update(extra)
return await self.post("/v2/groups/join_approval_strategy", json=body)
async def update_join_approval_strategy(
self,
strategy_id: str,
*,
is_enable: str | None = None,
expire_at: str | None = None,
group_action: dict | None = None,
remark: str | None = None,
) -> dict:
"""修改入群自动审批策略(PATCH /v2/groups/join_approval_strategy/{id})。
只传变更项;group_action 为 {"op": "add"/"del", "group_openids":
[...]} 或带 "group_ids",群标识形式须与创建时一致。
"""
body: dict[str, Any] = {}
if is_enable is not None:
body["is_enable"] = is_enable
if expire_at is not None:
body["expire_at"] = expire_at
if group_action is not None:
body["group_action"] = group_action
if remark is not None:
body["remark"] = remark
return await self.patch(
f"/v2/groups/join_approval_strategy/{strategy_id}", json=body
)
async def delete_join_approval_strategy(self, strategy_id: str) -> dict:
"""删除入群自动审批策略(DELETE /v2/groups/join_approval_strategy/{id})。"""
return await self.delete(f"/v2/groups/join_approval_strategy/{strategy_id}")
async def execute_join_approval_strategy(self, strategy_id: str) -> dict:
"""执行入群自动审批策略(POST .../{strategy_id}/execute)。
对策略关联的全部群发起全量扫描,命中白名单号码的入群申请自动
审批通过;异步执行,约 10 分钟完成。
"""
return await self.post(f"/v2/groups/join_approval_strategy/{strategy_id}/execute")
async def update_join_approval_strategy_whitelist(
self, strategy_id: str, op: str, whitelist_users: list[str]
) -> dict:
"""增删策略白名单 QQ 号码(POST .../{strategy_id}/whitelist_users)。
op 为 OP_ADD/OP_DEL;whitelist_users 为 QQ 号码字符串列表
(避免 JS 精度问题),单次最多 10000 个,号码上限 10 万。
"""
return await self.post(
f"/v2/groups/join_approval_strategy/{strategy_id}/whitelist_users",
json={"op": op, "whitelist_users": whitelist_users},
)
+224
View File
@@ -130,18 +130,242 @@ class GuildApi(BaseApi):
"""获取频道信息(GET /guilds/{guild_id})。""" """获取频道信息(GET /guilds/{guild_id})。"""
return await self.get(f"/guilds/{guild_id}") return await self.get(f"/guilds/{guild_id}")
async def get_me_guilds(
self,
before: str | None = None,
after: str | None = None,
limit: int | None = None,
) -> list | dict:
"""获取机器人加入的频道列表(GET /users/@me/guilds)。
before/after 为分页锚点 guild_id(同时传时 after 无效);limit
默认 100、最大 100。
"""
params: dict[str, Any] = {}
if before:
params["before"] = before
if after:
params["after"] = after
if limit is not None:
params["limit"] = limit
return await self.get("/users/@me/guilds", params=params or None)
async def get_guild_channels(self, guild_id: str) -> list | dict: async def get_guild_channels(self, guild_id: str) -> list | dict:
"""获取频道下的子频道列表(GET /channels/{guild_id}/channels)。""" """获取频道下的子频道列表(GET /channels/{guild_id}/channels)。"""
return await self.get(f"/channels/{guild_id}/channels") return await self.get(f"/channels/{guild_id}/channels")
async def create_channel(self, guild_id: str, name: str, **fields: Any) -> dict:
"""创建子频道(POST /guilds/{guild_id}/channels)。私域接口,需管理员权限。
fields 透传 type/sub_type/position/parent_id/private_type/
private_user_ids/speak_permission/application_id;成功后触发
CHANNEL_CREATE 事件。
"""
return await self.post(
f"/guilds/{guild_id}/channels", json={"name": name, **fields}
)
async def update_channel(self, channel_id: str, **fields: Any) -> dict:
"""修改子频道(PATCH /channels/{channel_id})。私域接口,需管理员权限。
fields 为要修改的字段(name/position/parent_id/private_type/
speak_permission),只需传变更项;成功后触发 CHANNEL_UPDATE 事件。
"""
return await self.patch(f"/channels/{channel_id}", json=fields)
async def delete_channel(self, channel_id: str) -> dict:
"""删除子频道(DELETE /channels/{channel_id})。私域接口,需管理员权限;
删除后不可恢复,成功后触发 CHANNEL_DELETE 事件。"""
return await self.delete(f"/channels/{channel_id}")
async def get_channel(self, channel_id: str) -> dict: async def get_channel(self, channel_id: str) -> dict:
"""获取子频道信息(GET /channels/{channel_id})。""" """获取子频道信息(GET /channels/{channel_id})。"""
return await self.get(f"/channels/{channel_id}") return await self.get(f"/channels/{channel_id}")
async def get_channel_online_nums(self, channel_id: str) -> dict:
"""获取子频道在线成员数(GET /channels/{channel_id}/online_nums)。"""
return await self.get(f"/channels/{channel_id}/online_nums")
async def get_guild_roles(self, guild_id: str) -> dict: async def get_guild_roles(self, guild_id: str) -> dict:
"""获取频道身份组列表(GET /guilds/{guild_id}/roles)。""" """获取频道身份组列表(GET /guilds/{guild_id}/roles)。"""
return await self.get(f"/guilds/{guild_id}/roles") return await self.get(f"/guilds/{guild_id}/roles")
async def create_guild_role(
self,
guild_id: str,
name: str = "",
color: int | None = None,
hoist: int | None = None,
) -> dict:
"""创建频道身份组(POST /guilds/{guild_id}/roles)。
color 为 ARGB HEX 十六进制颜色值转十进制;hoist=1 在成员列表
中单独展示。返回 {"role_id": ..., "role": {...}}。需要管理员权限。
"""
body: dict[str, Any] = {}
if name:
body["name"] = name
if color is not None:
body["color"] = color
if hoist is not None:
body["hoist"] = hoist
return await self.post(f"/guilds/{guild_id}/roles", json=body)
async def update_guild_role(
self,
guild_id: str,
role_id: str,
name: str | None = None,
color: int | None = None,
hoist: int | None = None,
) -> dict:
"""修改频道身份组(PATCH /guilds/{guild_id}/roles/{role_id})。
只传变更项;默认身份组(role_id=1~5,见官方文档)不可修改。
需要管理员权限。
"""
body: dict[str, Any] = {}
if name is not None:
body["name"] = name
if color is not None:
body["color"] = color
if hoist is not None:
body["hoist"] = hoist
return await self.patch(f"/guilds/{guild_id}/roles/{role_id}", json=body)
async def delete_guild_role(self, guild_id: str, role_id: str) -> dict:
"""删除频道身份组(DELETE /guilds/{guild_id}/roles/{role_id})。
只能删除自己创建的身份组;需要管理员权限。
"""
return await self.delete(f"/guilds/{guild_id}/roles/{role_id}")
async def add_guild_member_role(
self, guild_id: str, user_id: str, role_id: str
) -> dict:
"""给成员授予身份组(PUT /guilds/{guild_id}/members/{user_id}/roles/{role_id})。
需要管理员权限(同身份组的成员也可操作)。
"""
return await self.put(
f"/guilds/{guild_id}/members/{user_id}/roles/{role_id}"
)
async def remove_guild_member_role(
self, guild_id: str, user_id: str, role_id: str
) -> dict:
"""删除成员的身份组(DELETE .../roles/{role_id})。需要管理员权限。"""
return await self.delete(
f"/guilds/{guild_id}/members/{user_id}/roles/{role_id}"
)
async def get_guild_member(self, guild_id: str, user_id: str) -> dict: async def get_guild_member(self, guild_id: str, user_id: str) -> dict:
"""获取成员详情(GET /guilds/{guild_id}/members/{user_id})。""" """获取成员详情(GET /guilds/{guild_id}/members/{user_id})。"""
return await self.get(f"/guilds/{guild_id}/members/{user_id}") return await self.get(f"/guilds/{guild_id}/members/{user_id}")
async def get_guild_members(self, guild_id: str) -> list | dict:
"""获取频道成员列表(GET /guilds/{guild_id}/members)。
1 分钟内最多获取 20 次,每次最多 100 人;仅私域机器人可用。
"""
return await self.get(f"/guilds/{guild_id}/members")
async def remove_guild_member(
self,
guild_id: str,
user_id: str,
add_blacklist: bool = False,
delete_history_msg_days: int = 0,
) -> dict:
"""踢出频道成员(DELETE /guilds/{guild_id}/members/{user_id})。私域接口。
需要机器人具备踢人权限(管理员);无法移除管理员。add_blacklist
同时拉黑;delete_history_msg_days 撤回其消息,仅支持 3/7/15/30
或 -1(全部),默认 0 不撤回。
"""
body: dict[str, Any] = {}
if add_blacklist:
body["add_blacklist"] = True
if delete_history_msg_days:
body["delete_history_msg_days"] = delete_history_msg_days
return await self.delete(
f"/guilds/{guild_id}/members/{user_id}", json=body or None
)
async def get_role_members(self, guild_id: str, role_id: str) -> list | dict:
"""获取身份组成员列表(GET /guilds/{guild_id}/roles/{role_id}/members)。
1 分钟内最多获取 20 次,每次最多 100 人;仅私域机器人可用。
"""
return await self.get(f"/guilds/{guild_id}/roles/{role_id}/members")
# ---------- 禁言与消息设置 ----------
async def mute_guild(
self,
guild_id: str,
mute_end_timestamp: str | None = None,
mute_seconds: str | None = None,
) -> dict:
"""频道全员禁言(PATCH /guilds/{guild_id}/mute)。
mute_end_timestamp(秒级绝对时间戳)与 mute_seconds(字符串秒数)
二选一,同时传以前者为准;解除全员禁言两个传 "0"。需要管理员权限。
"""
body: dict[str, Any] = {}
if mute_end_timestamp is not None:
body["mute_end_timestamp"] = mute_end_timestamp
if mute_seconds is not None:
body["mute_seconds"] = mute_seconds
return await self.patch(f"/guilds/{guild_id}/mute", json=body)
async def mute_guild_members(
self,
guild_id: str,
user_ids: list[str],
mute_end_timestamp: str | None = None,
mute_seconds: str | None = None,
) -> dict:
"""频道批量成员禁言(PATCH /guilds/{guild_id}/mute,带 user_ids)。
一次最多禁言 20 人;时间字段规则同 mute_guild,解除时传 "0"。
"""
body: dict[str, Any] = {"user_ids": user_ids}
if mute_end_timestamp is not None:
body["mute_end_timestamp"] = mute_end_timestamp
if mute_seconds is not None:
body["mute_seconds"] = mute_seconds
return await self.patch(f"/guilds/{guild_id}/mute", json=body)
async def get_guild_message_setting(self, guild_id: str) -> dict:
"""获取频道消息频率设置详情(GET /guilds/{guild_id}/message/setting)。"""
return await self.get(f"/guilds/{guild_id}/message/setting")
# ---------- 接口权限 ----------
async def get_guild_api_permissions(self, guild_id: str) -> dict:
"""获取机器人在频道内的可用接口权限列表(GET .../api_permission)。"""
return await self.get(f"/guilds/{guild_id}/api_permission")
async def create_api_permission_demand(
self,
guild_id: str,
channel_id: str,
path: str,
method: str,
desc: str,
) -> dict:
"""申请频道接口权限(POST /guilds/{guild_id}/api_permission/demand)。
生成权限授权链接发到子频道 channel_id,由频道管理员点击授权;
path/method 为要申请的接口(如 "/channels/{channel_id}/messages",
method 为 GET/POST…),desc 说明申请后机器人能实现的功能。
"""
return await self.post(
f"/guilds/{guild_id}/api_permission/demand",
json={
"channel_id": channel_id,
"api_identify": {"path": path, "method": method},
"desc": desc,
},
)
+137
View File
@@ -0,0 +1,137 @@
"""指令面板(斜线指令)与全局自定义菜单方法集。
指令面板即用户在聊天输入框输入 "/" 唤起的指令列表,支持 c2c(单聊)、
group(群聊)、channel(文字子频道)、dm(频道私信)四种场景;c2c/group
支持 target_type=specific 按指定对象生效(关联对象每次最多 20 个,面板
详情最多查 1000 个),channel/dm 仅全局生效。一个机器人最多 20 个面板,
每个面板最多 20 个元素(name ≤14 字符,desc ≤30 字符)。
全局自定义菜单(/v2/menu)仅 C2C 场景、对所有用户生效;send_message
类型点击后把文本(如 "/help")填入输入框,常与斜线指令配合引导用户。
"""
from typing import Any
from .core import BaseApi
# scope 取值(生效场景)
SCOPE_C2C = "c2c"
SCOPE_GROUP = "group"
SCOPE_CHANNEL = "channel"
SCOPE_DM = "dm"
# target_type 取值(作用范围)
TARGET_ALL = "all"
TARGET_SPECIFIC = "specific"
# 面板元素 type 取值
ITEM_COMMAND = "command"
ITEM_LINK = "link"
# 关联对象操作 op 取值
TARGET_OP_ADD = "add"
TARGET_OP_DEL = "del"
class PanelApi(BaseApi):
"""指令面板与全局菜单方法集;请求内核(鉴权/重试/错误)由 BaseApi 提供。"""
async def create_panel(
self,
scope: str,
panel: dict,
*,
target_type: str | None = None,
user_openids: list[str] | None = None,
group_openids: list[str] | None = None,
**extra: Any,
) -> dict:
"""创建指令面板(POST /v2/panels),返回 {"panel_id": ...}。
panel 为 {"items": [...], "remark": ...} 结构,元素为
{"type": "command"/"link", "name", "desc", "only_admin", "link"}:
command 类型点击后 name 填入聊天输入框,link 类型点击跳转 link。
target_type=specific 时经 user_openids(仅 c2c)或 group_openids
(仅 group)指定生效对象;channel/dm 仅全局(target_type 只能 all)。
"""
body: dict[str, Any] = {"scope": scope, "panel": panel}
if target_type is not None:
body["target_type"] = target_type
if user_openids is not None:
body["user_openids"] = user_openids
if group_openids is not None:
body["group_openids"] = group_openids
body.update(extra)
return await self.post("/v2/panels", json=body)
async def get_panels(
self, scope: str, cursor: str | None = None, limit: int | None = None
) -> dict:
"""分页查询指令面板列表(GET /v2/panels),按设置时间倒序。
返回 {"records": [...], "next_cursor": ..., "is_end": ...};
next_cursor 为空串表示已到末页,翻页透传上次响应的 next_cursor。
limit 为每页条数,默认 20、最大 50。
"""
params: dict[str, Any] = {"scope": scope}
if cursor:
params["cursor"] = cursor
if limit is not None:
params["limit"] = limit
return await self.get("/v2/panels", params=params)
async def get_panel(self, panel_id: str) -> dict:
"""查询指令面板详情(GET /v2/panels/{panel_id})。
返回面板配置与生效场景/范围;specific 面板另带关联的
user_openids / group_openids 列表(最多 1000 条)。
"""
return await self.get(f"/v2/panels/{panel_id}")
async def update_panel(self, panel_id: str, panel: dict) -> dict:
"""修改指令面板(PUT /v2/panels/{panel_id}),返回 {"version": ...}。
panel 整体覆盖面板元素列表与备注,不影响已关联的用户/群列表。
"""
return await self.put(f"/v2/panels/{panel_id}", json={"panel": panel})
async def delete_panel(self, panel_id: str) -> dict:
"""删除指令面板(DELETE /v2/panels/{panel_id}),删除后对所有对象失效。"""
return await self.delete(f"/v2/panels/{panel_id}")
async def update_panel_targets(
self,
panel_id: str,
op: str,
user_openids: list[str] | None = None,
group_openids: list[str] | None = None,
) -> dict:
"""增删面板关联对象(PUT /v2/panels/{panel_id}/target)。
op:"add" 添加 / "del" 移除;user_openids 仅 c2c、group_openids
仅 group,一次最多 20 个。channel/dm 场景与 target_type=all 的
全局面板不支持此操作。
"""
body: dict[str, Any] = {"op": op}
if user_openids is not None:
body["user_openids"] = user_openids
if group_openids is not None:
body["group_openids"] = group_openids
return await self.put(f"/v2/panels/{panel_id}/target", json=body)
# ---------- 全局自定义菜单(仅 C2C) ----------
async def get_menu(self) -> dict:
"""查询全局自定义菜单(GET /v2/menu),返回 {"version": ..., "menu": ...}。
menu 为 {"items": [...]},元素为 {"type": "switch"/"send_message"/
"link"/"menu", "name", "sub_menu_items", "send_message", "link",
"switch"};未设置过菜单时该字段为空。
"""
return await self.get("/v2/menu")
async def update_menu(self, menu: dict | None = None) -> dict:
"""修改全局自定义菜单(PUT /v2/menu),返回 {"version": ...}。
仅 C2C 场景、对所有用户生效;menu 为完整配置,传入后整体覆盖
(菜单项最多 10 个,子菜单最多 5 个且不支持再嵌套)。
"""
body: dict[str, Any] = {"menu": menu} if menu is not None else {}
return await self.put("/v2/menu", json=body)
+191 -2
View File
@@ -1,5 +1,5 @@
"""v2 群聊/单聊方法集(openid 接入面):消息、富媒体上传、撤回、禁言, """v2 群聊/单聊方法集(openid 接入面):消息、流式消息、富媒体上传与
以及内嵌按钮/键盘的快捷构造。 大文件分片上传、撤回、禁言、分享链接,以及内嵌按钮/键盘的快捷构造。
被动回复规则(官方文档 2026-09 核对):携带 msg_id(或 event_id, 被动回复规则(官方文档 2026-09 核对):携带 msg_id(或 event_id,
二选一)为被动回复——单聊 60 分钟内最多 4 次,群聊 5 分钟内最多 5 次; 二选一)为被动回复——单聊 60 分钟内最多 4 次,群聊 5 分钟内最多 5 次;
@@ -21,6 +21,13 @@ FILE_TYPE_IMAGE = 1
FILE_TYPE_VIDEO = 2 FILE_TYPE_VIDEO = 2
FILE_TYPE_VOICE = 3 FILE_TYPE_VOICE = 3
FILE_TYPE_FILE = 4 FILE_TYPE_FILE = 4
# 流式消息取值
STREAM_INPUT_APPEND = "append" # 分片内容追加到待下发正文
STREAM_INPUT_REPLACE = "replace" # 分片内容为全量正文(须以上游已下发前缀开头)
STREAM_GENERATING = 1 # input_state:生成中
STREAM_FINISHED = 10 # input_state:生成结束
STREAM_CONTENT_TEXT = "text"
STREAM_CONTENT_MARKDOWN = "markdown"
def button( def button(
@@ -105,6 +112,93 @@ class V2Api(BaseApi):
f"/v2/users/{openid}", file_type, url, srv_send_msg f"/v2/users/{openid}", file_type, url, srv_send_msg
) )
async def prepare_c2c_upload(
self,
openid: str,
file_type: int,
file_size: int | str,
file_name: str,
md5: str,
sha1: str,
md5_10m: str,
**extra: Any,
) -> dict:
"""单聊大文件分片预上传(POST /v2/users/{openid}/upload_prepare)。
用法同 prepare_group_upload;全部分片完成后携带 upload_id 调
upload_c2c_file 完成合并。
"""
return await self._prepare_upload(
f"/v2/users/{openid}",
file_type,
file_size,
file_name,
md5,
sha1,
md5_10m,
**extra,
)
async def finish_c2c_upload_part(
self,
openid: str,
upload_id: str,
part_index: int,
block_size: int | str | None = None,
md5: str | None = None,
) -> dict:
"""单聊分片上传完成确认(POST .../upload_part_finish)。"""
return await self._finish_upload_part(
f"/v2/users/{openid}", upload_id, part_index, block_size, md5
)
async def post_c2c_stream_message(
self,
openid: str,
content_raw: str = "",
*,
stream_msg_id: str | None = None,
index: int = 0,
input_state: int = STREAM_GENERATING,
input_mode: str | None = None,
content_type: str = STREAM_CONTENT_MARKDOWN,
msg_id: str | None = None,
event_id: str | None = None,
msg_seq: int | None = None,
is_wakeup: bool | None = None,
**extra: Any,
) -> dict:
"""流式分批发送单聊消息(POST /v2/users/{openid}/stream_messages)。
每个分片使用相同 stream_msg_id、index 从 0 递增:首片不传
stream_msg_id,响应的 id 即后续分片要携带的 stream_msg_id;
以 input_state=STREAM_FINISHED 收尾。input_mode 默认 append
(分片拼接),replace 时 content_raw 须以上游已下发前缀开头。
content_type 默认 markdown;msg_id/event_id 二选一为被动回复;
is_wakeup=True 为召回消息,不校验 msg_id/event_id 有效期。
"""
body: dict[str, Any] = {
"index": index,
"input_state": input_state,
"content_type": content_type,
}
if content_raw:
body["content_raw"] = content_raw
if stream_msg_id:
body["stream_msg_id"] = stream_msg_id
if input_mode:
body["input_mode"] = input_mode
if msg_id:
body["msg_id"] = msg_id
elif event_id:
body["event_id"] = event_id
if msg_seq is not None:
body["msg_seq"] = msg_seq
if is_wakeup is not None:
body["is_wakeup"] = is_wakeup
body.update(extra)
return await self.post(f"/v2/users/{openid}/stream_messages", json=body)
async def post_group_message( async def post_group_message(
self, self,
group_openid: str, group_openid: str,
@@ -144,6 +238,52 @@ class V2Api(BaseApi):
f"/v2/groups/{group_openid}", file_type, url, srv_send_msg f"/v2/groups/{group_openid}", file_type, url, srv_send_msg
) )
async def prepare_group_upload(
self,
group_openid: str,
file_type: int,
file_size: int | str,
file_name: str,
md5: str,
sha1: str,
md5_10m: str,
**extra: Any,
) -> dict:
"""群聊大文件分片预上传(POST /v2/groups/{group_openid}/upload_prepare)。
返回 upload_id、分块大小 block_size 与各分片预签名 URL(parts);
将文件按 block_size 分片逐片 PUT 到预签名 URL,每片成功后调
finish_group_upload_part。md5/sha1 为整文件校验值,md5_10m 为
文件前 10002432 字节(约 10MB)的 MD5。全部分片完成后携带
upload_id 调 upload_group_file 完成合并。
"""
return await self._prepare_upload(
f"/v2/groups/{group_openid}",
file_type,
file_size,
file_name,
md5,
sha1,
md5_10m,
**extra,
)
async def finish_group_upload_part(
self,
group_openid: str,
upload_id: str,
part_index: int,
block_size: int | str | None = None,
md5: str | None = None,
) -> dict:
"""群聊分片上传完成确认(POST .../upload_part_finish)。
每个分片 PUT 到预签名 URL 成功后调用,通知服务端该分片已传完。
"""
return await self._finish_upload_part(
f"/v2/groups/{group_openid}", upload_id, part_index, block_size, md5
)
async def _upload_v2_file( async def _upload_v2_file(
self, base: str, file_type: int, url: str, srv_send_msg: bool self, base: str, file_type: int, url: str, srv_send_msg: bool
) -> dict: ) -> dict:
@@ -152,6 +292,44 @@ class V2Api(BaseApi):
json={"file_type": file_type, "url": url, "srv_send_msg": srv_send_msg}, json={"file_type": file_type, "url": url, "srv_send_msg": srv_send_msg},
) )
async def _prepare_upload(
self,
base: str,
file_type: int,
file_size: int | str,
file_name: str,
md5: str,
sha1: str,
md5_10m: str,
**extra: Any,
) -> dict:
# file_size 官方为字符串类型(字节)
body: dict[str, Any] = {
"file_type": file_type,
"file_size": str(file_size),
"file_name": file_name,
"md5": md5,
"sha1": sha1,
"md5_10m": md5_10m,
}
body.update(extra)
return await self.post(f"{base}/upload_prepare", json=body)
async def _finish_upload_part(
self,
base: str,
upload_id: str,
part_index: int,
block_size: int | str | None,
md5: str | None,
) -> dict:
body: dict[str, Any] = {"upload_id": upload_id, "part_index": part_index}
if block_size is not None:
body["block_size"] = str(block_size)
if md5 is not None:
body["md5"] = md5
return await self.post(f"{base}/upload_part_finish", json=body)
async def _post_message( async def _post_message(
self, self,
path: str, path: str,
@@ -216,3 +394,14 @@ class V2Api(BaseApi):
f"/v2/groups/{group_openid}/restrict_chat_setting", f"/v2/groups/{group_openid}/restrict_chat_setting",
json={"mute_expire_at": mute_expire_at}, json={"mute_expire_at": mute_expire_at},
) )
async def generate_url_link(self, callback_data: str | None = None) -> dict:
"""生成机器人分享链接(POST /v2/generate_url_link),返回 {"data": {"url": ...}}。
用于邀请用户添加机器人为好友;callback_data(≤32 字符)在用户
通过链接添加时透传给开发者。
"""
body: dict[str, Any] = {}
if callback_data:
body["callback_data"] = callback_data
return await self.post("/v2/generate_url_link", json=body)
+1
View File
@@ -79,6 +79,7 @@ class Intent(IntFlag):
GUILD_MESSAGES = 1 << 9 GUILD_MESSAGES = 1 << 9
GUILD_MESSAGE_REACTIONS = 1 << 10 GUILD_MESSAGE_REACTIONS = 1 << 10
DIRECT_MESSAGE = 1 << 12 DIRECT_MESSAGE = 1 << 12
GROUP_MEMBER_EVENT = 1 << 24
GROUP_AND_C2C_EVENT = 1 << 25 GROUP_AND_C2C_EVENT = 1 << 25
INTERACTION = 1 << 26 INTERACTION = 1 << 26
MESSAGE_AUDIT = 1 << 27 MESSAGE_AUDIT = 1 << 27
+54
View File
@@ -126,6 +126,56 @@ class GroupMsgReceive:
event_ts: str | None = None event_ts: str | None = None
@dataclass(slots=True)
class GroupMemberChange:
"""群成员加入/退出(GROUP_MEMBER_ADD / GROUP_MEMBER_REMOVE,d 结构一致)。
user_openid 为跨应用统一标识,可能为空。
"""
timestamp: int | None = None
group_openid: str | None = None
member_openid: str | None = None
user_openid: str | None = None
@dataclass(slots=True)
class GroupJoinRequest:
"""用户申请加群(GROUP_JOIN_REQUEST):仅机器人是群管理员时收到。
verify_info 为入群验证方式({"method": "verify_message"/
"admin_review_qa", "verify_message": ..., "review_qa_list": [...]});
auto_approved 非空(含 strategy_id)表示申请已被自动审批策略通过。
join_request_id 需在审批接口(review_group_join_request)回传。
"""
group_openid: str | None = None
join_request_id: str | None = None
member_openid: str | None = None
username: str | None = None
risk_tips: str | None = None
union_openid: str | None = None
apply_at: str | None = None
apply_source: str | None = None
invited_by: str | None = None
bot: bool | None = None
verify_info: dict | None = None
auto_approved: dict | None = None
@dataclass(slots=True)
class SubscribeMessageStatus:
"""订阅消息授权状态变更(SUBSCRIBE_MESSAGE_STATUS)。
result 为各模板授权结果列表:op 1=允许订阅 2=拒绝订阅,
subscribe_id 发送订阅消息时需使用;group_openid/openid 按场景二选一。
"""
group_openid: str | None = None
openid: str | None = None
result: list[dict] | None = None
@dataclass(slots=True) @dataclass(slots=True)
class AtMessage: class AtMessage:
"""频道 @机器人消息(AT_MESSAGE_CREATE / PUBLIC 与私域共用结构)。""" """频道 @机器人消息(AT_MESSAGE_CREATE / PUBLIC 与私域共用结构)。"""
@@ -298,6 +348,10 @@ EVENT_TYPES: dict[str, type] = {
"C2C_MSG_RECEIVE": C2CMsgReceive, "C2C_MSG_RECEIVE": C2CMsgReceive,
"GROUP_MSG_REJECT": GroupMsgReject, "GROUP_MSG_REJECT": GroupMsgReject,
"GROUP_MSG_RECEIVE": GroupMsgReceive, "GROUP_MSG_RECEIVE": GroupMsgReceive,
"GROUP_MEMBER_ADD": GroupMemberChange,
"GROUP_MEMBER_REMOVE": GroupMemberChange,
"GROUP_JOIN_REQUEST": GroupJoinRequest,
"SUBSCRIBE_MESSAGE_STATUS": SubscribeMessageStatus,
"AT_MESSAGE_CREATE": AtMessage, "AT_MESSAGE_CREATE": AtMessage,
"DIRECT_MESSAGE_CREATE": DirectMessage, "DIRECT_MESSAGE_CREATE": DirectMessage,
"GUILD_CREATE": Guild, "GUILD_CREATE": Guild,
+422
View File
@@ -172,6 +172,294 @@ async def test_respond_interaction():
assert kwargs["json"] == {"code": 0} assert kwargs["json"] == {"code": 0}
async def test_panel_create_list_detail():
api, cap = make_api()
panel = {"items": [{"type": "command", "name": "群签到", "desc": "每日签到"}]}
await api.create_panel(
"group", panel, target_type="specific", group_openids=["G1", "G2"]
)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/panels")
assert kwargs["json"] == {
"scope": "group",
"panel": panel,
"target_type": "specific",
"group_openids": ["G1", "G2"],
}
await api.get_panels("c2c", cursor="c1", limit=10)
method, path, kwargs = cap["call"]
assert (method, path) == ("GET", "/v2/panels")
assert kwargs["params"] == {"scope": "c2c", "cursor": "c1", "limit": 10}
await api.get_panel("P1")
assert cap["call"][:2] == ("GET", "/v2/panels/P1")
async def test_panel_update_delete_targets():
api, cap = make_api()
panel = {"items": [{"type": "command", "name": "新指令", "desc": "更新"}]}
await api.update_panel("P1", panel)
method, path, kwargs = cap["call"]
assert (method, path) == ("PUT", "/v2/panels/P1")
assert kwargs["json"] == {"panel": panel}
await api.delete_panel("P1")
assert cap["call"][:2] == ("DELETE", "/v2/panels/P1")
await api.update_panel_targets("P1", "add", group_openids=["G3"])
method, path, kwargs = cap["call"]
assert (method, path) == ("PUT", "/v2/panels/P1/target")
assert kwargs["json"] == {"op": "add", "group_openids": ["G3"]}
await api.update_panel_targets("P1", "del", user_openids=["U1"])
assert cap["call"][2]["json"] == {"op": "del", "user_openids": ["U1"]}
async def test_menu_get_and_update():
api, cap = make_api()
await api.get_menu()
assert cap["call"][:2] == ("GET", "/v2/menu")
menu = {
"items": [
{"type": "send_message", "name": "帮助", "send_message": "/help"},
{"type": "link", "name": "官网", "link": "https://example.com"},
]
}
await api.update_menu(menu)
method, path, kwargs = cap["call"]
assert (method, path) == ("PUT", "/v2/menu")
assert kwargs["json"] == {"menu": menu}
# ---------- v2 补充:流式消息 / 分片上传 / 分享链接 ----------
async def test_stream_message_first_and_next_chunk():
api, cap = make_api()
await api.post_c2c_stream_message("U1", "回答中", msg_id="m1", msg_seq=1)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/users/U1/stream_messages")
assert kwargs["json"] == {
"index": 0,
"input_state": 1,
"content_type": "markdown",
"content_raw": "回答中",
"msg_id": "m1",
"msg_seq": 1,
}
await api.post_c2c_stream_message(
"U1",
"回答中,更多内容",
stream_msg_id="s1",
index=1,
input_state=10,
input_mode="replace",
event_id="e1",
)
body = cap["call"][2]["json"]
assert body["stream_msg_id"] == "s1"
assert body["index"] == 1
assert body["input_state"] == 10
assert body["input_mode"] == "replace"
assert body["event_id"] == "e1"
assert "msg_id" not in body
async def test_chunked_upload_group_and_c2c():
api, cap = make_api()
common = {
"file_type": 2,
"file_size": 31457280,
"file_name": "demo.mp4",
"md5": "m",
"sha1": "s",
"md5_10m": "m10",
}
await api.prepare_group_upload("G1", **common)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/G1/upload_prepare")
assert kwargs["json"] == {
"file_type": 2,
"file_size": "31457280", # 官方为字符串类型
"file_name": "demo.mp4",
"md5": "m",
"sha1": "s",
"md5_10m": "m10",
}
await api.finish_group_upload_part("G1", "up1", part_index=0)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/G1/upload_part_finish")
assert kwargs["json"] == {"upload_id": "up1", "part_index": 0}
await api.prepare_c2c_upload("U1", **common)
assert cap["call"][:2] == ("POST", "/v2/users/U1/upload_prepare")
await api.finish_c2c_upload_part("U1", "up1", part_index=1, block_size=4096, md5="p1")
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/users/U1/upload_part_finish")
assert kwargs["json"] == {
"upload_id": "up1",
"part_index": 1,
"block_size": "4096",
"md5": "p1",
}
async def test_generate_url_link():
api, cap = make_api()
await api.generate_url_link()
assert cap["call"][2]["json"] == {}
await api.generate_url_link("track_01")
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/generate_url_link")
assert kwargs["json"] == {"callback_data": "track_01"}
# ---------- 频道补充:子频道增删改 / 机器人频道列表 ----------
async def test_channel_manage_and_me_guilds():
api, cap = make_api()
await api.create_channel("GID", "公告区", type=0, sub_type=1, position=3)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/guilds/GID/channels")
assert kwargs["json"] == {"name": "公告区", "type": 0, "sub_type": 1, "position": 3}
await api.update_channel("C1", name="新名字", position=5)
method, path, kwargs = cap["call"]
assert (method, path) == ("PATCH", "/channels/C1")
assert kwargs["json"] == {"name": "新名字", "position": 5}
await api.delete_channel("C1")
assert cap["call"][:2] == ("DELETE", "/channels/C1")
await api.get_me_guilds(after="G9", limit=20)
method, path, kwargs = cap["call"]
assert (method, path) == ("GET", "/users/@me/guilds")
assert kwargs["params"] == {"after": "G9", "limit": 20}
await api.get_me_guilds()
assert cap["call"][2].get("params") is None
# ---------- 群管理 ----------
async def test_group_info_members_state():
api, cap = make_api()
await api.get_group_info("G1")
assert cap["call"][:2] == ("GET", "/v2/groups/G1/info")
await api.get_group_members("G1", cursor="c1")
method, path, kwargs = cap["call"]
assert (method, path) == ("GET", "/v2/groups/G1/members")
assert kwargs["params"] == {"cursor": "c1"}
await api.get_group_member("G1", "M1")
assert cap["call"][:2] == ("GET", "/v2/groups/G1/members/M1")
await api.get_group_bot_state("G1")
assert cap["call"][:2] == ("GET", "/v2/groups/G1/bot_state")
await api.get_group_mute_state("G1")
assert cap["call"][:2] == ("GET", "/v2/groups/G1/restrict_chat_setting")
async def test_group_join_request_flow():
api, cap = make_api()
await api.get_group_join_requests("G1", limit=10)
method, path, kwargs = cap["call"]
assert (method, path) == ("GET", "/v2/groups/G1/join_request_list")
assert kwargs["params"] == {"limit": 10}
await api.review_group_join_request(
"G1", "M1", "approve", join_request_id="j1"
)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/G1/approval_join_request/M1")
assert kwargs["json"] == {"op": "approve", "join_request_id": "j1"}
await api.review_group_join_request(
"G1", "M1", "decline", join_request_id="j1",
reject_reason="广告", add_to_member_blacklist=True,
)
body = cap["call"][2]["json"]
assert body == {
"op": "decline",
"join_request_id": "j1",
"reject_reason": "广告",
"add_to_member_blacklist": True,
}
async def test_group_blacklist_and_remove():
api, cap = make_api()
await api.get_group_blacklist("G1", cursor="c1", limit=50)
method, path, kwargs = cap["call"]
assert (method, path) == ("GET", "/v2/groups/G1/member_blacklist")
assert kwargs["params"] == {"cursor": "c1", "limit": 50}
await api.update_group_blacklist("G1", "add", ["M1", "M2"])
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/G1/member_blacklist")
assert kwargs["json"] == {"op": "add", "member_openids": ["M1", "M2"]}
await api.remove_group_members("G1", ["M1"], add_to_member_blacklist=True)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/G1/batch_remove_members")
assert kwargs["json"] == {
"member_openids": ["M1"],
"add_to_member_blacklist": True,
}
async def test_join_approval_strategies():
api, cap = make_api()
await api.get_join_approval_strategies(cursor="c1", limit=20)
method, path, kwargs = cap["call"]
assert (method, path) == ("GET", "/v2/groups/join_approval_strategy")
assert kwargs["params"] == {"cursor": "c1", "limit": 20}
await api.create_join_approval_strategy(
group_openids=["G1"], expire_at="2027-08-05T15:30:16+08:00"
)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/join_approval_strategy")
assert kwargs["json"] == {
"group_openids": ["G1"],
"expire_at": "2027-08-05T15:30:16+08:00",
}
await api.update_join_approval_strategy(
"st1", is_enable="off", group_action={"op": "add", "group_openids": ["G2"]}
)
method, path, kwargs = cap["call"]
assert (method, path) == ("PATCH", "/v2/groups/join_approval_strategy/st1")
assert kwargs["json"] == {
"is_enable": "off",
"group_action": {"op": "add", "group_openids": ["G2"]},
}
await api.delete_join_approval_strategy("st1")
assert cap["call"][:2] == ("DELETE", "/v2/groups/join_approval_strategy/st1")
await api.execute_join_approval_strategy("st1")
assert cap["call"][:2] == ("POST", "/v2/groups/join_approval_strategy/st1/execute")
await api.update_join_approval_strategy_whitelist("st1", "add", ["1234567"])
method, path, kwargs = cap["call"]
assert (method, path) == (
"POST",
"/v2/groups/join_approval_strategy/st1/whitelist_users",
)
assert kwargs["json"] == {"op": "add", "whitelist_users": ["1234567"]}
def test_button_defaults_and_overrides(): def test_button_defaults_and_overrides():
btn = button("点我", data="d1") btn = button("点我", data="d1")
assert btn["render_data"] == {"label": "点我", "style": 1} assert btn["render_data"] == {"label": "点我", "style": 1}
@@ -369,3 +657,137 @@ async def test_request_401_invalidates_token_and_retries_once():
assert token.invalidated == 1 assert token.invalidated == 1
assert token.fetches == 2 assert token.fetches == 2
assert len(http.calls) == 2 assert len(http.calls) == 2
# ---------- 旧版(频道侧)接口补齐 ----------
async def test_guild_roles_crud_and_member_role():
api, cap = make_api()
await api.create_guild_role("GID", "码农", color=0xFF0000, hoist=1)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/guilds/GID/roles")
assert kwargs["json"] == {"name": "码农", "color": 16711680, "hoist": 1}
await api.update_guild_role("GID", "R1", name="新名")
method, path, kwargs = cap["call"]
assert (method, path) == ("PATCH", "/guilds/GID/roles/R1")
assert kwargs["json"] == {"name": "新名"}
await api.delete_guild_role("GID", "R1")
assert cap["call"][:2] == ("DELETE", "/guilds/GID/roles/R1")
await api.add_guild_member_role("GID", "U1", "R1")
assert cap["call"][:2] == ("PUT", "/guilds/GID/members/U1/roles/R1")
await api.remove_guild_member_role("GID", "U1", "R1")
assert cap["call"][:2] == ("DELETE", "/guilds/GID/members/U1/roles/R1")
async def test_guild_members_mutes_and_api_permission():
api, cap = make_api()
await api.get_guild_members("GID")
assert cap["call"][:2] == ("GET", "/guilds/GID/members")
await api.remove_guild_member("GID", "U1", add_blacklist=True, delete_history_msg_days=7)
method, path, kwargs = cap["call"]
assert (method, path) == ("DELETE", "/guilds/GID/members/U1")
assert kwargs["json"] == {"add_blacklist": True, "delete_history_msg_days": 7}
await api.get_role_members("GID", "R1")
assert cap["call"][:2] == ("GET", "/guilds/GID/roles/R1/members")
await api.get_channel_online_nums("C1")
assert cap["call"][:2] == ("GET", "/channels/C1/online_nums")
await api.mute_guild("GID", mute_seconds="60")
method, path, kwargs = cap["call"]
assert (method, path) == ("PATCH", "/guilds/GID/mute")
assert kwargs["json"] == {"mute_seconds": "60"}
await api.mute_guild_members("GID", ["U1", "U2"], mute_end_timestamp="1700000000")
assert cap["call"][2]["json"] == {
"user_ids": ["U1", "U2"],
"mute_end_timestamp": "1700000000",
}
await api.get_guild_message_setting("GID")
assert cap["call"][:2] == ("GET", "/guilds/GID/message/setting")
await api.get_guild_api_permissions("GID")
assert cap["call"][:2] == ("GET", "/guilds/GID/api_permission")
await api.create_api_permission_demand("GID", "C1", "/channels/x/messages", "POST", "d")
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/guilds/GID/api_permission/demand")
assert kwargs["json"] == {
"channel_id": "C1",
"api_identify": {"path": "/channels/x/messages", "method": "POST"},
"desc": "d",
}
async def test_channel_permissions_and_dms():
api, cap = make_api()
await api.get_channel_permissions("C1", "U1")
assert cap["call"][:2] == ("GET", "/channels/C1/members/U1/permissions")
await api.get_channel_role_permissions("C1", "R1")
assert cap["call"][:2] == ("GET", "/channels/C1/roles/R1/permissions")
await api.update_channel_permissions("C1", "U1", add="1", remove="4")
method, path, kwargs = cap["call"]
assert (method, path) == ("PUT", "/channels/C1/members/U1/permissions")
assert kwargs["json"] == {"add": "1", "remove": "4"}
await api.update_channel_role_permissions("C1", "R1", add="2")
assert cap["call"][2]["json"] == {"add": "2"}
await api.create_dms("U1", "GID")
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/users/@me/dms")
assert kwargs["json"] == {"recipient_id": "U1", "source_guild_id": "GID"}
await api.post_dms_message("DG", content="hi", msg_id="m1", msg_seq=2)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/dms/DG/messages")
assert kwargs["json"] == {"msg_seq": 2, "content": "hi", "msg_id": "m1"}
await api.withdraw_dms_message("DG", "m1", hide_tip=True)
method, path, kwargs = cap["call"]
assert (method, path) == ("DELETE", "/dms/DG/messages/m1")
assert kwargs["params"] == {"hidetip": "true"}
async def test_audio_and_forum():
api, cap = make_api()
await api.control_channel_audio("C1", audio_url="http://x/a.mp3", text="歌名")
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/channels/C1/audio")
assert kwargs["json"] == {
"status": 0,
"audio_url": "http://x/a.mp3",
"text": "歌名",
}
await api.control_channel_audio("C1", status=3)
assert cap["call"][2]["json"] == {"status": 3}
await api.put_channel_mic("C1")
assert cap["call"][:2] == ("PUT", "/channels/C1/mic")
await api.delete_channel_mic("C1")
assert cap["call"][:2] == ("DELETE", "/channels/C1/mic")
await api.get_channel_threads("C1")
assert cap["call"][:2] == ("GET", "/channels/C1/threads")
await api.get_channel_thread("C1", "T1")
assert cap["call"][:2] == ("GET", "/channels/C1/threads/T1")
await api.put_channel_thread("C1", "标题", "内容", format=3)
method, path, kwargs = cap["call"]
assert (method, path) == ("PUT", "/channels/C1/threads")
assert kwargs["json"] == {"title": "标题", "content": "内容", "format": 3}
await api.delete_channel_thread("C1", "T1")
assert cap["call"][:2] == ("DELETE", "/channels/C1/threads/T1")
+56
View File
@@ -192,3 +192,59 @@ async def test_untyped_annotation_still_receives_raw_dict():
{"op": Opcode.DISPATCH, "t": "GROUP_AT_MESSAGE_CREATE", "d": {"content": "x"}} {"op": Opcode.DISPATCH, "t": "GROUP_AT_MESSAGE_CREATE", "d": {"content": "x"}}
) )
assert received == [{"content": "x"}] assert received == [{"content": "x"}]
def test_parse_group_member_and_join_request_events():
from qqbotsdk.payloads import (
GroupJoinRequest,
GroupMemberChange,
SubscribeMessageStatus,
)
change = parse_event(
"GROUP_MEMBER_ADD",
{
"timestamp": 1784276757,
"group_openid": "G1",
"member_openid": "M1",
"user_openid": "U1",
},
)
assert isinstance(change, GroupMemberChange)
assert change.member_openid == "M1"
# 两事件 d 结构一致,共用 dataclass
remove = parse_event(
"GROUP_MEMBER_REMOVE",
{"timestamp": 1, "group_openid": "G1", "member_openid": "M2"},
)
assert isinstance(remove, GroupMemberChange)
assert remove.user_openid is None
req = parse_event(
"GROUP_JOIN_REQUEST",
{
"group_openid": "G1",
"join_request_id": "j1",
"member_openid": "M1",
"username": "小明",
"apply_source": "self_apply",
"verify_info": {"method": "verify_message", "verify_message": "来了"},
"auto_approved": {"strategy_id": "st1"},
"unknown_field": 1,
},
)
assert isinstance(req, GroupJoinRequest)
assert req.join_request_id == "j1"
assert req.verify_info == {"method": "verify_message", "verify_message": "来了"}
assert req.auto_approved == {"strategy_id": "st1"}
status = parse_event(
"SUBSCRIBE_MESSAGE_STATUS",
{
"openid": "U1",
"result": [{"template_id": 10001, "op": 1, "subscribe_id": "sub1"}],
},
)
assert isinstance(status, SubscribeMessageStatus)
assert status.group_openid is None
assert status.result[0]["subscribe_id"] == "sub1"