diff --git a/CHANGELOG.md b/CHANGELOG.md index 547df7b..b56237a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 等官方字段 - `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` 回调示例 diff --git a/docs/API.md b/docs/API.md index 8f86e38..3e8d8bb 100644 --- a/docs/API.md +++ b/docs/API.md @@ -97,7 +97,7 @@ async def slow(...): ... `from qqbotsdk.api import BotApi`。经参数注入(`api: BotApi`)。自动附带 `Authorization: QQBot ` 鉴权头并刷新 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=` 等) | | `get` / `post` / `delete` / `put` / `patch`(path, **kwargs) | `request` 快捷方式 | | `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 仅可回应一次 | -常量:`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`**。 @@ -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` 上传即发送 | | `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 到期时间;传早于当前的时间即解禁 | +| `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) @@ -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` | | `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 分钟内可撤回 | +| `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) @@ -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` | | `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} | 删除自己的表态 | +| `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_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"` | | `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} | 移出精华消息 | @@ -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 次 | | `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} | 删除日程 | +| `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 解禁 | ### 频道查询 @@ -159,11 +203,45 @@ async def slow(...): ... | 方法 | 端点 | |---|---| | `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_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_guild_roles(guild_id)` | GET /guilds/{guild_id}/roles | | `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(RuntimeError)`,重试后仍失败(HTTP ≥400)时抛出: @@ -192,6 +270,9 @@ async def slow(...): ... | | `GROUP_ADD_ROBOT` / `GROUP_DEL_ROBOT` | `GroupAddRobot` / `GroupDelRobot` | | | `C2C_MSG_REJECT` / `C2C_MSG_RECEIVE` | `C2CMsgReject` / `C2CMsgReceive` | | | `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_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 AtMessage # id, content, channel_id, guild_id, author, member, timestamp 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` 取发言人标识。 diff --git a/intents.toml b/intents.toml index b7cfc83..94e4801 100644 --- a/intents.toml +++ b/intents.toml @@ -10,7 +10,8 @@ GUILD_MEMBERS = false # (1 << 1) 频道成员增删改 GUILD_MESSAGES = false # (1 << 9) 频道全量消息,仅 *私域* 机器人 GUILD_MESSAGE_REACTIONS = false # (1 << 10) 频道表情表态 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) 互动事件(卡片按钮回调) MESSAGE_AUDIT = false # (1 << 27) 消息审核结果 FORUMS_EVENT = false # (1 << 28) 论坛事件,仅 *私域* 机器人 diff --git a/src/qqbotsdk/api/__init__.py b/src/qqbotsdk/api/__init__.py index 903bf97..da57826 100644 --- a/src/qqbotsdk/api/__init__.py +++ b/src/qqbotsdk/api/__init__.py @@ -1,17 +1,22 @@ -"""OpenAPI 客户端:BotApi 按域组合——v2 群/单聊、频道消息、频道管理。 +"""OpenAPI 客户端:BotApi 按域组合——v2 群/单聊、频道消息、频道管理、 +指令面板(斜线指令)与全局菜单、群管理、音频、论坛。 各域模块是方法集(继承 BaseApi 共享请求内核:鉴权头注入、429 自动 重试、401 刷新 token 重试、统一 ApiError),方法名与拆分前完全一致。 """ +from .audio import AudioApi from .channel import ChannelApi from .core import ApiError from .core import BaseApi as BaseApi +from .forum import ForumApi +from .group import GroupApi from .guild import GuildApi +from .panel import PanelApi from .v2 import V2Api, button, keyboard -class BotApi(V2Api, ChannelApi, GuildApi): +class BotApi(V2Api, ChannelApi, GuildApi, PanelApi, GroupApi, AudioApi, ForumApi): """通用 REST 封装;具体业务接口见各域方法集。 BaseApi(请求内核)经域方法集继承自然落在 MRO 末尾, @@ -21,9 +26,13 @@ class BotApi(V2Api, ChannelApi, GuildApi): __all__ = [ "ApiError", + "AudioApi", "BotApi", "ChannelApi", + "ForumApi", + "GroupApi", "GuildApi", + "PanelApi", "V2Api", "button", "keyboard", diff --git a/src/qqbotsdk/api/audio.py b/src/qqbotsdk/api/audio.py new file mode 100644 index 0000000..3d50345 --- /dev/null +++ b/src/qqbotsdk/api/audio.py @@ -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") diff --git a/src/qqbotsdk/api/channel.py b/src/qqbotsdk/api/channel.py index fa76bf4..509110e 100644 --- a/src/qqbotsdk/api/channel.py +++ b/src/qqbotsdk/api/channel.py @@ -1,10 +1,30 @@ -"""频道消息方法集(channel_id 接入面):发送、撤回与表情表态。""" +"""频道消息方法集(channel_id 接入面):发送、撤回、表情表态、 +子频道权限与频道私信。""" from typing import Any 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): """频道消息方法集;请求内核(鉴权/重试/错误)由 BaseApi 提供。""" @@ -21,14 +41,7 @@ class ChannelApi(BaseApi): image 为图片 URL;其余字段(embed/ark/markdown 等)经 extra 透传。 """ - 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) + body = _message_body(content, msg_id, msg_seq, image, extra) return await self.post(f"/channels/{channel_id}/messages", json=body) async def withdraw_channel_message( @@ -64,3 +77,101 @@ class ChannelApi(BaseApi): f"/channels/{channel_id}/messages/{message_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()}, + ) diff --git a/src/qqbotsdk/api/forum.py b/src/qqbotsdk/api/forum.py new file mode 100644 index 0000000..bf9dada --- /dev/null +++ b/src/qqbotsdk/api/forum.py @@ -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}") diff --git a/src/qqbotsdk/api/group.py b/src/qqbotsdk/api/group.py new file mode 100644 index 0000000..e731d77 --- /dev/null +++ b/src/qqbotsdk/api/group.py @@ -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}, + ) diff --git a/src/qqbotsdk/api/guild.py b/src/qqbotsdk/api/guild.py index 5568313..e73127d 100644 --- a/src/qqbotsdk/api/guild.py +++ b/src/qqbotsdk/api/guild.py @@ -130,18 +130,242 @@ class GuildApi(BaseApi): """获取频道信息(GET /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: """获取频道下的子频道列表(GET /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: """获取子频道信息(GET /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: """获取频道身份组列表(GET /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: """获取成员详情(GET /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, + }, + ) diff --git a/src/qqbotsdk/api/panel.py b/src/qqbotsdk/api/panel.py new file mode 100644 index 0000000..d8015f4 --- /dev/null +++ b/src/qqbotsdk/api/panel.py @@ -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) diff --git a/src/qqbotsdk/api/v2.py b/src/qqbotsdk/api/v2.py index 7a76f10..df64f1d 100644 --- a/src/qqbotsdk/api/v2.py +++ b/src/qqbotsdk/api/v2.py @@ -1,5 +1,5 @@ -"""v2 群聊/单聊方法集(openid 接入面):消息、富媒体上传、撤回、禁言, -以及内嵌按钮/键盘的快捷构造。 +"""v2 群聊/单聊方法集(openid 接入面):消息、流式消息、富媒体上传与 +大文件分片上传、撤回、禁言、分享链接,以及内嵌按钮/键盘的快捷构造。 被动回复规则(官方文档 2026-09 核对):携带 msg_id(或 event_id, 二选一)为被动回复——单聊 60 分钟内最多 4 次,群聊 5 分钟内最多 5 次; @@ -21,6 +21,13 @@ FILE_TYPE_IMAGE = 1 FILE_TYPE_VIDEO = 2 FILE_TYPE_VOICE = 3 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( @@ -105,6 +112,93 @@ class V2Api(BaseApi): 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( self, group_openid: str, @@ -144,6 +238,52 @@ class V2Api(BaseApi): 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( self, base: str, file_type: int, url: str, srv_send_msg: bool ) -> dict: @@ -152,6 +292,44 @@ class V2Api(BaseApi): 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( self, path: str, @@ -216,3 +394,14 @@ class V2Api(BaseApi): f"/v2/groups/{group_openid}/restrict_chat_setting", 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) diff --git a/src/qqbotsdk/model.py b/src/qqbotsdk/model.py index de5d05a..d616c10 100644 --- a/src/qqbotsdk/model.py +++ b/src/qqbotsdk/model.py @@ -79,6 +79,7 @@ class Intent(IntFlag): GUILD_MESSAGES = 1 << 9 GUILD_MESSAGE_REACTIONS = 1 << 10 DIRECT_MESSAGE = 1 << 12 + GROUP_MEMBER_EVENT = 1 << 24 GROUP_AND_C2C_EVENT = 1 << 25 INTERACTION = 1 << 26 MESSAGE_AUDIT = 1 << 27 diff --git a/src/qqbotsdk/payloads.py b/src/qqbotsdk/payloads.py index a54ca4c..aa78e3d 100644 --- a/src/qqbotsdk/payloads.py +++ b/src/qqbotsdk/payloads.py @@ -126,6 +126,56 @@ class GroupMsgReceive: 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) class AtMessage: """频道 @机器人消息(AT_MESSAGE_CREATE / PUBLIC 与私域共用结构)。""" @@ -298,6 +348,10 @@ EVENT_TYPES: dict[str, type] = { "C2C_MSG_RECEIVE": C2CMsgReceive, "GROUP_MSG_REJECT": GroupMsgReject, "GROUP_MSG_RECEIVE": GroupMsgReceive, + "GROUP_MEMBER_ADD": GroupMemberChange, + "GROUP_MEMBER_REMOVE": GroupMemberChange, + "GROUP_JOIN_REQUEST": GroupJoinRequest, + "SUBSCRIBE_MESSAGE_STATUS": SubscribeMessageStatus, "AT_MESSAGE_CREATE": AtMessage, "DIRECT_MESSAGE_CREATE": DirectMessage, "GUILD_CREATE": Guild, diff --git a/tests/test_api.py b/tests/test_api.py index 8a6abc9..4393f72 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -172,6 +172,294 @@ async def test_respond_interaction(): 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(): btn = button("点我", data="d1") 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.fetches == 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") diff --git a/tests/test_payloads.py b/tests/test_payloads.py index 38c291e..8f8dfa9 100644 --- a/tests/test_payloads.py +++ b/tests/test_payloads.py @@ -192,3 +192,59 @@ async def test_untyped_annotation_still_receives_raw_dict(): {"op": Opcode.DISPATCH, "t": "GROUP_AT_MESSAGE_CREATE", "d": {"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"