nonebot / nonebot2

跨平台 Python 异步聊天机器人框架 / Asynchronous multi-platform chatbot framework written in Python
https://nonebot.dev
MIT License
5.84k stars 551 forks source link

RFC: 适配器规范 #2435

Open RF-Tar-Railt opened 10 months ago

RF-Tar-Railt commented 10 months ago

希望能解决的问题

Nonebot现在的适配器很多,但是基本上都是各写各的,没有相对的统一结构,甚至存在代码质量的问题(e.g. Bilibili适配器)。

在这份issue中,我们提出了一个通用的适配器规范,其不会影响适配器的平台特性,并且能为用户提供更好的使用体验。

描述所需要的功能

以下规范依据文件名划分

adapter.py

  1. 对于网络请求,适配器不应当依赖特定的网络层框架/库(e.g. httpx)。
  2. 适配器的网络请求或 ws 连接应通过驱动器的 Request 进行。若存在所使用的驱动器无法满足平台条件的情况,[1] 条可忽略。
  3. 基于 [1][2],适配器应在 setup 阶段检查驱动器类型是否符合要求:
    def setup(self) -> None:
        if not isinstance(self.driver, HTTPClientMixin):
            raise RuntimeError(
                f"Current driver {self.config.driver} does not support http client requests! "
                f"{adapter} Adapter need a HTTPClient Driver to work."
            )
        if not isinstance(self.driver, WebSocketClientMixin):
            raise RuntimeError(
                f"Current driver {self.config.driver} does not support websocket client! "
                f"{adapter} Adapter need a WebSocketClient Driver to work."
            )
        self.driver.on_ready(self.prepare_connection)
        self.driver.on_shutdown(self.close_connection)
  4. 对于网络请求,若请求时出现异常,适配器应抛出 NetworkError;若请求的响应状态码异常(e.g. 404),适配器应根据情况抛出对应的异常。

exception.py

  1. 基于[4],适配器应继承 nonebot.exception 中的基础异常类型,声明适配器特定异常:

    import json
    from typing import Optional
    
    from nonebot.drivers import Response
    from nonebot.exception import AdapterException
    from nonebot.exception import ActionFailed as BaseActionFailed
    from nonebot.exception import NetworkError as BaseNetworkError
    from nonebot.exception import ApiNotAvailable as BaseApiNotAvailable
    
    class XXXAdapterException(AdapterException):
        def __init__(self):
            super().__init__("xxx")
    
    class NetworkError(BaseNetworkError, XXXAdapterException):
        def __init__(self, msg: Optional[str] = None):
            super().__init__()
            self.msg: Optional[str] = msg
            """错误原因"""
    
        def __repr__(self):
            return f"<NetWorkError message={self.msg}>"
    
        def __str__(self):
            return self.__repr__()
    
    class ActionFailed(BaseActionFailed, XXXAdapterException):
        def __init__(self, response: Response):
            self.status_code: int = response.status_code
            self.code: Optional[int] = ...
            self.message: Optional[str] = ...
            self.data: Optional[dict] = ...
    
    class UnauthorizedException(ActionFailed):
        pass
    
    class RateLimitException(ActionFailed):
        pass
    
    class ApiNotAvailable(BaseApiNotAvailable, XXXAdapterException):
        pass    

event.py

  1. Event 应存在 time 字段,表示事件创建的时间。time 为 datetime.datetime 类型。
  2. MessageEvent 应存在如下字段:

    • to_me: bool 类型,可选(因为主要是 is_tome 方法)。
    • reply: 一般为 reply 对应的原始消息/原始事件(由 reply 上的 msgid获取);同时也可以为自定义结构(e.g. ob12适配器下的Reply),但是应当挂载一个 async def get_origin() 方法,以获取原始事件;若平台不存在回复元素,置空即可。
    • message/_message: 适配器对应的 Message 类型。若原事件已存在 message字段并无法转换类型,则使用 _message
    • original_message: 适配器对应的 Message 类型,并且未经过 check_at_me, check_reply 等处理。
    • message_id: 消息id (有时与事件id等同),用于构造回复消息,撤回消息,编辑消息等操作;若平台不存在消息id,使用 "" 或随机生成即可。

    其中 _message, original_message 可如下处理:

    from copy import deepcopy
    from typing import TYPE_CHECKING
    from .message import Message
    
    class MessageEvent(Event):
        if TYPE_CHECKING:
            message: Message
            original_message: Message
    
        def get_message(self):
            if not hasattr(self, "message"):
                msg = Message(xxx)
                setattr(self, "message", msg)
                setattr(self, "original_message", deepcopy(msg))
            return getattr(self, "message")

