diff --git a/README.md b/README.md index 10633bdd..1bb43a02 100644 --- a/README.md +++ b/README.md @@ -110,3 +110,9 @@ NoneBot2 的驱动框架 `Driver` 以及通信协议 `Adapter` 均可**自定义 如果你在使用过程中发现任何问题,可以 [提交 issue](https://github.com/nonebot/nonebot2/issues/new) 或自行 fork 修改后提交 pull request。 如果你要提交 pull request,请确保你的代码风格和项目已有的代码保持一致,遵循 [PEP 8](https://www.python.org/dev/peps/pep-0008/),变量命名清晰,有适当的注释。 + +## 许可证 + +`NoneBot` 采用 `MIT` 协议开源,协议文件参考 [LICENSE](./LICENSE)。 + +特别的,由于 `mirai` 使用 `AGPLv3` 协议并要求使用 `mirai` 的软件同样以 `AGPLv3` 协议开源,本项目 `mirai` 适配器部分(即 [`nonebot/adapters/mirai/`](./nonebot/adapters/mirai/) 目录)以 `AGPLv3` 协议开源,协议文件参考 [LICENSE](./nonebot/adapters/mirai/LICENSE)。 diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 516d7ce0..3b011655 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -116,7 +116,7 @@ module.exports = context => ({ title: "协议适配", collapsable: false, sidebar: "auto", - children: ["cqhttp-guide", "ding-guide"] + children: ["cqhttp-guide", "ding-guide", "mirai-guide"] } ], "/advanced/": [ @@ -209,6 +209,10 @@ module.exports = context => ({ { title: "nonebot.adapters.ding 模块", path: "adapters/ding" + }, + { + title: "nonebot.adapters.mirai 模块", + path: "adapters/mirai" } ] } diff --git a/docs/api/README.md b/docs/api/README.md index 243733f8..36e9803e 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -50,3 +50,6 @@ * [nonebot.adapters.ding](adapters/ding.html) + + + * [nonebot.adapters.mirai](adapters/mirai.html) diff --git a/docs/api/adapters/mirai.md b/docs/api/adapters/mirai.md new file mode 100644 index 00000000..4b568152 --- /dev/null +++ b/docs/api/adapters/mirai.md @@ -0,0 +1,1908 @@ +--- +contentSidebar: true +sidebarDepth: 0 +--- + +# NoneBot.adapters.mirai 模块 + +## Mirai-API-HTTP 协议适配 + +协议详情请看: [mirai-api-http 文档](https://github.com/project-mirai/mirai-api-http/tree/master/docs) + +::: tip +该Adapter目前仍然处在早期实验性阶段, 并未经过充分测试 + +如果你在使用过程中遇到了任何问题, 请前往 [Issue页面](https://github.com/nonebot/nonebot2/issues) 为我们提供反馈 +::: + +::: danger +Mirai-API-HTTP 的适配器以 [AGPLv3许可](https://opensource.org/licenses/AGPL-3.0) 单独开源 + +这意味着在使用该适配器时需要 **以该许可开源您的完整程序代码** +::: + +# NoneBot.adapters.mirai.bot 模块 + + +## _class_ `SessionManager` + +基类:`object` + +Bot会话管理器, 提供API主动调用接口 + + +### _async_ `post(path, *, params=None)` + + +* **说明** + + 以POST方式主动提交API请求 + + + +* **参数** + + + * `path: str`: 对应API路径 + + + * `params: Optional[Dict[str, Any]]`: 请求参数 (无需sessionKey) + + + +* **返回** + + + * `Dict[str, Any]`: API 返回值 + + + +### _async_ `request(path, *, params=None)` + + +* **说明** + + 以GET方式主动提交API请求 + + + +* **参数** + + + * `path: str`: 对应API路径 + + + * `params: Optional[Dict[str, Any]]`: 请求参数 (无需sessionKey) + + + +### _async_ `upload(path, *, params)` + + +* **说明** + + 以表单(`multipart/form-data`)形式主动提交API请求 + + + +* **参数** + + + * `path: str`: 对应API路径 + + + * `params: Dict[str, Any]`: 请求参数 (无需sessionKey) + + + +## _class_ `Bot` + +基类:[`nonebot.adapters.Bot`](README.md#nonebot.adapters.Bot) + +mirai-api-http 协议 Bot 适配。 + +::: warning +API中为了使代码更加整洁, 我们采用了与PEP8相符的命名规则取代Mirai原有的驼峰命名 + +部分字段可能与文档在符号上不一致 +::: + + +### _property_ `api` + +返回该Bot对象的会话管理实例以提供API主动调用 + + +### _async_ `call_api(api, **data)` + +::: danger +由于Mirai的HTTP API特殊性, 该API暂时无法实现 +::: + +::: tip +你可以使用 `MiraiBot.api` 中提供的调用方法来代替 +::: + + +### `send(event, message, at_sender=False)` + + +* **说明** + + 根据 `event` 向触发事件的主体发送信息 + + + +* **参数** + + + * `event: Event`: Event对象 + + + * `message: Union[MessageChain, MessageSegment, str]`: 要发送的消息 + + + * `at_sender: bool`: 是否 @ 事件主体 + + + +### `send_friend_message(target, message_chain)` + + +* **说明** + + 使用此方法向指定好友发送消息 + + + +* **参数** + + + * `target: int`: 发送消息目标好友的 QQ 号 + + + * `message_chain: MessageChain`: 消息链,是一个消息对象构成的数组 + + + +### `send_temp_message(qq, group, message_chain)` + + +* **说明** + + 使用此方法向临时会话对象发送消息 + + + +* **参数** + + + * `qq: int`: 临时会话对象 QQ 号 + + + * `group: int`: 临时会话群号 + + + * `message_chain: MessageChain`: 消息链,是一个消息对象构成的数组 + + + +### `send_group_message(group, message_chain, quote=None)` + + +* **说明** + + 使用此方法向指定群发送消息 + + + +* **参数** + + + * `group: int`: 发送消息目标群的群号 + + + * `message_chain: MessageChain`: 消息链,是一个消息对象构成的数组 + + + * `quote: Optional[int]`: 引用一条消息的 message_id 进行回复 + + + +### `recall(target)` + + +* **说明** + + 使用此方法撤回指定消息。对于bot发送的消息,有2分钟时间限制。对于撤回群聊中群员的消息,需要有相应权限 + + + +* **参数** + + + * `target: int`: 需要撤回的消息的message_id + + + +### `send_image_message(target, qq, group, urls)` + + +* **说明** + + 使用此方法向指定对象(群或好友)发送图片消息 + 除非需要通过此手段获取image_id,否则不推荐使用该接口 + + > 当qq和group同时存在时,表示发送临时会话图片,qq为临时会话对象QQ号,group为临时会话发起的群号 + + + +* **参数** + + + * `target: int`: 发送对象的QQ号或群号,可能存在歧义 + + + * `qq: int`: 发送对象的QQ号 + + + * `group: int`: 发送对象的群号 + + + * `urls: List[str]`: 是一个url字符串构成的数组 + + + +* **返回** + + + * `List[str]`: 一个包含图片imageId的数组 + + + +### `upload_image(type, img)` + + +* **说明** + + 使用此方法上传图片文件至服务器并返回Image_id + + + +* **参数** + + + * `type: str`: "friend" 或 "group" 或 "temp" + + + * `img: BytesIO`: 图片的BytesIO对象 + + + +### `upload_voice(type, voice)` + + +* **说明** + + 使用此方法上传语音文件至服务器并返回voice_id + + + +* **参数** + + + * `type: str`: 当前仅支持 "group" + + + * `voice: BytesIO`: 语音的BytesIO对象 + + + +### `fetch_message(count=10)` + + +* **说明** + + 使用此方法获取bot接收到的最老消息和最老各类事件 + (会从MiraiApiHttp消息记录中删除) + + + +* **参数** + + + * `count: int`: 获取消息和事件的数量 + + + +### `fetch_latest_message(count=10)` + + +* **说明** + + 使用此方法获取bot接收到的最新消息和最新各类事件 + (会从MiraiApiHttp消息记录中删除) + + + +* **参数** + + + * `count: int`: 获取消息和事件的数量 + + + +### `peek_message(count=10)` + + +* **说明** + + 使用此方法获取bot接收到的最老消息和最老各类事件 + (不会从MiraiApiHttp消息记录中删除) + + + +* **参数** + + + * `count: int`: 获取消息和事件的数量 + + + +### `peek_latest_message(count=10)` + + +* **说明** + + 使用此方法获取bot接收到的最新消息和最新各类事件 + (不会从MiraiApiHttp消息记录中删除) + + + +* **参数** + + + * `count: int`: 获取消息和事件的数量 + + + +### `messsage_from_id(id)` + + +* **说明** + + 通过messageId获取一条被缓存的消息 + 使用此方法获取bot接收到的消息和各类事件 + + + +* **参数** + + + * `id: int`: 获取消息的message_id + + + +### `count_message()` + + +* **说明** + + 使用此方法获取bot接收并缓存的消息总数,注意不包含被删除的 + + + +### `friend_list()` + + +* **说明** + + 使用此方法获取bot的好友列表 + + + +* **返回** + + + * `List[Dict[str, Any]]`: 返回的好友列表数据 + + + +### `group_list()` + + +* **说明** + + 使用此方法获取bot的群列表 + + + +* **返回** + + + * `List[Dict[str, Any]]`: 返回的群列表数据 + + + +### `member_list(target)` + + +* **说明** + + 使用此方法获取bot指定群种的成员列表 + + + +* **参数** + + + * `target: int`: 指定群的群号 + + + +* **返回** + + + * `List[Dict[str, Any]]`: 返回的群成员列表数据 + + + +### `mute(target, member_id, time)` + + +* **说明** + + 使用此方法指定群禁言指定群员(需要有相关权限) + + + +* **参数** + + + * `target: int`: 指定群的群号 + + + * `member_id: int`: 指定群员QQ号 + + + * `time: int`: 禁言时长,单位为秒,最多30天 + + + +### `unmute(target, member_id)` + + +* **说明** + + 使用此方法指定群解除群成员禁言(需要有相关权限) + + + +* **参数** + + + * `target: int`: 指定群的群号 + + + * `member_id: int`: 指定群员QQ号 + + + +### `kick(target, member_id, msg)` + + +* **说明** + + 使用此方法移除指定群成员(需要有相关权限) + + + +* **参数** + + + * `target: int`: 指定群的群号 + + + * `member_id: int`: 指定群员QQ号 + + + * `msg: str`: 信息 + + + +### `quit(target)` + + +* **说明** + + 使用此方法使Bot退出群聊 + + + +* **参数** + + + * `target: int`: 退出的群号 + + + +### `mute_all(target)` + + +* **说明** + + 使用此方法令指定群进行全体禁言(需要有相关权限) + + + +* **参数** + + + * `target: int`: 指定群的群号 + + + +### `unmute_all(target)` + + +* **说明** + + 使用此方法令指定群解除全体禁言(需要有相关权限) + + + +* **参数** + + + * `target: int`: 指定群的群号 + + + +### `group_config(target)` + + +* **说明** + + 使用此方法获取群设置 + + + +* **参数** + + + * `target: int`: 指定群的群号 + + + +* **返回** + + +```json +{ + "name": "群名称", + "announcement": "群公告", + "confessTalk": true, + "allowMemberInvite": true, + "autoApprove": true, + "anonymousChat": true +} +``` + + +### `modify_group_config(target, config)` + + +* **说明** + + 使用此方法修改群设置(需要有相关权限) + + + +* **参数** + + + * `target: int`: 指定群的群号 + + + * `config: Dict[str, Any]`: 群设置, 格式见 `group_config` 的返回值 + + + +### `member_info(target, member_id)` + + +* **说明** + + 使用此方法获取群员资料 + + + +* **参数** + + + * `target: int`: 指定群的群号 + + + * `member_id: int`: 群员QQ号 + + + +* **返回** + + +```json +{ + "name": "群名片", + "specialTitle": "群头衔" +} +``` + + +### `modify_member_info(target, member_id, info)` + + +* **说明** + + 使用此方法修改群员资料(需要有相关权限) + + + +* **参数** + + + * `target: int`: 指定群的群号 + + + * `member_id: int`: 群员QQ号 + + + * `info: Dict[str, Any]`: 群员资料, 格式见 `member_info` 的返回值 + + +# NoneBot.adapters.mirai.bot_ws 模块 + + +## _class_ `WebsocketBot` + +基类:`nonebot.adapters.mirai.bot.Bot` + +mirai-api-http 正向 Websocket 协议 Bot 适配。 + + +### _classmethod_ `register(driver, config, qq)` + + +* **说明** + + 注册该Adapter + + + +* **参数** + + + * `driver: Driver`: 程序所使用的\`\`Driver\`\` + + + * `config: Config`: 程序配置对象 + + + * `qq: int`: 要使用的Bot的QQ号 **注意: 在使用正向Websocket时必须指定该值!** + + +# NoneBot.adapters.mirai.config 模块 + + +## _class_ `Config` + +基类:`pydantic.main.BaseModel` + +Mirai 配置类 + + +* **必填** + + + * `mirai_auth_key`: mirai-api-http的auth_key + + + * `mirai_host`: mirai-api-http的地址 + + + * `mirai_port`: mirai-api-http的端口 + + +# NoneBot.adapters.mirai.message 模块 + + +## _class_ `MessageType` + +基类:`str`, `enum.Enum` + +消息类型枚举类 + + +## _class_ `MessageSegment` + +基类:[`nonebot.adapters.MessageSegment`](README.md#nonebot.adapters.MessageSegment) + +CQHTTP 协议 MessageSegment 适配。具体方法参考 [mirai-api-http 消息类型](https://github.com/project-mirai/mirai-api-http/blob/master/docs/MessageType.md) + + +### `as_dict()` + +导出可以被正常json序列化的结构体 + + +### _classmethod_ `quote(id, group_id, sender_id, target_id, origin)` + + +* **说明** + + 生成回复引用消息段 + + + +* **参数** + + + * `id: int`: 被引用回复的原消息的message_id + + + * `group_id: int`: 被引用回复的原消息所接收的群号,当为好友消息时为0 + + + * `sender_id: int`: 被引用回复的原消息的发送者的QQ号 + + + * `target_id: int`: 被引用回复的原消息的接收者者的QQ号(或群号) + + + * `origin: MessageChain`: 被引用回复的原消息的消息链对象 + + + +### _classmethod_ `at(target)` + + +* **说明** + + @某个人 + + + +* **参数** + + + * `target: int`: 群员QQ号 + + + +### _classmethod_ `at_all()` + + +* **说明** + + @全体成员 + + + +### _classmethod_ `face(face_id=None, name=None)` + + +* **说明** + + 发送QQ表情 + + + +* **参数** + + + * `face_id: Optional[int]`: QQ表情编号,可选,优先高于name + + + * `name: Optional[str]`: QQ表情拼音,可选 + + + +### _classmethod_ `plain(text)` + + +* **说明** + + 纯文本消息 + + + +* **参数** + + + * `text: str`: 文字消息 + + + +### _classmethod_ `image(image_id=None, url=None, path=None)` + + +* **说明** + + 图片消息 + + + +* **参数** + + + * `image_id: Optional[str]`: 图片的image_id,群图片与好友图片格式不同。不为空时将忽略url属性 + + + * `url: Optional[str]`: 图片的URL,发送时可作网络图片的链接 + + + * `path: Optional[str]`: 图片的路径,发送本地图片 + + + +### _classmethod_ `flash_image(image_id=None, url=None, path=None)` + + +* **说明** + + 闪照消息 + + + +* **参数** + + 同 `image` + + + +### _classmethod_ `voice(voice_id=None, url=None, path=None)` + + +* **说明** + + 语音消息 + + + +* **参数** + + + * `voice_id: Optional[str]`: 语音的voice_id,不为空时将忽略url属性 + + + * `url: Optional[str]`: 语音的URL,发送时可作网络语音的链接 + + + * `path: Optional[str]`: 语音的路径,发送本地语音 + + + +### _classmethod_ `xml(xml)` + + +* **说明** + + XML消息 + + + +* **参数** + + + * `xml: str`: XML文本 + + + +### _classmethod_ `json(json)` + + +* **说明** + + Json消息 + + + +* **参数** + + + * `json: str`: Json文本 + + + +### _classmethod_ `app(content)` + + +* **说明** + + 应用程序消息 + + + +* **参数** + + + * `content: str`: 内容 + + + +### _classmethod_ `poke(name)` + + +* **说明** + + 戳一戳消息 + + + +* **参数** + + + * `name: str`: 戳一戳的类型 + + + * `Poke`: 戳一戳 + + + * `ShowLove`: 比心 + + + * `Like`: 点赞 + + + * `Heartbroken`: 心碎 + + + * `SixSixSix`: 666 + + + * `FangDaZhao`: 放大招 + + + +## _class_ `MessageChain` + +基类:[`nonebot.adapters.Message`](README.md#nonebot.adapters.Message) + +Mirai 协议 Messaqge 适配 + +由于Mirai协议的Message实现较为特殊, 故使用MessageChain命名 + + +### `export()` + +导出为可以被正常json序列化的数组 + +# NoneBot.adapters.mirai.utils 模块 + + +## _exception_ `ActionFailed` + +基类:[`nonebot.exception.ActionFailed`](../exception.md#nonebot.exception.ActionFailed) + + +* **说明** + + API 请求成功返回数据,但 API 操作失败。 + + + +## _exception_ `InvalidArgument` + +基类:[`nonebot.exception.AdapterException`](../exception.md#nonebot.exception.AdapterException) + + +* **说明** + + 调用API的参数出错 + + + +## `catch_network_error(function)` + + +* **说明** + + 捕捉函数抛出的httpx网络异常并释放 `NetworkError` 异常 + + 处理返回数据, 在code不为0时释放 `ActionFailed` 异常 + + +::: warning +此装饰器只支持使用了httpx的异步函数 +::: + + +## `argument_validation(function)` + + +* **说明** + + 通过函数签名中的类型注解来对传入参数进行运行时校验 + + 会在参数出错时释放 `InvalidArgument` 异常 + + +# NoneBot.adapters.mirai.event 模块 + +::: warning +事件中为了使代码更加整洁, 我们采用了与PEP8相符的命名规则取代Mirai原有的驼峰命名 + +部分字段可能与文档在符号上不一致 +::: + + +## _class_ `Event` + +基类:[`nonebot.adapters.Event`](README.md#nonebot.adapters.Event) + +mirai-api-http 协议事件,字段与 mirai-api-http 一致。各事件字段参考 [mirai-api-http 事件类型](https://github.com/project-mirai/mirai-api-http/blob/master/docs/EventType.md) + + +### _classmethod_ `new(data)` + +此事件类的工厂函数, 能够通过事件数据选择合适的子类进行序列化 + + +### `normalize_dict(**kwargs)` + +返回可以被json正常反序列化的结构体 + + +## _class_ `UserPermission` + +基类:`str`, `enum.Enum` + + +* **说明** + + +用户权限枚举类 + +> +> * `OWNER`: 群主 + + +> * `ADMINISTRATOR`: 群管理 + + +> * `MEMBER`: 普通群成员 + + +## _class_ `MessageChain` + +基类:[`nonebot.adapters.Message`](README.md#nonebot.adapters.Message) + +Mirai 协议 Messaqge 适配 + +由于Mirai协议的Message实现较为特殊, 故使用MessageChain命名 + + +### `export()` + +导出为可以被正常json序列化的数组 + + +## _class_ `MessageEvent` + +基类:`nonebot.adapters.mirai.event.base.Event` + +消息事件基类 + + +## _class_ `GroupMessage` + +基类:`nonebot.adapters.mirai.event.message.MessageEvent` + +群消息事件 + + +## _class_ `FriendMessage` + +基类:`nonebot.adapters.mirai.event.message.MessageEvent` + +好友消息事件 + + +## _class_ `TempMessage` + +基类:`nonebot.adapters.mirai.event.message.MessageEvent` + +临时会话消息事件 + + +## _class_ `NoticeEvent` + +基类:`nonebot.adapters.mirai.event.base.Event` + +通知事件基类 + + +## _class_ `MuteEvent` + +基类:`nonebot.adapters.mirai.event.notice.NoticeEvent` + +禁言类事件基类 + + +## _class_ `BotMuteEvent` + +基类:`nonebot.adapters.mirai.event.notice.MuteEvent` + +Bot被禁言 + + +## _class_ `BotUnmuteEvent` + +基类:`nonebot.adapters.mirai.event.notice.MuteEvent` + +Bot被取消禁言 + + +## _class_ `MemberMuteEvent` + +基类:`nonebot.adapters.mirai.event.notice.MuteEvent` + +群成员被禁言事件(该成员不是Bot) + + +## _class_ `MemberUnmuteEvent` + +基类:`nonebot.adapters.mirai.event.notice.MuteEvent` + +群成员被取消禁言事件(该成员不是Bot) + + +## _class_ `BotJoinGroupEvent` + +基类:`nonebot.adapters.mirai.event.notice.NoticeEvent` + +Bot加入了一个新群 + + +## _class_ `BotLeaveEventActive` + +基类:`nonebot.adapters.mirai.event.notice.BotJoinGroupEvent` + +Bot主动退出一个群 + + +## _class_ `BotLeaveEventKick` + +基类:`nonebot.adapters.mirai.event.notice.BotJoinGroupEvent` + +Bot被踢出一个群 + + +## _class_ `MemberJoinEvent` + +基类:`nonebot.adapters.mirai.event.notice.NoticeEvent` + +新人入群的事件 + + +## _class_ `MemberLeaveEventKick` + +基类:`nonebot.adapters.mirai.event.notice.MemberJoinEvent` + +成员被踢出群(该成员不是Bot) + + +## _class_ `MemberLeaveEventQuit` + +基类:`nonebot.adapters.mirai.event.notice.MemberJoinEvent` + +成员主动离群(该成员不是Bot) + + +## _class_ `FriendRecallEvent` + +基类:`nonebot.adapters.mirai.event.notice.NoticeEvent` + +好友消息撤回 + + +## _class_ `GroupRecallEvent` + +基类:`nonebot.adapters.mirai.event.notice.FriendRecallEvent` + +群消息撤回 + + +## _class_ `GroupStateChangeEvent` + +基类:`nonebot.adapters.mirai.event.notice.NoticeEvent` + +群变化事件基类 + + +## _class_ `GroupNameChangeEvent` + +基类:`nonebot.adapters.mirai.event.notice.GroupStateChangeEvent` + +某个群名改变 + + +## _class_ `GroupEntranceAnnouncementChangeEvent` + +基类:`nonebot.adapters.mirai.event.notice.GroupStateChangeEvent` + +某群入群公告改变 + + +## _class_ `GroupMuteAllEvent` + +基类:`nonebot.adapters.mirai.event.notice.GroupStateChangeEvent` + +全员禁言 + + +## _class_ `GroupAllowAnonymousChatEvent` + +基类:`nonebot.adapters.mirai.event.notice.GroupStateChangeEvent` + +匿名聊天 + + +## _class_ `GroupAllowConfessTalkEvent` + +基类:`nonebot.adapters.mirai.event.notice.GroupStateChangeEvent` + +坦白说 + + +## _class_ `GroupAllowMemberInviteEvent` + +基类:`nonebot.adapters.mirai.event.notice.GroupStateChangeEvent` + +允许群员邀请好友加群 + + +## _class_ `MemberStateChangeEvent` + +基类:`nonebot.adapters.mirai.event.notice.NoticeEvent` + +群成员变化事件基类 + + +## _class_ `MemberCardChangeEvent` + +基类:`nonebot.adapters.mirai.event.notice.MemberStateChangeEvent` + +群名片改动 + + +## _class_ `MemberSpecialTitleChangeEvent` + +基类:`nonebot.adapters.mirai.event.notice.MemberStateChangeEvent` + +群头衔改动(只有群主有操作限权) + + +## _class_ `BotGroupPermissionChangeEvent` + +基类:`nonebot.adapters.mirai.event.notice.MemberStateChangeEvent` + +Bot在群里的权限被改变 + + +## _class_ `MemberPermissionChangeEvent` + +基类:`nonebot.adapters.mirai.event.notice.MemberStateChangeEvent` + +成员权限改变的事件(该成员不是Bot) + + +## _class_ `RequestEvent` + +基类:`nonebot.adapters.mirai.event.base.Event` + +请求事件基类 + + +## _class_ `NewFriendRequestEvent` + +基类:`nonebot.adapters.mirai.event.request.RequestEvent` + +添加好友申请 + + +### _async_ `approve(bot)` + + +* **说明** + + 通过此人的好友申请 + + + +* **参数** + + + * `bot: Bot`: 当前的 `Bot` 对象 + + + +### _async_ `reject(bot, operate=1, message='')` + + +* **说明** + + 拒绝此人的好友申请 + + + +* **参数** + + + * `bot: Bot`: 当前的 `Bot` 对象 + + + * `operate: Literal[1, 2]`: 响应的操作类型 + + + * `1`: 拒绝添加好友 + + + * `2`: 拒绝添加好友并添加黑名单,不再接收该用户的好友申请 + + + * `message: str`: 回复的信息 + + + +## _class_ `MemberJoinRequestEvent` + +基类:`nonebot.adapters.mirai.event.request.RequestEvent` + +用户入群申请(Bot需要有管理员权限) + + +### _async_ `approve(bot)` + + +* **说明** + + 通过此人的加群申请 + + + +* **参数** + + + * `bot: Bot`: 当前的 `Bot` 对象 + + + +### _async_ `reject(bot, operate=1, message='')` + + +* **说明** + + 拒绝(忽略)此人的加群申请 + + + +* **参数** + + + * `bot: Bot`: 当前的 `Bot` 对象 + + + * `operate: Literal[1, 2, 3, 4]`: 响应的操作类型 + + + * `1`: 拒绝入群 + + + * `2`: 忽略请求 + + + * `3`: 拒绝入群并添加黑名单,不再接收该用户的入群申请 + + + * `4`: 忽略入群并添加黑名单,不再接收该用户的入群申请 + + + * `message: str`: 回复的信息 + + + +## _class_ `BotInvitedJoinGroupRequestEvent` + +基类:`nonebot.adapters.mirai.event.request.RequestEvent` + +Bot被邀请入群申请 + + +### _async_ `approve(bot)` + + +* **说明** + + 通过这份被邀请入群申请 + + + +* **参数** + + + * `bot: Bot`: 当前的 `Bot` 对象 + + + +### _async_ `reject(bot, message='')` + + +* **说明** + + 拒绝这份被邀请入群申请 + + + +* **参数** + + + * `bot: Bot`: 当前的 `Bot` 对象 + + + * `message: str`: 邀请消息 + + +# NoneBot.adapters.mirai.event.base 模块 + + +## _class_ `UserPermission` + +基类:`str`, `enum.Enum` + + +* **说明** + + +用户权限枚举类 + +> +> * `OWNER`: 群主 + + +> * `ADMINISTRATOR`: 群管理 + + +> * `MEMBER`: 普通群成员 + + +## _class_ `Event` + +基类:[`nonebot.adapters.Event`](README.md#nonebot.adapters.Event) + +mirai-api-http 协议事件,字段与 mirai-api-http 一致。各事件字段参考 [mirai-api-http 事件类型](https://github.com/project-mirai/mirai-api-http/blob/master/docs/EventType.md) + + +### _classmethod_ `new(data)` + +此事件类的工厂函数, 能够通过事件数据选择合适的子类进行序列化 + + +### `normalize_dict(**kwargs)` + +返回可以被json正常反序列化的结构体 + +# NoneBot.adapters.mirai.event.meta 模块 + + +## _class_ `MetaEvent` + +基类:`nonebot.adapters.mirai.event.base.Event` + +元事件基类 + + +## _class_ `BotOnlineEvent` + +基类:`nonebot.adapters.mirai.event.meta.MetaEvent` + +Bot登录成功 + + +## _class_ `BotOfflineEventActive` + +基类:`nonebot.adapters.mirai.event.meta.MetaEvent` + +Bot主动离线 + + +## _class_ `BotOfflineEventForce` + +基类:`nonebot.adapters.mirai.event.meta.MetaEvent` + +Bot被挤下线 + + +## _class_ `BotOfflineEventDropped` + +基类:`nonebot.adapters.mirai.event.meta.MetaEvent` + +Bot被服务器断开或因网络问题而掉线 + + +## _class_ `BotReloginEvent` + +基类:`nonebot.adapters.mirai.event.meta.MetaEvent` + +Bot主动重新登录 + +# NoneBot.adapters.mirai.event.message 模块 + + +## _class_ `MessageEvent` + +基类:`nonebot.adapters.mirai.event.base.Event` + +消息事件基类 + + +## _class_ `GroupMessage` + +基类:`nonebot.adapters.mirai.event.message.MessageEvent` + +群消息事件 + + +## _class_ `FriendMessage` + +基类:`nonebot.adapters.mirai.event.message.MessageEvent` + +好友消息事件 + + +## _class_ `TempMessage` + +基类:`nonebot.adapters.mirai.event.message.MessageEvent` + +临时会话消息事件 + +# NoneBot.adapters.mirai.event.notice 模块 + + +## _class_ `NoticeEvent` + +基类:`nonebot.adapters.mirai.event.base.Event` + +通知事件基类 + + +## _class_ `MuteEvent` + +基类:`nonebot.adapters.mirai.event.notice.NoticeEvent` + +禁言类事件基类 + + +## _class_ `BotMuteEvent` + +基类:`nonebot.adapters.mirai.event.notice.MuteEvent` + +Bot被禁言 + + +## _class_ `BotUnmuteEvent` + +基类:`nonebot.adapters.mirai.event.notice.MuteEvent` + +Bot被取消禁言 + + +## _class_ `MemberMuteEvent` + +基类:`nonebot.adapters.mirai.event.notice.MuteEvent` + +群成员被禁言事件(该成员不是Bot) + + +## _class_ `MemberUnmuteEvent` + +基类:`nonebot.adapters.mirai.event.notice.MuteEvent` + +群成员被取消禁言事件(该成员不是Bot) + + +## _class_ `BotJoinGroupEvent` + +基类:`nonebot.adapters.mirai.event.notice.NoticeEvent` + +Bot加入了一个新群 + + +## _class_ `BotLeaveEventActive` + +基类:`nonebot.adapters.mirai.event.notice.BotJoinGroupEvent` + +Bot主动退出一个群 + + +## _class_ `BotLeaveEventKick` + +基类:`nonebot.adapters.mirai.event.notice.BotJoinGroupEvent` + +Bot被踢出一个群 + + +## _class_ `MemberJoinEvent` + +基类:`nonebot.adapters.mirai.event.notice.NoticeEvent` + +新人入群的事件 + + +## _class_ `MemberLeaveEventKick` + +基类:`nonebot.adapters.mirai.event.notice.MemberJoinEvent` + +成员被踢出群(该成员不是Bot) + + +## _class_ `MemberLeaveEventQuit` + +基类:`nonebot.adapters.mirai.event.notice.MemberJoinEvent` + +成员主动离群(该成员不是Bot) + + +## _class_ `FriendRecallEvent` + +基类:`nonebot.adapters.mirai.event.notice.NoticeEvent` + +好友消息撤回 + + +## _class_ `GroupRecallEvent` + +基类:`nonebot.adapters.mirai.event.notice.FriendRecallEvent` + +群消息撤回 + + +## _class_ `GroupStateChangeEvent` + +基类:`nonebot.adapters.mirai.event.notice.NoticeEvent` + +群变化事件基类 + + +## _class_ `GroupNameChangeEvent` + +基类:`nonebot.adapters.mirai.event.notice.GroupStateChangeEvent` + +某个群名改变 + + +## _class_ `GroupEntranceAnnouncementChangeEvent` + +基类:`nonebot.adapters.mirai.event.notice.GroupStateChangeEvent` + +某群入群公告改变 + + +## _class_ `GroupMuteAllEvent` + +基类:`nonebot.adapters.mirai.event.notice.GroupStateChangeEvent` + +全员禁言 + + +## _class_ `GroupAllowAnonymousChatEvent` + +基类:`nonebot.adapters.mirai.event.notice.GroupStateChangeEvent` + +匿名聊天 + + +## _class_ `GroupAllowConfessTalkEvent` + +基类:`nonebot.adapters.mirai.event.notice.GroupStateChangeEvent` + +坦白说 + + +## _class_ `GroupAllowMemberInviteEvent` + +基类:`nonebot.adapters.mirai.event.notice.GroupStateChangeEvent` + +允许群员邀请好友加群 + + +## _class_ `MemberStateChangeEvent` + +基类:`nonebot.adapters.mirai.event.notice.NoticeEvent` + +群成员变化事件基类 + + +## _class_ `MemberCardChangeEvent` + +基类:`nonebot.adapters.mirai.event.notice.MemberStateChangeEvent` + +群名片改动 + + +## _class_ `MemberSpecialTitleChangeEvent` + +基类:`nonebot.adapters.mirai.event.notice.MemberStateChangeEvent` + +群头衔改动(只有群主有操作限权) + + +## _class_ `BotGroupPermissionChangeEvent` + +基类:`nonebot.adapters.mirai.event.notice.MemberStateChangeEvent` + +Bot在群里的权限被改变 + + +## _class_ `MemberPermissionChangeEvent` + +基类:`nonebot.adapters.mirai.event.notice.MemberStateChangeEvent` + +成员权限改变的事件(该成员不是Bot) + +# NoneBot.adapters.mirai.event.request 模块 + + +## _class_ `RequestEvent` + +基类:`nonebot.adapters.mirai.event.base.Event` + +请求事件基类 + + +## _class_ `NewFriendRequestEvent` + +基类:`nonebot.adapters.mirai.event.request.RequestEvent` + +添加好友申请 + + +### _async_ `approve(bot)` + + +* **说明** + + 通过此人的好友申请 + + + +* **参数** + + + * `bot: Bot`: 当前的 `Bot` 对象 + + + +### _async_ `reject(bot, operate=1, message='')` + + +* **说明** + + 拒绝此人的好友申请 + + + +* **参数** + + + * `bot: Bot`: 当前的 `Bot` 对象 + + + * `operate: Literal[1, 2]`: 响应的操作类型 + + + * `1`: 拒绝添加好友 + + + * `2`: 拒绝添加好友并添加黑名单,不再接收该用户的好友申请 + + + * `message: str`: 回复的信息 + + + +## _class_ `MemberJoinRequestEvent` + +基类:`nonebot.adapters.mirai.event.request.RequestEvent` + +用户入群申请(Bot需要有管理员权限) + + +### _async_ `approve(bot)` + + +* **说明** + + 通过此人的加群申请 + + + +* **参数** + + + * `bot: Bot`: 当前的 `Bot` 对象 + + + +### _async_ `reject(bot, operate=1, message='')` + + +* **说明** + + 拒绝(忽略)此人的加群申请 + + + +* **参数** + + + * `bot: Bot`: 当前的 `Bot` 对象 + + + * `operate: Literal[1, 2, 3, 4]`: 响应的操作类型 + + + * `1`: 拒绝入群 + + + * `2`: 忽略请求 + + + * `3`: 拒绝入群并添加黑名单,不再接收该用户的入群申请 + + + * `4`: 忽略入群并添加黑名单,不再接收该用户的入群申请 + + + * `message: str`: 回复的信息 + + + +## _class_ `BotInvitedJoinGroupRequestEvent` + +基类:`nonebot.adapters.mirai.event.request.RequestEvent` + +Bot被邀请入群申请 + + +### _async_ `approve(bot)` + + +* **说明** + + 通过这份被邀请入群申请 + + + +* **参数** + + + * `bot: Bot`: 当前的 `Bot` 对象 + + + +### _async_ `reject(bot, message='')` + + +* **说明** + + 拒绝这份被邀请入群申请 + + + +* **参数** + + + * `bot: Bot`: 当前的 `Bot` 对象 + + + * `message: str`: 邀请消息 diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index b23a0de7..edb898a3 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -63,6 +63,7 @@ python bot.py - [配置 CQHTTP](./cqhttp-guide.md) - [配置钉钉](./ding-guide.md) +- [配置 mirai-api-http](./mirai-guide.md) NoneBot 接受的上报地址与 `Driver` 有关,默认使用的 `FastAPI Driver` 所接受的上报地址有: diff --git a/docs/guide/mirai-guide.md b/docs/guide/mirai-guide.md new file mode 100644 index 00000000..bd11083c --- /dev/null +++ b/docs/guide/mirai-guide.md @@ -0,0 +1,195 @@ +# Mirai-API-HTTP 协议使用指南 + +::: warning + +Mirai-API-HTTP 的适配现在仍然处于早期阶段, 可能没有进行过充分的测试 + +请在生产环境中谨慎使用 + +::: + +::: tip + +为了你的使用之旅更加顺畅, 我们建议您在配置之前具有以下的前置知识 + +- 对服务端/客户端(C/S)模型的基本了解 +- 对 Web 服务配置基础的认知 +- 对`YAML`语法的一点点了解 + +::: + +::: danger + +Mirai-API-HTTP 的适配器以 [AGPLv3 许可](https://opensource.org/licenses/AGPL-3.0) 单独开源 + +这意味着在使用该适配器时需要 **以该许可开源您的完整程序代码** + +::: + +**为了便捷起见, 以下内容均以缩写 `MAH` 代替 `mirai-api-http`** + +## 配置 MAH 客户端 + +正如你可能刚刚在[CQHTTP 协议使用指南](./cqhttp-guide.md)中所读到的: + +> 单纯运行 NoneBot 实例并不会产生任何效果,因为此刻 QQ 这边还不知道 NoneBot 的存在,也就无法把消息发送给它,因此现在需要使用一个无头 QQ 来把消息等事件上报给 NoneBot。 + +这次, 我们将采用在实现上有别于 onebot即 CQHTTP协议的另外一种无头 QQ API 协议, 即 MAH + +为了配置 MAH 端, 我们现在需要移步到[MAH 的项目地址](https://github.com/project-mirai/mirai-api-http), 来看看它是如何配置的 + +根据[项目提供的 README](https://github.com/project-mirai/mirai-api-http/blob/056beedba31d6ad06426997a1d3fde861a7f8ba3/README.md),配置 MAH 大概需要以下几步 + +1. 下载并安装 Java 运行环境, 你可以有以下几种选择: + + - [由 Oracle 提供的 Java 运行环境](https://java.com/zh-CN/download/manual.jsp) **在没有特殊需求的情况下推荐** + - [由 Zulu 编译的 OpenJRE 环境](https://www.azul.com/downloads/zulu-community/?version=java-8-lts&architecture=x86-64-bit&package=jre) + +2. 下载[Mirai Console Loader](https://github.com/iTXTech/mirai-console-loader) + + - 请按照文档 README 中的步骤下载并安装 + +3. 安装 MAH: + + - 在 Mirai Console Loader 目录下执行该指令 + + - ```shell + ./mcl --update-package net.mamoe:mirai-api-http --channel stable --type plugin + ``` + + 注意: 该指令的前缀`./mcl`可能根据操作系统以及使用 java 环境的不同而变化 + +4. 修改配置文件 + + ::: tip + + 在此之前, 你可能需要了解我们为 MAH 设计的两种通信方式 + + - 正向 Websocket + - NoneBot 作为纯粹的客户端,通过 websocket 监听事件下发 + - 优势 + 1. 网络配置简单, 特别是在使用 Docker 等网络隔离的容器时 + 2. 在初步测试中连接性较好 + - 劣势 + 1. 与 NoneBot 本身的架构不同, 可能稳定性较差 + 2. 需要在注册 adapter 时显式指定 qq, 对于需要开源的程序来讲不利 + - POST 消息上报 + - NoneBot 在接受消息上报时作为服务端, 发送消息时作为客户端 + - 优势 + 1. 与 NoneBot 本身架构相符, 性能和稳定性较强 + 2. 无需在任何地方指定 QQ, 即插即用 + - 劣势 + 1. 由于同时作为客户端和服务端, 配置较为复杂 + 2. 在测试中网络连接性较差 (未确认原因) + + ::: + + - 这是当使用正向 Websocket 时的配置举例 + + - MAH 的`setting.yml`文件 + + - ```yaml + # 省略了部分无需修改的部分 + + host: "0.0.0.0" # 监听地址 + port: 8080 # 监听端口 + authKey: 1234567890 # 访问密钥, 最少八位 + enableWebsocket: true # 必须为true + ``` + + - `.env`文件 + + - ```shell + MIRAI_AUTH_KEY=1234567890 + MIRAI_HOST=127.0.0.1 # 当MAH运行在本机时 + MIRAI_PORT=8080 # MAH的监听端口 + ``` + + - `bot.py`文件 + + - ```python + import nonebot + from nonebot.adapters.mirai import WebsocketBot + + nonebot.init() + nonebot.get_driver().register_adapter('mirai-ws', WebsocketBot, qq=12345678) # qq参数需要填在mah中登录的qq + nonebot.load_builtin_plugins() # 加载 nonebot 内置插件 + nonebot.run() + ``` + + - 这是当使用 POST 消息上报时的配置文件 + + - MAH 的`setting.yml`文件 + + - ```yaml + # 省略了部分无需修改的部分 + + host: '0.0.0.0' # 监听地址 + port: 8080 # 监听端口 + authKey: 1234567890 # 访问密钥, 最少八位 + + ## 消息上报 + report: + enable: true # 必须为true + groupMessage: + report: true # 群消息上报 + friendMessage: + report: true # 好友消息上报 + tempMessage: + report: true # 临时会话上报 + eventMessage: + report: true # 事件上报 + destinations: + - 'http://127.0.0.1:2333/mirai/http' #上报地址, 请按照实际情况修改 + # 上报时的额外Header + extraHeaders: {} + ``` + + - `.env`文件 + + - ```shell + HOST=127.0.0.1 # 当MAH运行在本机时 + PORT=2333 + + MIRAI_AUTH_KEY=1234567890 + MIRAI_HOST=127.0.0.1 # 当MAH运行在本机时 + MIRAI_PORT=8080 # MAH的监听端口 + ``` + + - `bot.py`文件 + + - ```python + import nonebot + from nonebot.adapters.mirai import Bot + + nonebot.init() + nonebot.get_driver().register_adapter('mirai', Bot) + nonebot.load_builtin_plugins() # 加载 nonebot 内置插件 + nonebot.run() + ``` + +## 历史性的第一次对话 + +现在, 先启动 NoneBot, 再启动 MAH + +如果你的配置文件一切正常, 你将在控制台看到类似于下列的日志 + +```log +02-01 18:25:12 [INFO] nonebot | NoneBot is initializing... +02-01 18:25:12 [INFO] nonebot | Current Env: prod +02-01 18:25:12 [DEBUG] nonebot | Loaded Config: {'driver': 'nonebot.drivers.fastapi', 'host': IPv4Address('127.0.0.1'), 'port': 8080, 'debug': True, 'api_root': {}, 'api_timeout': 30.0, 'access_token': None, 'secret': None, 'superusers': set(), 'nickname': set(), 'command_start': {'/'}, 'command_sep': {'.'}, 'session_expire_timeout': datetime.timedelta(seconds=120), 'mirai_port': 8080, 'environment': 'prod', 'mirai_auth_key': 12345678, 'mirai_host': '127.0.0.1'} +02-01 18:25:12 [DEBUG] nonebot | Succeeded to load adapter "mirai" +02-01 18:25:12 [INFO] nonebot | Succeeded to import "nonebot.plugins.echo" +02-01 18:25:12 [INFO] nonebot | Running NoneBot... +02-01 18:25:12 [DEBUG] nonebot | Loaded adapters: mirai +02-01 18:25:12 [INFO] uvicorn | Started server process [183155] +02-01 18:25:12 [INFO] uvicorn | Waiting for application startup. +02-01 18:25:12 [INFO] uvicorn | Application startup complete. +02-01 18:25:12 [INFO] uvicorn | Uvicorn running on http://127.0.0.1:2333 (Press CTRL+C to quit) +02-01 18:25:14 [INFO] uvicorn | 127.0.0.1:37794 - "POST /mirai/http HTTP/1.1" 204 +02-01 18:25:14 [DEBUG] nonebot | MIRAI | received message {'type': 'BotOnlineEvent', 'qq': 1234567} +02-01 18:25:14 [INFO] nonebot | MIRAI 1234567 | [BotOnlineEvent]: {'self_id': 1234567, 'type': 'BotOnlineEvent', 'qq': 1234567} +02-01 18:25:14 [DEBUG] nonebot | Checking for matchers in priority 1... +``` + +恭喜你, 你的配置已经成功! diff --git a/docs_build/README.rst b/docs_build/README.rst index 95ffcc2d..4a273041 100644 --- a/docs_build/README.rst +++ b/docs_build/README.rst @@ -18,3 +18,4 @@ NoneBot Api Reference - `nonebot.adapters `_ - `nonebot.adapters.cqhttp `_ - `nonebot.adapters.ding `_ + - `nonebot.adapters.mirai `_ diff --git a/docs_build/adapters/mirai.rst b/docs_build/adapters/mirai.rst new file mode 100644 index 00000000..a2f6a9c6 --- /dev/null +++ b/docs_build/adapters/mirai.rst @@ -0,0 +1,86 @@ +--- +contentSidebar: true +sidebarDepth: 0 +--- + +NoneBot.adapters.mirai 模块 +=========================== + +.. automodule:: nonebot.adapters.mirai + +NoneBot.adapters.mirai.bot 模块 +=============================== + +.. automodule:: nonebot.adapters.mirai.bot + :members: + :show-inheritance: + +NoneBot.adapters.mirai.bot_ws 模块 +================================== + +.. automodule:: nonebot.adapters.mirai.bot_ws + :members: + :show-inheritance: + +NoneBot.adapters.mirai.config 模块 +================================== + +.. automodule:: nonebot.adapters.mirai.config + :members: + :show-inheritance: + +NoneBot.adapters.mirai.message 模块 +=================================== + +.. automodule:: nonebot.adapters.mirai.message + :members: + :show-inheritance: + +NoneBot.adapters.mirai.utils 模块 +=================================== + +.. automodule:: nonebot.adapters.mirai.utils + :members: + :show-inheritance: + +NoneBot.adapters.mirai.event 模块 +================================= + +.. automodule:: nonebot.adapters.mirai.event + :members: + :show-inheritance: + +NoneBot.adapters.mirai.event.base 模块 +====================================== + +.. automodule:: nonebot.adapters.mirai.event.base + :members: + :show-inheritance: + +NoneBot.adapters.mirai.event.meta 模块 +====================================== + +.. automodule:: nonebot.adapters.mirai.event.meta + :members: + :show-inheritance: + +NoneBot.adapters.mirai.event.message 模块 +========================================= + +.. automodule:: nonebot.adapters.mirai.event.message + :members: + :show-inheritance: + +NoneBot.adapters.mirai.event.notice 模块 +========================================= + +.. automodule:: nonebot.adapters.mirai.event.notice + :members: + :show-inheritance: + +NoneBot.adapters.mirai.event.request 模块 +========================================= + +.. automodule:: nonebot.adapters.mirai.event.request + :members: + :show-inheritance: \ No newline at end of file diff --git a/nonebot/adapters/mirai/LICENSE b/nonebot/adapters/mirai/LICENSE new file mode 100644 index 00000000..be3f7b28 --- /dev/null +++ b/nonebot/adapters/mirai/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/nonebot/adapters/mirai/__init__.py b/nonebot/adapters/mirai/__init__.py new file mode 100644 index 00000000..2b09e365 --- /dev/null +++ b/nonebot/adapters/mirai/__init__.py @@ -0,0 +1,33 @@ +""" +Mirai-API-HTTP 协议适配 +============================ + +协议详情请看: `mirai-api-http 文档`_ + +\:\:\: tip +该Adapter目前仍然处在早期实验性阶段, 并未经过充分测试 + +如果你在使用过程中遇到了任何问题, 请前往 `Issue页面`_ 为我们提供反馈 +\:\:\: + +\:\:\: danger +Mirai-API-HTTP 的适配器以 `AGPLv3许可`_ 单独开源 + +这意味着在使用该适配器时需要 **以该许可开源您的完整程序代码** +\:\:\: + +.. _mirai-api-http 文档: + https://github.com/project-mirai/mirai-api-http/tree/master/docs + +.. _Issue页面: + https://github.com/nonebot/nonebot2/issues + +.. _AGPLv3许可: + https://opensource.org/licenses/AGPL-3.0 + +""" + +from .bot import Bot +from .bot_ws import WebsocketBot +from .event import * +from .message import MessageChain, MessageSegment diff --git a/nonebot/adapters/mirai/bot.py b/nonebot/adapters/mirai/bot.py new file mode 100644 index 00000000..0a1262c7 --- /dev/null +++ b/nonebot/adapters/mirai/bot.py @@ -0,0 +1,737 @@ +from datetime import datetime, timedelta +from functools import wraps +from io import BytesIO +from ipaddress import IPv4Address +from typing import (Any, Dict, List, NoReturn, Optional, Tuple, Union) + +import httpx + +from nonebot.adapters import Bot as BaseBot +from nonebot.config import Config +from nonebot.drivers import Driver, WebSocket +from nonebot.exception import ApiNotAvailable, RequestDenied +from nonebot.log import logger +from nonebot.message import handle_event +from nonebot.typing import overrides +from nonebot.utils import escape_tag + +from .config import Config as MiraiConfig +from .event import Event, FriendMessage, GroupMessage, TempMessage +from .message import MessageChain, MessageSegment +from .utils import catch_network_error, argument_validation, check_tome, Log + + +class SessionManager: + """Bot会话管理器, 提供API主动调用接口""" + sessions: Dict[int, Tuple[str, datetime, httpx.AsyncClient]] = {} + session_expiry: timedelta = timedelta(minutes=15) + + def __init__(self, session_key: str, client: httpx.AsyncClient): + self.session_key, self.client = session_key, client + + @catch_network_error + async def post(self, + path: str, + *, + params: Optional[Dict[str, Any]] = None) -> Any: + """ + :说明: + + 以POST方式主动提交API请求 + + :参数: + + * ``path: str``: 对应API路径 + * ``params: Optional[Dict[str, Any]]``: 请求参数 (无需sessionKey) + + :返回: + + - ``Dict[str, Any]``: API 返回值 + """ + response = await self.client.post( + path, + json={ + **(params or {}), + 'sessionKey': self.session_key, + }, + timeout=3, + ) + response.raise_for_status() + return response.json() + + @catch_network_error + async def request(self, + path: str, + *, + params: Optional[Dict[str, Any]] = None) -> Any: + """ + :说明: + + 以GET方式主动提交API请求 + + :参数: + + * ``path: str``: 对应API路径 + * ``params: Optional[Dict[str, Any]]``: 请求参数 (无需sessionKey) + """ + response = await self.client.get( + path, + params={ + **(params or {}), + 'sessionKey': self.session_key, + }, + timeout=3, + ) + response.raise_for_status() + return response.json() + + @catch_network_error + async def upload(self, path: str, *, params: Dict[str, Any]) -> Any: + """ + :说明: + + 以表单(``multipart/form-data``)形式主动提交API请求 + + :参数: + + * ``path: str``: 对应API路径 + * ``params: Dict[str, Any]``: 请求参数 (无需sessionKey) + """ + files = {k: v for k, v in params.items() if isinstance(v, BytesIO)} + form = {k: v for k, v in params.items() if k not in files} + response = await self.client.post( + path, + data=form, + files=files, + timeout=6, + ) + response.raise_for_status() + return response.json() + + @classmethod + async def new(cls, self_id: int, *, host: IPv4Address, port: int, + auth_key: str) -> "SessionManager": + session = cls.get(self_id) + if session is not None: + return session + + client = httpx.AsyncClient(base_url=f'http://{host}:{port}') + response = await client.post('/auth', json={'authKey': auth_key}) + response.raise_for_status() + auth = response.json() + assert auth['code'] == 0 + session_key = auth['session'] + response = await client.post('/verify', + json={ + 'sessionKey': session_key, + 'qq': self_id + }) + assert response.json()['code'] == 0 + cls.sessions[self_id] = session_key, datetime.now(), client + + return cls(session_key, client) + + @classmethod + def get(cls, + self_id: int, + check_expire: bool = True) -> Optional["SessionManager"]: + if self_id not in cls.sessions: + return None + key, time, client = cls.sessions[self_id] + if check_expire and (datetime.now() - time > cls.session_expiry): + return None + return cls(key, client) + + +class Bot(BaseBot): + """ + mirai-api-http 协议 Bot 适配。 + + \:\:\: warning + API中为了使代码更加整洁, 我们采用了与PEP8相符的命名规则取代Mirai原有的驼峰命名 + + 部分字段可能与文档在符号上不一致 + \:\:\: + + """ + + @overrides(BaseBot) + def __init__(self, + connection_type: str, + self_id: str, + *, + websocket: Optional[WebSocket] = None): + super().__init__(connection_type, self_id, websocket=websocket) + + @property + @overrides(BaseBot) + def type(self) -> str: + return "mirai" + + @property + def alive(self) -> bool: + return not self.websocket.closed + + @property + def api(self) -> SessionManager: + """返回该Bot对象的会话管理实例以提供API主动调用""" + api = SessionManager.get(self_id=int(self.self_id)) + assert api is not None, 'SessionManager has not been initialized' + return api + + @classmethod + @overrides(BaseBot) + async def check_permission(cls, driver: "Driver", connection_type: str, + headers: dict, body: Optional[dict]) -> str: + if connection_type == 'ws': + raise RequestDenied( + status_code=501, + reason='Websocket connection is not implemented') + self_id: Optional[str] = headers.get('bot') + if self_id is None: + raise RequestDenied(status_code=400, + reason='Header `Bot` is required.') + self_id = str(self_id).strip() + await SessionManager.new( + int(self_id), + host=cls.mirai_config.host, # type: ignore + port=cls.mirai_config.port, #type: ignore + auth_key=cls.mirai_config.auth_key) # type: ignore + return self_id + + @classmethod + @overrides(BaseBot) + def register(cls, driver: "Driver", config: "Config"): + cls.mirai_config = MiraiConfig(**config.dict()) + if (cls.mirai_config.auth_key and cls.mirai_config.host and + cls.mirai_config.port) is None: + raise ApiNotAvailable('mirai') + super().register(driver, config) + + @overrides(BaseBot) + async def handle_message(self, message: dict): + Log.debug(f'received message {message}') + try: + await handle_event( + bot=self, + event=await check_tome( + bot=self, + event=Event.new({ + **message, + 'self_id': self.self_id, + }), + ), + ) + except Exception as e: + logger.opt(colors=True, exception=e).exception( + 'Failed to handle message ' + f'{escape_tag(str(message))}: ') + + @overrides(BaseBot) + async def call_api(self, api: str, **data) -> NoReturn: + """ + \:\:\: danger + 由于Mirai的HTTP API特殊性, 该API暂时无法实现 + \:\:\: + + \:\:\: tip + 你可以使用 ``MiraiBot.api`` 中提供的调用方法来代替 + \:\:\: + """ + raise NotImplementedError + + @overrides(BaseBot) + def __getattr__(self, key: str) -> NoReturn: + """由于Mirai的HTTP API特殊性, 该API暂时无法实现""" + raise NotImplementedError + + @overrides(BaseBot) + @argument_validation + async def send(self, + event: Event, + message: Union[MessageChain, MessageSegment, str], + at_sender: bool = False): + """ + :说明: + + 根据 ``event`` 向触发事件的主体发送信息 + + :参数: + + * ``event: Event``: Event对象 + * ``message: Union[MessageChain, MessageSegment, str]``: 要发送的消息 + * ``at_sender: bool``: 是否 @ 事件主体 + """ + if isinstance(message, MessageSegment): + message = MessageChain(message) + elif isinstance(message, str): + message = MessageChain(MessageSegment.plain(message)) + if isinstance(event, FriendMessage): + return await self.send_friend_message(target=event.sender.id, + message_chain=message) + elif isinstance(event, GroupMessage): + if at_sender: + message = MessageSegment.at(event.sender.id) + message + return await self.send_group_message(group=event.sender.group.id, + message_chain=message) + elif isinstance(event, TempMessage): + return await self.send_temp_message(qq=event.sender.id, + group=event.sender.group.id, + message_chain=message) + else: + raise ValueError(f'Unsupported event type {event!r}.') + + @argument_validation + async def send_friend_message(self, target: int, + message_chain: MessageChain): + """ + :说明: + + 使用此方法向指定好友发送消息 + + :参数: + + * ``target: int``: 发送消息目标好友的 QQ 号 + * ``message_chain: MessageChain``: 消息链,是一个消息对象构成的数组 + """ + return await self.api.post('sendFriendMessage', + params={ + 'target': target, + 'messageChain': message_chain.export() + }) + + @argument_validation + async def send_temp_message(self, qq: int, group: int, + message_chain: MessageChain): + """ + :说明: + + 使用此方法向临时会话对象发送消息 + + :参数: + + * ``qq: int``: 临时会话对象 QQ 号 + * ``group: int``: 临时会话群号 + * ``message_chain: MessageChain``: 消息链,是一个消息对象构成的数组 + """ + return await self.api.post('sendTempMessage', + params={ + 'qq': qq, + 'group': group, + 'messageChain': message_chain.export() + }) + + @argument_validation + async def send_group_message(self, + group: int, + message_chain: MessageChain, + quote: Optional[int] = None): + """ + :说明: + + 使用此方法向指定群发送消息 + + :参数: + + * ``group: int``: 发送消息目标群的群号 + * ``message_chain: MessageChain``: 消息链,是一个消息对象构成的数组 + * ``quote: Optional[int]``: 引用一条消息的 message_id 进行回复 + """ + return await self.api.post('sendGroupMessage', + params={ + 'group': group, + 'messageChain': message_chain.export(), + 'quote': quote + }) + + @argument_validation + async def recall(self, target: int): + """ + :说明: + + 使用此方法撤回指定消息。对于bot发送的消息,有2分钟时间限制。对于撤回群聊中群员的消息,需要有相应权限 + + :参数: + + * ``target: int``: 需要撤回的消息的message_id + """ + return await self.api.post('recall', params={'target': target}) + + @argument_validation + async def send_image_message(self, target: int, qq: int, group: int, + urls: List[str]) -> List[str]: + """ + :说明: + + 使用此方法向指定对象(群或好友)发送图片消息 + 除非需要通过此手段获取image_id,否则不推荐使用该接口 + + > 当qq和group同时存在时,表示发送临时会话图片,qq为临时会话对象QQ号,group为临时会话发起的群号 + + :参数: + + * ``target: int``: 发送对象的QQ号或群号,可能存在歧义 + * ``qq: int``: 发送对象的QQ号 + * ``group: int``: 发送对象的群号 + * ``urls: List[str]``: 是一个url字符串构成的数组 + + :返回: + + - ``List[str]``: 一个包含图片imageId的数组 + """ + return await self.api.post('sendImageMessage', + params={ + 'target': target, + 'qq': qq, + 'group': group, + 'urls': urls + }) + + @argument_validation + async def upload_image(self, type: str, img: BytesIO): + """ + :说明: + + 使用此方法上传图片文件至服务器并返回Image_id + + :参数: + + * ``type: str``: "friend" 或 "group" 或 "temp" + * ``img: BytesIO``: 图片的BytesIO对象 + """ + return await self.api.upload('uploadImage', + params={ + 'type': type, + 'img': img + }) + + @argument_validation + async def upload_voice(self, type: str, voice: BytesIO): + """ + :说明: + + 使用此方法上传语音文件至服务器并返回voice_id + + :参数: + + * ``type: str``: 当前仅支持 "group" + * ``voice: BytesIO``: 语音的BytesIO对象 + """ + return await self.api.upload('uploadVoice', + params={ + 'type': type, + 'voice': voice + }) + + @argument_validation + async def fetch_message(self, count: int = 10): + """ + :说明: + + 使用此方法获取bot接收到的最老消息和最老各类事件 + (会从MiraiApiHttp消息记录中删除) + + :参数: + + * ``count: int``: 获取消息和事件的数量 + """ + return await self.api.request('fetchMessage', params={'count': count}) + + @argument_validation + async def fetch_latest_message(self, count: int = 10): + """ + :说明: + + 使用此方法获取bot接收到的最新消息和最新各类事件 + (会从MiraiApiHttp消息记录中删除) + + :参数: + + * ``count: int``: 获取消息和事件的数量 + """ + return await self.api.request('fetchLatestMessage', + params={'count': count}) + + @argument_validation + async def peek_message(self, count: int = 10): + """ + :说明: + + 使用此方法获取bot接收到的最老消息和最老各类事件 + (不会从MiraiApiHttp消息记录中删除) + + :参数: + + * ``count: int``: 获取消息和事件的数量 + """ + return await self.api.request('peekMessage', params={'count': count}) + + @argument_validation + async def peek_latest_message(self, count: int = 10): + """ + :说明: + + 使用此方法获取bot接收到的最新消息和最新各类事件 + (不会从MiraiApiHttp消息记录中删除) + + :参数: + + * ``count: int``: 获取消息和事件的数量 + """ + return await self.api.request('peekLatestMessage', + params={'count': count}) + + @argument_validation + async def messsage_from_id(self, id: int): + """ + :说明: + + 通过messageId获取一条被缓存的消息 + 使用此方法获取bot接收到的消息和各类事件 + + :参数: + + * ``id: int``: 获取消息的message_id + """ + return await self.api.request('messageFromId', params={'id': id}) + + @argument_validation + async def count_message(self): + """ + :说明: + + 使用此方法获取bot接收并缓存的消息总数,注意不包含被删除的 + """ + return await self.api.request('countMessage') + + @argument_validation + async def friend_list(self) -> List[Dict[str, Any]]: + """ + :说明: + + 使用此方法获取bot的好友列表 + + :返回: + + - ``List[Dict[str, Any]]``: 返回的好友列表数据 + """ + return await self.api.request('friendList') + + @argument_validation + async def group_list(self) -> List[Dict[str, Any]]: + """ + :说明: + + 使用此方法获取bot的群列表 + + :返回: + + - ``List[Dict[str, Any]]``: 返回的群列表数据 + """ + return await self.api.request('groupList') + + @argument_validation + async def member_list(self, target: int) -> List[Dict[str, Any]]: + """ + :说明: + + 使用此方法获取bot指定群种的成员列表 + + :参数: + + * ``target: int``: 指定群的群号 + + :返回: + + - ``List[Dict[str, Any]]``: 返回的群成员列表数据 + """ + return await self.api.request('memberList', params={'target': target}) + + @argument_validation + async def mute(self, target: int, member_id: int, time: int): + """ + :说明: + + 使用此方法指定群禁言指定群员(需要有相关权限) + + :参数: + + * ``target: int``: 指定群的群号 + * ``member_id: int``: 指定群员QQ号 + * ``time: int``: 禁言时长,单位为秒,最多30天 + """ + return await self.api.post('mute', + params={ + 'target': target, + 'memberId': member_id, + 'time': time + }) + + @argument_validation + async def unmute(self, target: int, member_id: int): + """ + :说明: + + 使用此方法指定群解除群成员禁言(需要有相关权限) + + :参数: + + * ``target: int``: 指定群的群号 + * ``member_id: int``: 指定群员QQ号 + """ + return await self.api.post('unmute', + params={ + 'target': target, + 'memberId': member_id + }) + + @argument_validation + async def kick(self, target: int, member_id: int, msg: str): + """ + :说明: + + 使用此方法移除指定群成员(需要有相关权限) + + :参数: + + * ``target: int``: 指定群的群号 + * ``member_id: int``: 指定群员QQ号 + * ``msg: str``: 信息 + """ + return await self.api.post('kick', + params={ + 'target': target, + 'memberId': member_id, + 'msg': msg + }) + + @argument_validation + async def quit(self, target: int): + """ + :说明: + + 使用此方法使Bot退出群聊 + + :参数: + + * ``target: int``: 退出的群号 + """ + return await self.api.post('quit', params={'target': target}) + + @argument_validation + async def mute_all(self, target: int): + """ + :说明: + + 使用此方法令指定群进行全体禁言(需要有相关权限) + + :参数: + + * ``target: int``: 指定群的群号 + """ + return await self.api.post('muteAll', params={'target': target}) + + @argument_validation + async def unmute_all(self, target: int): + """ + :说明: + + 使用此方法令指定群解除全体禁言(需要有相关权限) + + :参数: + + * ``target: int``: 指定群的群号 + """ + return await self.api.post('unmuteAll', params={'target': target}) + + @argument_validation + async def group_config(self, target: int): + """ + :说明: + + 使用此方法获取群设置 + + :参数: + + * ``target: int``: 指定群的群号 + + :返回: + + .. code-block:: json + + { + "name": "群名称", + "announcement": "群公告", + "confessTalk": true, + "allowMemberInvite": true, + "autoApprove": true, + "anonymousChat": true + } + """ + return await self.api.request('groupConfig', params={'target': target}) + + @argument_validation + async def modify_group_config(self, target: int, config: Dict[str, Any]): + """ + :说明: + + 使用此方法修改群设置(需要有相关权限) + + :参数: + + * ``target: int``: 指定群的群号 + * ``config: Dict[str, Any]``: 群设置, 格式见 ``group_config`` 的返回值 + """ + return await self.api.post('groupConfig', + params={ + 'target': target, + 'config': config + }) + + @argument_validation + async def member_info(self, target: int, member_id: int): + """ + :说明: + + 使用此方法获取群员资料 + + :参数: + + * ``target: int``: 指定群的群号 + * ``member_id: int``: 群员QQ号 + + :返回: + + .. code-block:: json + + { + "name": "群名片", + "specialTitle": "群头衔" + } + """ + return await self.api.request('memberInfo', + params={ + 'target': target, + 'memberId': member_id + }) + + @argument_validation + async def modify_member_info(self, target: int, member_id: int, + info: Dict[str, Any]): + """ + :说明: + + 使用此方法修改群员资料(需要有相关权限) + + :参数: + + * ``target: int``: 指定群的群号 + * ``member_id: int``: 群员QQ号 + * ``info: Dict[str, Any]``: 群员资料, 格式见 ``member_info`` 的返回值 + """ + return await self.api.post('memberInfo', + params={ + 'target': target, + 'memberId': member_id, + 'info': info + }) diff --git a/nonebot/adapters/mirai/bot_ws.py b/nonebot/adapters/mirai/bot_ws.py new file mode 100644 index 00000000..9dabe356 --- /dev/null +++ b/nonebot/adapters/mirai/bot_ws.py @@ -0,0 +1,192 @@ +import asyncio +import json +from ipaddress import IPv4Address +from typing import (Any, Callable, Coroutine, Dict, NoReturn, Optional, Set, + TypeVar) + +import httpx +import websockets + +from nonebot.config import Config +from nonebot.drivers import Driver +from nonebot.drivers import WebSocket as BaseWebSocket +from nonebot.exception import RequestDenied +from nonebot.log import logger +from nonebot.typing import overrides + +from .bot import SessionManager, Bot + +WebsocketHandlerFunction = Callable[[Dict[str, Any]], Coroutine[Any, Any, None]] +WebsocketHandler_T = TypeVar('WebsocketHandler_T', + bound=WebsocketHandlerFunction) + + +class WebSocket(BaseWebSocket): + + @classmethod + async def new(cls, *, host: IPv4Address, port: int, + session_key: str) -> "WebSocket": + listen_address = httpx.URL(f'ws://{host}:{port}/all', + params={'sessionKey': session_key}) + websocket = await websockets.connect(uri=str(listen_address)) + await (await websocket.ping()) + return cls(websocket) + + @overrides(BaseWebSocket) + def __init__(self, websocket: websockets.WebSocketClientProtocol): + self.event_handlers: Set[WebsocketHandlerFunction] = set() + super().__init__(websocket) + + @property + @overrides(BaseWebSocket) + def websocket(self) -> websockets.WebSocketClientProtocol: + return self._websocket + + @property + @overrides(BaseWebSocket) + def closed(self) -> bool: + return self.websocket.closed + + @overrides(BaseWebSocket) + async def send(self, data: Dict[str, Any]): + return await self.websocket.send(json.dumps(data)) + + @overrides(BaseWebSocket) + async def receive(self) -> Dict[str, Any]: + received = await self.websocket.recv() + return json.loads(received) + + async def _dispatcher(self): + while not self.closed: + try: + data = await self.receive() + except websockets.ConnectionClosedOK: + logger.debug(f'Websocket connection {self.websocket} closed') + break + except websockets.ConnectionClosedError: + logger.exception(f'Websocket connection {self.websocket} ' + 'connection closed abnormally:') + break + except json.JSONDecodeError as e: + logger.exception(f'Websocket client listened {self.websocket} ' + f'failed to decode data: {e}') + continue + asyncio.gather( + *map(lambda f: f(data), self.event_handlers), #type: ignore + return_exceptions=True) + + @overrides(BaseWebSocket) + async def accept(self): + asyncio.create_task(self._dispatcher()) + + @overrides(BaseWebSocket) + async def close(self): + await self.websocket.close() + + def handle(self, callable: WebsocketHandler_T) -> WebsocketHandler_T: + self.event_handlers.add(callable) + return callable + + +class WebsocketBot(Bot): + """ + mirai-api-http 正向 Websocket 协议 Bot 适配。 + """ + + @overrides(Bot) + def __init__(self, connection_type: str, self_id: str, *, + websocket: WebSocket): + super().__init__(connection_type, self_id, websocket=websocket) + + @property + @overrides(Bot) + def type(self) -> str: + return "mirai-ws" + + @property + def alive(self) -> bool: + return not self.websocket.closed + + @property + def api(self) -> SessionManager: + api = SessionManager.get(self_id=int(self.self_id), check_expire=False) + assert api is not None, 'SessionManager has not been initialized' + return api + + @classmethod + @overrides(Bot) + async def check_permission(cls, driver: "Driver", connection_type: str, + headers: dict, body: Optional[dict]) -> NoReturn: + raise RequestDenied( + status_code=501, + reason=f'Connection {connection_type} not implented') + + @classmethod + @overrides(Bot) + def register(cls, driver: "Driver", config: "Config", qq: int): + """ + :说明: + + 注册该Adapter + + :参数: + + * ``driver: Driver``: 程序所使用的``Driver`` + * ``config: Config``: 程序配置对象 + * ``qq: int``: 要使用的Bot的QQ号 **注意: 在使用正向Websocket时必须指定该值!** + """ + super().register(driver, config) + cls.active = True + + async def _bot_connection(): + session: SessionManager = await SessionManager.new( + qq, + host=cls.mirai_config.host, # type: ignore + port=cls.mirai_config.port, # type: ignore + auth_key=cls.mirai_config.auth_key # type: ignore + ) + websocket = await WebSocket.new( + host=cls.mirai_config.host, # type: ignore + port=cls.mirai_config.port, # type: ignore + session_key=session.session_key) + bot = cls(connection_type='forward_ws', + self_id=str(qq), + websocket=websocket) + websocket.handle(bot.handle_message) + await websocket.accept() + return bot + + async def _connection_ensure(): + self_id = str(qq) + if self_id not in driver._clients: + bot = await _bot_connection() + driver._bot_connect(bot) + else: + bot = driver._clients[self_id] + if not bot.alive: + driver._bot_disconnect(bot) + return + + @driver.on_startup + async def _startup(): + + async def _checker(): + while cls.active: + try: + await _connection_ensure() + except Exception as e: + logger.opt(colors=True).warning( + 'Failed to create mirai connection to ' + f'{qq}, reason: {e}. ' + 'Will retry after 3 seconds') + await asyncio.sleep(3) + + asyncio.create_task(_checker()) + + @driver.on_shutdown + async def _shutdown(): + cls.active = False + bot = driver._clients.pop(str(qq), None) + if bot is None: + return + await bot.websocket.close() #type:ignore diff --git a/nonebot/adapters/mirai/config.py b/nonebot/adapters/mirai/config.py new file mode 100644 index 00000000..a907dd17 --- /dev/null +++ b/nonebot/adapters/mirai/config.py @@ -0,0 +1,22 @@ +from ipaddress import IPv4Address +from typing import Optional + +from pydantic import BaseModel, Extra, Field + + +class Config(BaseModel): + """ + Mirai 配置类 + + :必填: + + - ``mirai_auth_key``: mirai-api-http的auth_key + - ``mirai_host``: mirai-api-http的地址 + - ``mirai_port``: mirai-api-http的端口 + """ + auth_key: Optional[str] = Field(None, alias='mirai_auth_key') + host: Optional[IPv4Address] = Field(None, alias='mirai_host') + port: Optional[int] = Field(None, alias='mirai_port') + + class Config: + extra = Extra.ignore diff --git a/nonebot/adapters/mirai/event/__init__.py b/nonebot/adapters/mirai/event/__init__.py new file mode 100644 index 00000000..1cf92096 --- /dev/null +++ b/nonebot/adapters/mirai/event/__init__.py @@ -0,0 +1,29 @@ +""" +\:\:\: warning +事件中为了使代码更加整洁, 我们采用了与PEP8相符的命名规则取代Mirai原有的驼峰命名 + +部分字段可能与文档在符号上不一致 +\:\:\: +""" +from .base import (Event, GroupChatInfo, GroupInfo, PrivateChatInfo, + UserPermission) +from .message import * +from .notice import * +from .request import * + +__all__ = [ + 'Event', 'GroupChatInfo', 'GroupInfo', 'PrivateChatInfo', 'UserPermission', + 'MessageChain', 'MessageEvent', 'GroupMessage', 'FriendMessage', + 'TempMessage', 'NoticeEvent', 'MuteEvent', 'BotMuteEvent', 'BotUnmuteEvent', + 'MemberMuteEvent', 'MemberUnmuteEvent', 'BotJoinGroupEvent', + 'BotLeaveEventActive', 'BotLeaveEventKick', 'MemberJoinEvent', + 'MemberLeaveEventKick', 'MemberLeaveEventQuit', 'FriendRecallEvent', + 'GroupRecallEvent', 'GroupStateChangeEvent', 'GroupNameChangeEvent', + 'GroupEntranceAnnouncementChangeEvent', 'GroupMuteAllEvent', + 'GroupAllowAnonymousChatEvent', 'GroupAllowConfessTalkEvent', + 'GroupAllowMemberInviteEvent', 'MemberStateChangeEvent', + 'MemberCardChangeEvent', 'MemberSpecialTitleChangeEvent', + 'BotGroupPermissionChangeEvent', 'MemberPermissionChangeEvent', + 'RequestEvent', 'NewFriendRequestEvent', 'MemberJoinRequestEvent', + 'BotInvitedJoinGroupRequestEvent' +] diff --git a/nonebot/adapters/mirai/event/base.py b/nonebot/adapters/mirai/event/base.py new file mode 100644 index 00000000..4a7b3809 --- /dev/null +++ b/nonebot/adapters/mirai/event/base.py @@ -0,0 +1,133 @@ +import json +from enum import Enum +from typing import Any, Dict, Optional, Type + +from pydantic import BaseModel, Field, ValidationError +from typing_extensions import Literal + +from nonebot.adapters import Event as BaseEvent +from nonebot.adapters import Message as BaseMessage +from nonebot.log import logger +from nonebot.typing import overrides + + +class UserPermission(str, Enum): + """ + :说明: + + 用户权限枚举类 + + * ``OWNER``: 群主 + * ``ADMINISTRATOR``: 群管理 + * ``MEMBER``: 普通群成员 + """ + OWNER = 'OWNER' + ADMINISTRATOR = 'ADMINISTRATOR' + MEMBER = 'MEMBER' + + +class GroupInfo(BaseModel): + id: int + name: str + permission: UserPermission + + +class GroupChatInfo(BaseModel): + id: int + name: str = Field(alias='memberName') + permission: UserPermission + group: GroupInfo + + +class PrivateChatInfo(BaseModel): + id: int + nickname: str + remark: str + + +class Event(BaseEvent): + """ + mirai-api-http 协议事件,字段与 mirai-api-http 一致。各事件字段参考 `mirai-api-http 事件类型`_ + + .. _mirai-api-http 事件类型: + https://github.com/project-mirai/mirai-api-http/blob/master/docs/EventType.md + """ + self_id: int + type: str + + @classmethod + def new(cls, data: Dict[str, Any]) -> "Event": + """ + 此事件类的工厂函数, 能够通过事件数据选择合适的子类进行序列化 + """ + type = data['type'] + + def all_subclasses(cls: Type[Event]): + return set(cls.__subclasses__()).union( + [s for c in cls.__subclasses__() for s in all_subclasses(c)]) + + event_class: Optional[Type[Event]] = None + for subclass in all_subclasses(cls): + if subclass.__name__ != type: + continue + event_class = subclass + + if event_class is None: + return Event.parse_obj(data) + + while issubclass(event_class, Event): + try: + return event_class.parse_obj(data) + except ValidationError as e: + logger.info( + f'Failed to parse {data} to class {event_class.__name__}: ' + f'{e.errors()!r}. Fallback to parent class.') + event_class = event_class.__base__ + + raise ValueError(f'Failed to serialize {data}.') + + @overrides(BaseEvent) + def get_type(self) -> Literal["message", "notice", "request", "meta_event"]: + from . import message, notice, request, meta + if isinstance(self, message.MessageEvent): + return 'message' + elif isinstance(self, notice.NoticeEvent): + return 'notice' + elif isinstance(self, request.RequestEvent): + return 'request' + else: + return 'meta_event' + + @overrides(BaseEvent) + def get_event_name(self) -> str: + return self.type + + @overrides(BaseEvent) + def get_event_description(self) -> str: + return str(self.normalize_dict()) + + @overrides(BaseEvent) + def get_message(self) -> BaseMessage: + raise ValueError("Event has no message!") + + @overrides(BaseEvent) + def get_plaintext(self) -> str: + raise ValueError("Event has no message!") + + @overrides(BaseEvent) + def get_user_id(self) -> str: + raise ValueError("Event has no message!") + + @overrides(BaseEvent) + def get_session_id(self) -> str: + raise ValueError("Event has no message!") + + @overrides(BaseEvent) + def is_tome(self) -> bool: + return False + + def normalize_dict(self, **kwargs) -> Dict[str, Any]: + """ + 返回可以被json正常反序列化的结构体 + """ + return json.loads(self.json(**kwargs)) diff --git a/nonebot/adapters/mirai/event/message.py b/nonebot/adapters/mirai/event/message.py new file mode 100644 index 00000000..26d534d4 --- /dev/null +++ b/nonebot/adapters/mirai/event/message.py @@ -0,0 +1,78 @@ +from typing import Any + +from pydantic import Field + +from nonebot.typing import overrides + +from ..message import MessageChain +from .base import Event, GroupChatInfo, PrivateChatInfo + + +class MessageEvent(Event): + """消息事件基类""" + message_chain: MessageChain = Field(alias='messageChain') + sender: Any + + @overrides(Event) + def get_message(self) -> MessageChain: + return self.message_chain + + @overrides(Event) + def get_plaintext(self) -> str: + return self.message_chain.extract_plain_text() + + @overrides(Event) + def get_user_id(self) -> str: + raise NotImplementedError + + @overrides(Event) + def get_session_id(self) -> str: + raise NotImplementedError + + +class GroupMessage(MessageEvent): + """群消息事件""" + sender: GroupChatInfo + to_me: bool = False + + @overrides(MessageEvent) + def get_session_id(self) -> str: + return f'group_{self.sender.group.id}_' + self.get_user_id() + + @overrides(MessageEvent) + def get_user_id(self) -> str: + return str(self.sender.id) + + @overrides(MessageEvent) + def is_tome(self) -> bool: + return self.to_me + + +class FriendMessage(MessageEvent): + """好友消息事件""" + sender: PrivateChatInfo + + @overrides(MessageEvent) + def get_user_id(self) -> str: + return str(self.sender.id) + + @overrides(MessageEvent) + def get_session_id(self) -> str: + return 'friend_' + self.get_user_id() + + @overrides(MessageEvent) + def is_tome(self) -> bool: + return True + + +class TempMessage(MessageEvent): + """临时会话消息事件""" + sender: GroupChatInfo + + @overrides(MessageEvent) + def get_session_id(self) -> str: + return f'temp_{self.sender.group.id}_' + self.get_user_id() + + @overrides(MessageEvent) + def is_tome(self) -> bool: + return True diff --git a/nonebot/adapters/mirai/event/meta.py b/nonebot/adapters/mirai/event/meta.py new file mode 100644 index 00000000..e42baf72 --- /dev/null +++ b/nonebot/adapters/mirai/event/meta.py @@ -0,0 +1,31 @@ +from .base import Event + + +class MetaEvent(Event): + """元事件基类""" + qq: int + + +class BotOnlineEvent(MetaEvent): + """Bot登录成功""" + pass + + +class BotOfflineEventActive(MetaEvent): + """Bot主动离线""" + pass + + +class BotOfflineEventForce(MetaEvent): + """Bot被挤下线""" + pass + + +class BotOfflineEventDropped(MetaEvent): + """Bot被服务器断开或因网络问题而掉线""" + pass + + +class BotReloginEvent(MetaEvent): + """Bot主动重新登录""" + pass \ No newline at end of file diff --git a/nonebot/adapters/mirai/event/notice.py b/nonebot/adapters/mirai/event/notice.py new file mode 100644 index 00000000..276b12d1 --- /dev/null +++ b/nonebot/adapters/mirai/event/notice.py @@ -0,0 +1,156 @@ +from typing import Any, Optional + +from pydantic import Field + +from .base import Event, GroupChatInfo, GroupInfo, UserPermission + + +class NoticeEvent(Event): + """通知事件基类""" + pass + + +class MuteEvent(NoticeEvent): + """禁言类事件基类""" + operator: GroupChatInfo + + +class BotMuteEvent(MuteEvent): + """Bot被禁言""" + pass + + +class BotUnmuteEvent(MuteEvent): + """Bot被取消禁言""" + pass + + +class MemberMuteEvent(MuteEvent): + """群成员被禁言事件(该成员不是Bot)""" + duration_seconds: int = Field(alias='durationSeconds') + member: GroupChatInfo + operator: Optional[GroupChatInfo] = None + + +class MemberUnmuteEvent(MuteEvent): + """群成员被取消禁言事件(该成员不是Bot)""" + member: GroupChatInfo + operator: Optional[GroupChatInfo] = None + + +class BotJoinGroupEvent(NoticeEvent): + """Bot加入了一个新群""" + group: GroupInfo + + +class BotLeaveEventActive(BotJoinGroupEvent): + """Bot主动退出一个群""" + pass + + +class BotLeaveEventKick(BotJoinGroupEvent): + """Bot被踢出一个群""" + pass + + +class MemberJoinEvent(NoticeEvent): + """新人入群的事件""" + member: GroupChatInfo + + +class MemberLeaveEventKick(MemberJoinEvent): + """成员被踢出群(该成员不是Bot)""" + operator: Optional[GroupChatInfo] = None + + +class MemberLeaveEventQuit(MemberJoinEvent): + """成员主动离群(该成员不是Bot)""" + pass + + +class FriendRecallEvent(NoticeEvent): + """好友消息撤回""" + author_id: int = Field(alias='authorId') + message_id: int = Field(alias='messageId') + time: int + operator: int + + +class GroupRecallEvent(FriendRecallEvent): + """群消息撤回""" + group: GroupInfo + operator: Optional[GroupChatInfo] = None + + +class GroupStateChangeEvent(NoticeEvent): + """群变化事件基类""" + origin: Any + current: Any + group: GroupInfo + operator: Optional[GroupChatInfo] = None + + +class GroupNameChangeEvent(GroupStateChangeEvent): + """某个群名改变""" + origin: str + current: str + + +class GroupEntranceAnnouncementChangeEvent(GroupStateChangeEvent): + """某群入群公告改变""" + origin: str + current: str + + +class GroupMuteAllEvent(GroupStateChangeEvent): + """全员禁言""" + origin: bool + current: bool + + +class GroupAllowAnonymousChatEvent(GroupStateChangeEvent): + """匿名聊天""" + origin: bool + current: bool + + +class GroupAllowConfessTalkEvent(GroupStateChangeEvent): + """坦白说""" + origin: bool + current: bool + + +class GroupAllowMemberInviteEvent(GroupStateChangeEvent): + """允许群员邀请好友加群""" + origin: bool + current: bool + + +class MemberStateChangeEvent(NoticeEvent): + """群成员变化事件基类""" + member: GroupChatInfo + operator: Optional[GroupChatInfo] = None + + +class MemberCardChangeEvent(MemberStateChangeEvent): + """群名片改动""" + origin: str + current: str + + +class MemberSpecialTitleChangeEvent(MemberStateChangeEvent): + """群头衔改动(只有群主有操作限权)""" + origin: str + current: str + + +class BotGroupPermissionChangeEvent(MemberStateChangeEvent): + """Bot在群里的权限被改变""" + origin: UserPermission + current: UserPermission + + +class MemberPermissionChangeEvent(MemberStateChangeEvent): + """成员权限改变的事件(该成员不是Bot)""" + origin: UserPermission + current: UserPermission diff --git a/nonebot/adapters/mirai/event/request.py b/nonebot/adapters/mirai/event/request.py new file mode 100644 index 00000000..3bf82f01 --- /dev/null +++ b/nonebot/adapters/mirai/event/request.py @@ -0,0 +1,170 @@ +from typing import TYPE_CHECKING + +from pydantic import Field +from typing_extensions import Literal + +from .base import Event + +if TYPE_CHECKING: + from ..bot import Bot + + +class RequestEvent(Event): + """请求事件基类""" + event_id: int = Field(alias='eventId') + message: str + nick: str + + +class NewFriendRequestEvent(RequestEvent): + """添加好友申请""" + from_id: int = Field(alias='fromId') + group_id: int = Field(0, alias='groupId') + + async def approve(self, bot: "Bot"): + """ + :说明: + + 通过此人的好友申请 + + :参数: + + * ``bot: Bot``: 当前的 ``Bot`` 对象 + """ + return await bot.api.post('/resp/newFriendRequestEvent', + params={ + 'eventId': self.event_id, + 'groupId': self.group_id, + 'fromId': self.from_id, + 'operate': 0 + }) + + async def reject(self, + bot: "Bot", + operate: Literal[1, 2] = 1, + message: str = ''): + """ + :说明: + + 拒绝此人的好友申请 + + :参数: + + * ``bot: Bot``: 当前的 ``Bot`` 对象 + * ``operate: Literal[1, 2]``: 响应的操作类型 + + * ``1``: 拒绝添加好友 + * ``2``: 拒绝添加好友并添加黑名单,不再接收该用户的好友申请 + + * ``message: str``: 回复的信息 + """ + assert operate > 0 + return await bot.api.post('/resp/newFriendRequestEvent', + params={ + 'eventId': self.event_id, + 'groupId': self.group_id, + 'fromId': self.from_id, + 'operate': operate, + 'message': message + }) + + +class MemberJoinRequestEvent(RequestEvent): + """用户入群申请(Bot需要有管理员权限)""" + from_id: int = Field(alias='fromId') + group_id: int = Field(alias='groupId') + group_name: str = Field(alias='groupName') + + async def approve(self, bot: "Bot"): + """ + :说明: + + 通过此人的加群申请 + + :参数: + + * ``bot: Bot``: 当前的 ``Bot`` 对象 + """ + return await bot.api.post('/resp/memberJoinRequestEvent', + params={ + 'eventId': self.event_id, + 'groupId': self.group_id, + 'fromId': self.from_id, + 'operate': 0 + }) + + async def reject(self, + bot: "Bot", + operate: Literal[1, 2, 3, 4] = 1, + message: str = ''): + """ + :说明: + + 拒绝(忽略)此人的加群申请 + + :参数: + + * ``bot: Bot``: 当前的 ``Bot`` 对象 + * ``operate: Literal[1, 2, 3, 4]``: 响应的操作类型 + + * ``1``: 拒绝入群 + * ``2``: 忽略请求 + * ``3``: 拒绝入群并添加黑名单,不再接收该用户的入群申请 + * ``4``: 忽略入群并添加黑名单,不再接收该用户的入群申请 + + * ``message: str``: 回复的信息 + """ + assert operate > 0 + return await bot.api.post('/resp/memberJoinRequestEvent', + params={ + 'eventId': self.event_id, + 'groupId': self.group_id, + 'fromId': self.from_id, + 'operate': operate, + 'message': message + }) + + +class BotInvitedJoinGroupRequestEvent(RequestEvent): + """Bot被邀请入群申请""" + from_id: int = Field(alias='fromId') + group_id: int = Field(alias='groupId') + group_name: str = Field(alias='groupName') + + async def approve(self, bot: "Bot"): + """ + :说明: + + 通过这份被邀请入群申请 + + :参数: + + * ``bot: Bot``: 当前的 ``Bot`` 对象 + """ + return await bot.api.post('/resp/botInvitedJoinGroupRequestEvent', + params={ + 'eventId': self.event_id, + 'groupId': self.group_id, + 'fromId': self.from_id, + 'operate': 0 + }) + + async def reject(self, bot: "Bot", message: str = ""): + """ + :说明: + + 拒绝这份被邀请入群申请 + + :参数: + + * ``bot: Bot``: 当前的 ``Bot`` 对象 + * ``message: str``: 邀请消息 + """ + return await bot.api.post('/resp/botInvitedJoinGroupRequestEvent', + params={ + 'eventId': self.event_id, + 'groupId': self.group_id, + 'fromId': self.from_id, + 'operate': 1, + 'message': message + }) diff --git a/nonebot/adapters/mirai/message.py b/nonebot/adapters/mirai/message.py new file mode 100644 index 00000000..26fb198c --- /dev/null +++ b/nonebot/adapters/mirai/message.py @@ -0,0 +1,310 @@ +from enum import Enum +from typing import Any, Dict, Iterable, List, Optional, Union + +from pydantic import validate_arguments + +from nonebot.adapters import Message as BaseMessage +from nonebot.adapters import MessageSegment as BaseMessageSegment +from nonebot.typing import overrides + + +class MessageType(str, Enum): + """消息类型枚举类""" + SOURCE = 'Source' + QUOTE = 'Quote' + AT = 'At' + AT_ALL = 'AtAll' + FACE = 'Face' + PLAIN = 'Plain' + IMAGE = 'Image' + FLASH_IMAGE = 'FlashImage' + VOICE = 'Voice' + XML = 'Xml' + JSON = 'Json' + APP = 'App' + POKE = 'Poke' + + +class MessageSegment(BaseMessageSegment): + """ + CQHTTP 协议 MessageSegment 适配。具体方法参考 `mirai-api-http 消息类型`_ + + .. _mirai-api-http 消息类型: + https://github.com/project-mirai/mirai-api-http/blob/master/docs/MessageType.md + """ + + type: MessageType + data: Dict[str, Any] + + @overrides(BaseMessageSegment) + @validate_arguments + def __init__(self, type: MessageType, **data): + super().__init__(type=type, + data={k: v for k, v in data.items() if v is not None}) + + @overrides(BaseMessageSegment) + def __str__(self) -> str: + if self.is_text(): + return self.data.get('text', '') + return '[mirai:%s]' % ','.join([ + self.type.value, + *map( + lambda s: '%s=%r' % s, + self.data.items(), + ), + ]) + + @overrides(BaseMessageSegment) + def __add__(self, other) -> "MessageChain": + return MessageChain(self) + other + + @overrides(BaseMessageSegment) + def __radd__(self, other) -> "MessageChain": + return MessageChain(other) + self + + @overrides(BaseMessageSegment) + def is_text(self) -> bool: + return self.type == MessageType.PLAIN + + def as_dict(self) -> Dict[str, Any]: + """导出可以被正常json序列化的结构体""" + return {'type': self.type.value, **self.data} + + @classmethod + def source(cls, id: int, time: int): + return cls(type=MessageType.SOURCE, id=id, time=time) + + @classmethod + def quote(cls, id: int, group_id: int, sender_id: int, target_id: int, + origin: "MessageChain"): + """ + :说明: + + 生成回复引用消息段 + + :参数: + + * ``id: int``: 被引用回复的原消息的message_id + * ``group_id: int``: 被引用回复的原消息所接收的群号,当为好友消息时为0 + * ``sender_id: int``: 被引用回复的原消息的发送者的QQ号 + * ``target_id: int``: 被引用回复的原消息的接收者者的QQ号(或群号) + * ``origin: MessageChain``: 被引用回复的原消息的消息链对象 + """ + return cls(type=MessageType.QUOTE, + id=id, + groupId=group_id, + senderId=sender_id, + targetId=target_id, + origin=origin.export()) + + @classmethod + def at(cls, target: int): + """ + :说明: + + @某个人 + + :参数: + + * ``target: int``: 群员QQ号 + """ + return cls(type=MessageType.AT, target=target) + + @classmethod + def at_all(cls): + """ + :说明: + + @全体成员 + """ + return cls(type=MessageType.AT_ALL) + + @classmethod + def face(cls, face_id: Optional[int] = None, name: Optional[str] = None): + """ + :说明: + + 发送QQ表情 + + :参数: + + * ``face_id: Optional[int]``: QQ表情编号,可选,优先高于name + * ``name: Optional[str]``: QQ表情拼音,可选 + """ + return cls(type=MessageType.FACE, faceId=face_id, name=name) + + @classmethod + def plain(cls, text: str): + """ + :说明: + + 纯文本消息 + + :参数: + + * ``text: str``: 文字消息 + """ + return cls(type=MessageType.PLAIN, text=text) + + @classmethod + def image(cls, + image_id: Optional[str] = None, + url: Optional[str] = None, + path: Optional[str] = None): + """ + :说明: + + 图片消息 + + :参数: + + * ``image_id: Optional[str]``: 图片的image_id,群图片与好友图片格式不同。不为空时将忽略url属性 + * ``url: Optional[str]``: 图片的URL,发送时可作网络图片的链接 + * ``path: Optional[str]``: 图片的路径,发送本地图片 + """ + return cls(type=MessageType.IMAGE, imageId=image_id, url=url, path=path) + + @classmethod + def flash_image(cls, + image_id: Optional[str] = None, + url: Optional[str] = None, + path: Optional[str] = None): + """ + :说明: + + 闪照消息 + + :参数: + + 同 ``image`` + """ + return cls(type=MessageType.FLASH_IMAGE, + imageId=image_id, + url=url, + path=path) + + @classmethod + def voice(cls, + voice_id: Optional[str] = None, + url: Optional[str] = None, + path: Optional[str] = None): + """ + :说明: + + 语音消息 + + :参数: + + * ``voice_id: Optional[str]``: 语音的voice_id,不为空时将忽略url属性 + * ``url: Optional[str]``: 语音的URL,发送时可作网络语音的链接 + * ``path: Optional[str]``: 语音的路径,发送本地语音 + """ + return cls(type=MessageType.FLASH_IMAGE, + imageId=voice_id, + url=url, + path=path) + + @classmethod + def xml(cls, xml: str): + """ + :说明: + + XML消息 + + :参数: + + * ``xml: str``: XML文本 + """ + return cls(type=MessageType.XML, xml=xml) + + @classmethod + def json(cls, json: str): + """ + :说明: + + Json消息 + + :参数: + + * ``json: str``: Json文本 + """ + return cls(type=MessageType.JSON, json=json) + + @classmethod + def app(cls, content: str): + """ + :说明: + + 应用程序消息 + + :参数: + + * ``content: str``: 内容 + """ + return cls(type=MessageType.APP, content=content) + + @classmethod + def poke(cls, name: str): + """ + :说明: + + 戳一戳消息 + + :参数: + + * ``name: str``: 戳一戳的类型 + + * ``Poke``: 戳一戳 + * ``ShowLove``: 比心 + * ``Like``: 点赞 + * ``Heartbroken``: 心碎 + * ``SixSixSix``: 666 + * ``FangDaZhao``: 放大招 + + """ + return cls(type=MessageType.POKE, name=name) + + +class MessageChain(BaseMessage): + """ + Mirai 协议 Messaqge 适配 + + 由于Mirai协议的Message实现较为特殊, 故使用MessageChain命名 + """ + + @overrides(BaseMessage) + def __init__(self, message: Union[List[Dict[str, Any]], + Iterable[MessageSegment], MessageSegment], + **kwargs): + super().__init__(**kwargs) + if isinstance(message, MessageSegment): + self.append(message) + elif isinstance(message, Iterable): + self.extend(self._construct(message)) + else: + raise ValueError( + f'Type {type(message).__name__} is not supported in mirai adapter.' + ) + + @overrides(BaseMessage) + def _construct( + self, message: Union[List[Dict[str, Any]], Iterable[MessageSegment]] + ) -> List[MessageSegment]: + if isinstance(message, str): + raise ValueError( + "String operation is not supported in mirai adapter") + return [ + *map( + lambda x: x + if isinstance(x, MessageSegment) else MessageSegment(**x), + message) + ] + + def export(self) -> List[Dict[str, Any]]: + """导出为可以被正常json序列化的数组""" + return [ + *map(lambda segment: segment.as_dict(), self.copy()) # type: ignore + ] + + def __repr__(self) -> str: + return f'<{self.__class__.__name__} {[*self.copy()]}>' diff --git a/nonebot/adapters/mirai/utils.py b/nonebot/adapters/mirai/utils.py new file mode 100644 index 00000000..cb2b5e2d --- /dev/null +++ b/nonebot/adapters/mirai/utils.py @@ -0,0 +1,156 @@ +import re +from functools import wraps +from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional, TypeVar + +import httpx +from pydantic import Extra, ValidationError, validate_arguments + +import nonebot.exception as exception +from nonebot.log import logger +from nonebot.utils import escape_tag, logger_wrapper + +from .event import Event, GroupMessage +from .message import MessageSegment, MessageType + +if TYPE_CHECKING: + from .bot import Bot + +_AsyncCallable = TypeVar("_AsyncCallable", bound=Callable[..., Coroutine]) +_AnyCallable = TypeVar("_AnyCallable", bound=Callable) + + +class Log: + _log = logger_wrapper('MIRAI') + + @classmethod + def info(cls, message: Any): + cls._log('INFO', str(message)) + + @classmethod + def debug(cls, message: Any): + cls._log('DEBUG', str(message)) + + @classmethod + def warn(cls, message: Any): + cls._log('WARNING', str(message)) + + @classmethod + def error(cls, message: Any, exception: Optional[Exception] = None): + cls._log('ERROR', str(message), exception=exception) + + +class ActionFailed(exception.ActionFailed): + """ + :说明: + + API 请求成功返回数据,但 API 操作失败。 + """ + + def __init__(self, **kwargs): + super().__init__('mirai') + self.data = kwargs.copy() + + def __repr__(self): + return self.__class__.__name__ + '(%s)' % ', '.join( + map(lambda m: '%s=%r' % m, self.data.items())) + + +class InvalidArgument(exception.AdapterException): + """ + :说明: + + 调用API的参数出错 + """ + + def __init__(self, **kwargs): + super().__init__('mirai') + + +def catch_network_error(function: _AsyncCallable) -> _AsyncCallable: + """ + :说明: + + 捕捉函数抛出的httpx网络异常并释放 ``NetworkError`` 异常 + + 处理返回数据, 在code不为0时释放 ``ActionFailed`` 异常 + + \:\:\: warning + 此装饰器只支持使用了httpx的异步函数 + \:\:\: + """ + + @wraps(function) + async def wrapper(*args, **kwargs): + try: + data = await function(*args, **kwargs) + except httpx.HTTPError: + raise exception.NetworkError('mirai') + logger.opt(colors=True).debug('Mirai API returned data: ' + f'{escape_tag(str(data))}') + if isinstance(data, dict): + if data.get('code', 0) != 0: + raise ActionFailed(**data) + return data + + return wrapper # type: ignore + + +def argument_validation(function: _AnyCallable) -> _AnyCallable: + """ + :说明: + + 通过函数签名中的类型注解来对传入参数进行运行时校验 + + 会在参数出错时释放 ``InvalidArgument`` 异常 + """ + function = validate_arguments(config={ + 'arbitrary_types_allowed': True, + 'extra': Extra.forbid + })(function) + + @wraps(function) + def wrapper(*args, **kwargs): + try: + return function(*args, **kwargs) + except ValidationError: + raise InvalidArgument + + return wrapper # type: ignore + + +async def check_tome(bot: "Bot", event: "Event") -> "Event": + if not isinstance(event, GroupMessage): + return event + + def _is_at(event: GroupMessage) -> bool: + for segment in event.message_chain: + segment: MessageSegment + if segment.type != MessageType.AT: + continue + if segment.data['target'] == event.self_id: + return True + return False + + def _is_nick(event: GroupMessage) -> bool: + text = event.get_plaintext() + if not text: + return False + nick_regex = '|'.join( + {i.strip() for i in bot.config.nickname if i.strip()}) + matched = re.search(rf"^({nick_regex})([\s,,]*|$)", text, re.IGNORECASE) + if matched is None: + return False + Log.info(f'User is calling me {matched.group(1)}') + return True + + def _is_reply(event: GroupMessage) -> bool: + for segment in event.message_chain: + segment: MessageSegment + if segment.type != MessageType.QUOTE: + continue + if segment.data['senderId'] == event.self_id: + return True + return False + + event.to_me = any([_is_at(event), _is_reply(event), _is_nick(event)]) + return event diff --git a/poetry.lock b/poetry.lock index dc0b527f..9523412b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -143,7 +143,7 @@ reference = "aliyun" [[package]] name = "httpcore" -version = "0.12.2" +version = "0.12.3" description = "A minimal low-level HTTP client." category = "main" optional = false @@ -228,7 +228,7 @@ reference = "aliyun" [[package]] name = "jinja2" -version = "2.11.2" +version = "2.11.3" description = "A very fast and expressive template engine." category = "dev" optional = false @@ -280,7 +280,7 @@ reference = "aliyun" [[package]] name = "packaging" -version = "20.8" +version = "20.9" description = "Core utilities for Python packages" category = "dev" optional = false @@ -334,7 +334,7 @@ reference = "aliyun" [[package]] name = "pygments" -version = "2.7.3" +version = "2.7.4" description = "Pygments is a syntax highlighting package written in Python." category = "dev" optional = false @@ -347,7 +347,7 @@ reference = "aliyun" [[package]] name = "pygtrie" -version = "2.4.1" +version = "2.4.2" description = "A pure Python trie data structure implementation." category = "main" optional = false @@ -457,8 +457,8 @@ reference = "aliyun" [[package]] name = "snowballstemmer" -version = "2.0.0" -description = "This package provides 26 stemmers for 25 languages generated from Snowball algorithms." +version = "2.1.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." category = "dev" optional = false python-versions = "*" @@ -470,7 +470,7 @@ reference = "aliyun" [[package]] name = "sphinx" -version = "3.4.1" +version = "3.4.3" description = "Python documentation generator" category = "dev" optional = false @@ -687,7 +687,7 @@ reference = "aliyun" [[package]] name = "urllib3" -version = "1.26.2" +version = "1.26.3" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "dev" optional = false @@ -784,7 +784,7 @@ reference = "aliyun" [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "55439e671ff8c89285f2cf645189c1bf3e3bd53638bbb31ed505727a041d1012" +content-hash = "0038c5b3aa4a382184c1ef5b37a668ce37d8246c8fdf18deb71dccc8bf97be62" [metadata.files] alabaster = [ @@ -828,8 +828,8 @@ html2text = [ {file = "html2text-2020.1.16.tar.gz", hash = "sha256:e296318e16b059ddb97f7a8a1d6a5c1d7af4544049a01e261731d2d5cc277bbb"}, ] httpcore = [ - {file = "httpcore-0.12.2-py3-none-any.whl", hash = "sha256:420700af11db658c782f7e8fda34f9dcd95e3ee93944dd97d78cb70247e0cd06"}, - {file = "httpcore-0.12.2.tar.gz", hash = "sha256:dd1d762d4f7c2702149d06be2597c35fb154c5eff9789a8c5823fbcf4d2978d6"}, + {file = "httpcore-0.12.3-py3-none-any.whl", hash = "sha256:93e822cd16c32016b414b789aeff4e855d0ccbfc51df563ee34d4dbadbb3bcdc"}, + {file = "httpcore-0.12.3.tar.gz", hash = "sha256:37ae835fb370049b2030c3290e12ed298bf1473c41bb72ca4aa78681eba9b7c9"}, ] httptools = [ {file = "httptools-0.1.1-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:a2719e1d7a84bb131c4f1e0cb79705034b48de6ae486eb5297a139d6a3296dce"}, @@ -858,8 +858,8 @@ imagesize = [ {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, ] jinja2 = [ - {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"}, - {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, + {file = "Jinja2-2.11.3-py2.py3-none-any.whl", hash = "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419"}, + {file = "Jinja2-2.11.3.tar.gz", hash = "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"}, ] loguru = [ {file = "loguru-0.5.3-py3-none-any.whl", hash = "sha256:f8087ac396b5ee5f67c963b495d615ebbceac2796379599820e324419d53667c"}, @@ -901,8 +901,8 @@ markupsafe = [ {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, ] packaging = [ - {file = "packaging-20.8-py2.py3-none-any.whl", hash = "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858"}, - {file = "packaging-20.8.tar.gz", hash = "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"}, + {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, + {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, ] pydantic = [ {file = "pydantic-1.7.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c59ea046aea25be14dc22d69c97bee629e6d48d2b2ecb724d7fe8806bf5f61cd"}, @@ -933,11 +933,11 @@ pydash = [ {file = "pydash-4.9.2.tar.gz", hash = "sha256:11d8f3c92d92a004e042fdb226b10dba28f4e311546b0de89d983e91539d5e55"}, ] pygments = [ - {file = "Pygments-2.7.3-py3-none-any.whl", hash = "sha256:f275b6c0909e5dafd2d6269a656aa90fa58ebf4a74f8fcf9053195d226b24a08"}, - {file = "Pygments-2.7.3.tar.gz", hash = "sha256:ccf3acacf3782cbed4a989426012f1c535c9a90d3a7fc3f16d231b9372d2b716"}, + {file = "Pygments-2.7.4-py3-none-any.whl", hash = "sha256:bc9591213a8f0e0ca1a5e68a479b4887fdc3e75d0774e5c71c31920c427de435"}, + {file = "Pygments-2.7.4.tar.gz", hash = "sha256:df49d09b498e83c1a73128295860250b0b7edd4c723a32e9bc0d295c7c2ec337"}, ] pygtrie = [ - {file = "pygtrie-2.4.1.tar.gz", hash = "sha256:4367b87d92eaf475107421dce0295a9d4d72156702908c96c430a426b654aee7"}, + {file = "pygtrie-2.4.2.tar.gz", hash = "sha256:43205559d28863358dbbf25045029f58e2ab357317a59b11f11ade278ac64692"}, ] pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, @@ -964,12 +964,12 @@ sniffio = [ {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, ] snowballstemmer = [ - {file = "snowballstemmer-2.0.0-py2.py3-none-any.whl", hash = "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0"}, - {file = "snowballstemmer-2.0.0.tar.gz", hash = "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"}, + {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"}, + {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"}, ] sphinx = [ - {file = "Sphinx-3.4.1-py3-none-any.whl", hash = "sha256:aeef652b14629431c82d3fe994ce39ead65b3fe87cf41b9a3714168ff8b83376"}, - {file = "Sphinx-3.4.1.tar.gz", hash = "sha256:e450cb205ff8924611085183bf1353da26802ae73d9251a8fcdf220a8f8712ef"}, + {file = "Sphinx-3.4.3-py3-none-any.whl", hash = "sha256:c314c857e7cd47c856d2c5adff514ac2e6495f8b8e0f886a8a37e9305dfea0d8"}, + {file = "Sphinx-3.4.3.tar.gz", hash = "sha256:41cad293f954f7d37f803d97eb184158cfd90f51195131e94875bc07cd08b93c"}, ] sphinx-markdown-builder = [] sphinxcontrib-applehelp = [ @@ -1012,8 +1012,8 @@ untokenize = [ {file = "untokenize-0.1.1.tar.gz", hash = "md5:50d325dff09208c624cc603fad33bb0d"}, ] urllib3 = [ - {file = "urllib3-1.26.2-py2.py3-none-any.whl", hash = "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"}, - {file = "urllib3-1.26.2.tar.gz", hash = "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08"}, + {file = "urllib3-1.26.3-py2.py3-none-any.whl", hash = "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80"}, + {file = "urllib3-1.26.3.tar.gz", hash = "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"}, ] uvicorn = [ {file = "uvicorn-0.11.8-py3-none-any.whl", hash = "sha256:4b70ddb4c1946e39db9f3082d53e323dfd50634b95fd83625d778729ef1730ef"}, diff --git a/pyproject.toml b/pyproject.toml index 23f8e799..39854210 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ loguru = "^0.5.1" pygtrie = "^2.4.1" fastapi = "^0.63.0" uvicorn = "^0.11.5" +websockets = "^8.1" pydantic = {extras = ["dotenv", "typing_extensions"], version = "^1.7.3"} [tool.poetry.dev-dependencies] diff --git a/tests/.env.dev b/tests/.env.dev index 9b69f65a..33e6f835 100644 --- a/tests/.env.dev +++ b/tests/.env.dev @@ -11,3 +11,7 @@ COMMAND_SEP=["/", "."] CUSTOM_CONFIG1=config in env CUSTOM_CONFIG3= + +MIRAI_AUTH_KEY=12345678 +MIRAI_HOST=127.0.0.1 +MIRAI_PORT=8080 \ No newline at end of file diff --git a/tests/bot.py b/tests/bot.py index 6e45e051..849aee27 100644 --- a/tests/bot.py +++ b/tests/bot.py @@ -6,6 +6,7 @@ sys.path.insert(0, os.path.abspath("..")) import nonebot from nonebot.adapters.cqhttp import Bot from nonebot.adapters.ding import Bot as DingBot +from nonebot.adapters.mirai import Bot as MiraiBot from nonebot.log import logger, default_format # test custom log @@ -20,6 +21,7 @@ app = nonebot.get_asgi() driver = nonebot.get_driver() driver.register_adapter("cqhttp", Bot) driver.register_adapter("ding", DingBot) +driver.register_adapter("mirai", MiraiBot) # load builtin plugin nonebot.load_builtin_plugins() diff --git a/tests/test_plugins/test_mirai.py b/tests/test_plugins/test_mirai.py new file mode 100644 index 00000000..a5da93ae --- /dev/null +++ b/tests/test_plugins/test_mirai.py @@ -0,0 +1,13 @@ +from nonebot.plugin import on_message +from nonebot.adapters.mirai import Bot, MessageEvent + +message_test = on_message() + + +@message_test.handle() +async def _message(bot: Bot, event: MessageEvent): + text = event.get_plaintext() + if not text: + return + reversed_text = ''.join(reversed(text)) + await bot.send(event, reversed_text, at_sender=True)