bot.py

  1. 适配器应当在 handle_event内执行 check_reply, check_at_me, check_nickname。

    • check_reply: 检查消息序列或事件中是否存在 reply 数据,无论是否回复 bot 自己,有则移除 reply 消息段,并将 reply 对应的事件赋值给 reply 属性。在此之上,若回复的是 bot 自己的消息,则设 to_me 为真。
    • check_at_me:检查消息首部或尾部是否是 @bot_self, 如果是则移除,并且连带@后随的空格一并移除,设 to_me 为真。尾部 at 不强制要求。
    • check_nickname: 如果机器人配置了nickname,则检查消息首部是否为昵称,同 check_at_me。
  2. 适配器应当在 Bot 上编写常用的方法/接口,并写明每个接口的输入类型和输出类型。如果对接协议不存在或未允许扩展api,请将Bot的getattr方法明确写为不支持(def __getattr__(self, item): raise NotImplementError);否则需要编写 bot.pyi 或生成全部的可用方法,而不是完全让用户使用call_api。

  3. Bot 应声明自己的 adapter属性为适配器对应的 Adapter 类型:

    from typing import TYPE_CHECKING, override
    from nonebot.adapters import Bot as BaseBot
    
    if TYPE_CHECKING:
        from .adapter import Adapter
    
    class Bot(BaseBot):
        adapter: "Adapter"
    
        @override
        def __init__(self, adapter: "Adapter", self_id: str,  **kwargs):
            ...

    message.py

  4. 适配器应在 message.py 内编写原始数据转为消息序列的方法(e.g. Message.from_guild_message)。

  5. 消息段应尽量使用子类消息段+父类消息段静态方法合集:

    class MessageSegment(BaseMessageSegment):
        @staticmethod
        def text(text: str) -> "Text":
            return Text("text", {"text": text})
    
    class Text(MessageSegment):
        @override
        def __str__(self):
            return self.data["text"]
  6. MessageSegment.text 的 data 必须为 {"text": xxx}

  7. 对于某些平台存在元素包含特殊子元素的情况(例如,kook平台的 KMarkdown 包含 mention, emoji等子元素),适配器应特殊处理,将这些子元素提取为单独的消息段。

utils.py

  1. 适配器的日志部分应使用 logger_wrapper 返回的 log 进行:
    from nonebot.utils import logger_wrapper
    log = logger_wrapper("XXX Adapter")

README.md

  1. 适配器的 README 应当写明自己的配置项,至少需要说明自己需要的驱动器类型。

pyproject.toml

  1. 适配器的最低 python 依赖版本应跟随 nonebot2 要求的最低版本。
GreyElaina commented 10 months ago

Q: 如果对接形似 python-satori 或者极端一点的 avilla,网络通信的部分应如何处理?

RF-Tar-Railt commented 10 months ago

Q: 如果对接形似 python-satori 或者极端一点的 avilla,网络通信的部分应如何处理?

A: 适配器的网络通信部分针对的是适配器通过网络通信与对应平台交互的情况。若适配器使用的是另外的sdk,则适配器本体不关心网络部分。但是相应的,适配器应对sdk做类似处理(e.g. 挂载运行任务,处理异常返回)

GreyElaina commented 10 months ago

check_at_me:检查消息首部或尾部是否是 @bot_self, 如果是则移除,并且连带@后随的空格一并移除,设 to_me 为真。尾部 at 不强制要求。

Q: 存疑的,例如 Telegram 平台中,对于调用某个 Bot 的指令,可以用 /some_command@some_bot 的形式,这种似乎不符合 “尾部或首部” 的定义……?

RF-Tar-Railt commented 10 months ago

check_at_me:检查消息首部或尾部是否是 @bot_self, 如果是则移除,并且连带@后随的空格一并移除,设 to_me 为真。尾部 at 不强制要求。

Q: 存疑的,例如 Telegram 平台中,对于调用某个 Bot 的指令,可以用 /some_command@some_bot 的形式,这种似乎不符合 “尾部或首部” 的定义……?

按ddl说法,这种情况的 @ 确实得移除(只要是 @ 当前bot自己的)

CMHopeSunshine commented 10 months ago

可以在适配器编写教程中写一写吧

RF-Tar-Railt commented 10 months ago

提案:对于存在 "回复消息" 的适配器,是否强制要求在消息段层面实现一个 MessageSegment.reply,或要求在 send 接口增加与 reply 相关的参数? (例如,feishu 适配器有专门的接口发送回复消息,tg适配器有专门的参数指定回复元素,而 red,sattori,qq 适配器则可以直接在 Message 中构造回复元素)

GreyElaina commented 10 months ago

我的评价是: avilla.standard.core

RF-Tar-Railt commented 10 months ago

新增:第 13条 MessageSegment.text 的 data 必须为 {"text": xxx} 更新:第 7条

RF-Tar-Railt commented 10 months ago

@ssttkkl @Tian-que

RF-Tar-Railt commented 10 months ago

新增:第14条 对于某些平台存在元素包含特殊子元素的情况(例如,kook平台的 KMarkdown 包含 mention, emoji等子元素),适配器应特殊处理,将这些子元素提取为单独的消息段。 @ssttkkl @Tian-que

RF-Tar-Railt commented 5 months ago

更新第9条: 适配器应当在 Bot 上编写常用的方法/接口,并写明每个接口的输入类型和输出类型。如果对接协议不存在或未允许扩展api,请将Bot的getattr方法明确写为不支持(def __getattr__(self, item): raise NotImplementError);否则需要编写 bot.pyi 或生成全部的可用方法,而不是完全让用户使用call_api。