diff --git a/docs/api/adapters/ding.md b/docs/api/adapters/ding.md index 10d0aa42..0bbdd6d8 100644 --- a/docs/api/adapters/ding.md +++ b/docs/api/adapters/ding.md @@ -5,6 +5,376 @@ sidebarDepth: 0 # NoneBot.adapters.ding 模块 -## 钉钉群机器人 协议适配 -协议详情请看: [钉钉文档](https://ding-doc.dingtalk.com/doc#/serverapi2/krgddi) +## _exception_ `DingAdapterException` + +基类:[`nonebot.exception.AdapterException`](../exception.md#nonebot.exception.AdapterException) + + +* **说明** + + 钉钉 Adapter 错误基类 + + + +## _exception_ `ActionFailed` + +基类:[`nonebot.exception.ActionFailed`](../exception.md#nonebot.exception.ActionFailed), `nonebot.adapters.ding.exception.DingAdapterException` + + +* **说明** + + API 请求返回错误信息。 + + + +* **参数** + + + * `errcode: Optional[int]`: 错误码 + + + * `errmsg: Optional[str]`: 错误信息 + + + +## _exception_ `ApiNotAvailable` + +基类:[`nonebot.exception.ApiNotAvailable`](../exception.md#nonebot.exception.ApiNotAvailable), `nonebot.adapters.ding.exception.DingAdapterException` + + +## _exception_ `NetworkError` + +基类:[`nonebot.exception.NetworkError`](../exception.md#nonebot.exception.NetworkError), `nonebot.adapters.ding.exception.DingAdapterException` + + +* **说明** + + 网络错误。 + + + +* **参数** + + + * `retcode: Optional[int]`: 错误码 + + + +## _exception_ `SessionExpired` + +基类:[`nonebot.exception.ApiNotAvailable`](../exception.md#nonebot.exception.ApiNotAvailable), `nonebot.adapters.ding.exception.DingAdapterException` + + +* **说明** + + 发消息的 session 已经过期。 + + + +## _class_ `Bot` + +基类:[`nonebot.adapters.BaseBot`](README.md#nonebot.adapters.BaseBot) + +钉钉 协议 Bot 适配。继承属性参考 [BaseBot](./#class-basebot) 。 + + +### _property_ `type` + + +* 返回: `"ding"` + + +### _async classmethod_ `check_permission(driver, connection_type, headers, body)` + + +* **说明** + + 钉钉协议鉴权。参考 [鉴权](https://ding-doc.dingtalk.com/doc#/serverapi2/elzz1p) + + + +### _async_ `handle_message(body)` + + +* **说明** + + 处理上报消息的函数,转换为 `Event` 事件后调用 `nonebot.message.handle_event` 进一步处理事件。 + + + +* **参数** + + + * `message: dict`: 收到的上报消息 + + + +### _async_ `call_api(api, event=None, **data)` + + +* **说明** + + 调用 钉钉 协议 API + + + +* **参数** + + + * `api: str`: API 名称 + + + * `**data: Any`: API 参数 + + + +* **返回** + + + * `Any`: API 调用返回数据 + + + +* **异常** + + + * `NetworkError`: 网络错误 + + + * `ActionFailed`: API 调用失败 + + + +### _async_ `send(event, message, at_sender=False, **kwargs)` + + +* **说明** + + 根据 `event` 向触发事件的主体发送消息。 + + + +* **参数** + + + * `event: Event`: Event 对象 + + + * `message: Union[str, Message, MessageSegment]`: 要发送的消息 + + + * `at_sender: bool`: 是否 @ 事件主体 + + + * `**kwargs`: 覆盖默认参数 + + + +* **返回** + + + * `Any`: API 调用返回数据 + + + +* **异常** + + + * `ValueError`: 缺少 `user_id`, `group_id` + + + * `NetworkError`: 网络错误 + + + * `ActionFailed`: API 调用失败 + + + +## _class_ `Event` + +基类:[`nonebot.adapters.BaseEvent`](README.md#nonebot.adapters.BaseEvent) + +钉钉 协议 Event 适配。继承属性参考 [BaseEvent](./#class-baseevent) 。 + + +### _property_ `raw_event` + +原始上报消息 + + +### _property_ `id` + + +* 类型: `Optional[str]` + + +* 说明: 消息 ID + + +### _property_ `name` + + +* 类型: `str` + + +* 说明: 事件名称,由 type.\`detail_type\` 组合而成 + + +### _property_ `self_id` + + +* 类型: `str` + + +* 说明: 机器人自身 ID + + +### _property_ `time` + + +* 类型: `int` + + +* 说明: 消息的时间戳,单位 s + + +### _property_ `type` + + +* 类型: `str` + + +* 说明: 事件类型 + + +### _property_ `detail_type` + + +* 类型: `str` + + +* 说明: 事件详细类型 + + +### _property_ `sub_type` + + +* 类型: `None` + + +* 说明: 钉钉适配器无事件子类型 + + +### _property_ `user_id` + + +* 类型: `Optional[str]` + + +* 说明: 发送者 ID + + +### _property_ `group_id` + + +* 类型: `Optional[str]` + + +* 说明: 事件主体群 ID + + +### _property_ `to_me` + + +* 类型: `Optional[bool]` + + +* 说明: 消息是否与机器人相关 + + +### _property_ `message` + + +* 类型: `Optional[Message]` + + +* 说明: 消息内容 + + +### _property_ `reply` + + +* 类型: `None` + + +* 说明: 回复消息详情 + + +### _property_ `raw_message` + + +* 类型: `Optional[str]` + + +* 说明: 原始消息 + + +### _property_ `plain_text` + + +* 类型: `Optional[str]` + + +* 说明: 纯文本消息内容 + + +### _property_ `sender` + + +* 类型: `Optional[dict]` + + +* 说明: 消息发送者信息 + + +## _class_ `MessageSegment` + +基类:[`nonebot.adapters.BaseMessageSegment`](README.md#nonebot.adapters.BaseMessageSegment) + +钉钉 协议 MessageSegment 适配。具体方法参考协议消息段类型或源码。 + + +### _static_ `actionCardSingleMultiBtns(title, text, btns=[], hideAvatar=False, btnOrientation='1')` + + +* **参数** + + + * `btnOrientation`: 0:按钮竖直排列 1:按钮横向排列 + + + * `btns`: [{ "title": title, "actionURL": actionURL }, ...] + + + +### _static_ `feedCard(links=[])` + + +* **参数** + + + * `links`: [{ "title": xxx, "messageURL": xxx, "picURL": xxx }, ...] + + + +### _static_ `empty()` + +不想回复消息到群里 + + +## _class_ `Message` + +基类:[`nonebot.adapters.BaseMessage`](README.md#nonebot.adapters.BaseMessage) + +钉钉 协议 Message 适配。 diff --git a/docs/api/utils.md b/docs/api/utils.md index ed98fab9..52cf5766 100644 --- a/docs/api/utils.md +++ b/docs/api/utils.md @@ -54,8 +54,6 @@ sidebarDepth: 0 ## _class_ `DataclassEncoder` -基类:`json.encoder.JSONEncoder` - * **说明** diff --git a/docs_build/adapters/ding.rst b/docs_build/adapters/ding.rst index b26dac19..cff5979f 100644 --- a/docs_build/adapters/ding.rst +++ b/docs_build/adapters/ding.rst @@ -6,7 +6,24 @@ sidebarDepth: 0 NoneBot.adapters.ding 模块 ============================ -.. automodule:: nonebot.adapters.ding +.. automodule:: nonebot.adapters.ding.exception + :members: + :show-inheritance: + + +.. automodule:: nonebot.adapters.ding.bot + :members: + :private-members: + :show-inheritance: + + +.. automodule:: nonebot.adapters.ding.event + :members: + :private-members: + :show-inheritance: + + +.. automodule:: nonebot.adapters.ding.message :members: :private-members: :show-inheritance: diff --git a/docs_build/utils.rst b/docs_build/utils.rst index b7609fdc..5e7acd8d 100644 --- a/docs_build/utils.rst +++ b/docs_build/utils.rst @@ -10,4 +10,5 @@ NoneBot.utils 模块 .. autofunction:: nonebot.utils.escape_tag .. autodecorator:: nonebot.utils.run_sync .. autoclass:: nonebot.utils.DataclassEncoder +.. autodecorator:: nonebot.utils.logger_wrapper :show-inheritance: diff --git a/nonebot/adapters/cqhttp/__init__.py b/nonebot/adapters/cqhttp/__init__.py index 8271c1e6..14635eda 100644 --- a/nonebot/adapters/cqhttp/__init__.py +++ b/nonebot/adapters/cqhttp/__init__.py @@ -14,3 +14,4 @@ from .event import Event from .message import Message, MessageSegment from .utils import log, escape, unescape, _b2s from .bot import Bot, _check_at_me, _check_nickname, _check_reply, _handle_api_result +from .exception import CQHTTPAdapterException, ApiNotAvailable, ActionFailed, NetworkError diff --git a/nonebot/adapters/cqhttp/bot.py b/nonebot/adapters/cqhttp/bot.py index af673ec9..929cd095 100644 --- a/nonebot/adapters/cqhttp/bot.py +++ b/nonebot/adapters/cqhttp/bot.py @@ -247,7 +247,7 @@ class Bot(BaseBot): """ x_self_id = headers.get("x-self-id") x_signature = headers.get("x-signature") - access_token = get_auth_bearer(headers.get("authorization")) + token = get_auth_bearer(headers.get("authorization")) # 检查连接方式 if connection_type not in ["http", "websocket"]: @@ -272,13 +272,13 @@ class Bot(BaseBot): raise RequestDenied(403, "Signature is invalid") access_token = driver.config.access_token - if access_token and access_token != access_token: + if access_token and access_token != token: log( "WARNING", "Authorization Header is invalid" - if access_token else "Missing Authorization Header") + if token else "Missing Authorization Header") raise RequestDenied( 403, "Authorization Header is invalid" - if access_token else "Missing Authorization Header") + if token else "Missing Authorization Header") return str(x_self_id) @overrides(BaseBot) diff --git a/nonebot/adapters/ding/__init__.py b/nonebot/adapters/ding/__init__.py index e9742bf4..4eb33e28 100644 --- a/nonebot/adapters/ding/__init__.py +++ b/nonebot/adapters/ding/__init__.py @@ -9,7 +9,9 @@ """ +from .utils import log from .bot import Bot from .event import Event from .message import Message, MessageSegment -from .exception import ApiError, SessionExpired, DingAdapterException +from .exception import (DingAdapterException, ApiNotAvailable, NetworkError, + ActionFailed, SessionExpired) diff --git a/nonebot/adapters/ding/bot.py b/nonebot/adapters/ding/bot.py index 97ff7c2e..0f0a10a8 100644 --- a/nonebot/adapters/ding/bot.py +++ b/nonebot/adapters/ding/bot.py @@ -1,19 +1,20 @@ -import httpx +import hmac +import base64 from datetime import datetime +import httpx from nonebot.log import logger from nonebot.config import Config from nonebot.adapters import BaseBot from nonebot.message import handle_event -from nonebot.typing import Driver, NoReturn -from nonebot.typing import Any, Union, Optional -from nonebot.exception import NetworkError, RequestDenied, ApiNotAvailable +from nonebot.exception import RequestDenied +from nonebot.typing import Any, Union, Driver, Optional, NoReturn +from .utils import log from .event import Event from .model import MessageModel -from .utils import check_legal, log from .message import Message, MessageSegment -from .exception import ApiError, SessionExpired +from .exception import NetworkError, ApiNotAvailable, ActionFailed, SessionExpired class Bot(BaseBot): @@ -35,8 +36,7 @@ class Bot(BaseBot): @classmethod async def check_permission(cls, driver: Driver, connection_type: str, - headers: dict, - body: Optional[dict]) -> Union[str, NoReturn]: + headers: dict, body: Optional[dict]) -> str: """ :说明: @@ -45,25 +45,29 @@ class Bot(BaseBot): timestamp = headers.get("timestamp") sign = headers.get("sign") - # 检查 timestamp - if not timestamp: - raise RequestDenied(400, "Missing `timestamp` Header") - # 检查 sign - if not sign: - raise RequestDenied(400, "Missing `sign` Header") - # 校验 sign 和 timestamp,判断是否是来自钉钉的合法请求 - if not check_legal(timestamp, sign, driver): - raise RequestDenied(403, "Signature is invalid") # 检查连接方式 if connection_type not in ["http"]: raise RequestDenied(405, "Unsupported connection type") - access_token = driver.config.access_token - if access_token and access_token != access_token: - raise RequestDenied( - 403, "Authorization Header is invalid" - if access_token else "Missing Authorization Header") - return body.get("chatbotUserId") + # 检查 timestamp + if not timestamp: + raise RequestDenied(400, "Missing `timestamp` Header") + + # 检查 sign + secret = driver.config.secret + if secret: + if not sign: + log("WARNING", "Missing Signature Header") + raise RequestDenied(400, "Missing `sign` Header") + string_to_sign = f"{timestamp}\n{secret}" + sig = hmac.new(secret.encode("utf-8"), + string_to_sign.encode("utf-8"), "sha256").digest() + if sign != base64.b64encode(sig).decode("utf-8"): + log("WARNING", "Signature Header is invalid") + raise RequestDenied(403, "Signature is invalid") + else: + log("WARNING", "Ding signature check ignored!") + return body["chatbotUserId"] async def handle_message(self, body: dict): message = MessageModel.parse_obj(body) @@ -79,7 +83,10 @@ class Bot(BaseBot): ) return - async def call_api(self, api: str, **data) -> Union[Any, NoReturn]: + async def call_api(self, + api: str, + event: Optional[Event] = None, + **data) -> Union[Any, NoReturn]: """ :说明: @@ -111,13 +118,15 @@ class Bot(BaseBot): log("DEBUG", f"Calling API {api}") if api == "send_message": - raw_event: MessageModel = data["raw_event"] - # 确保 sessionWebhook 没有过期 - if int(datetime.now().timestamp()) > int( - raw_event.sessionWebhookExpiredTime / 1000): - raise SessionExpired + if event: + # 确保 sessionWebhook 没有过期 + if int(datetime.now().timestamp()) > int( + event.raw_event.sessionWebhookExpiredTime / 1000): + raise SessionExpired - target = raw_event.sessionWebhook + target = event.raw_event.sessionWebhook + else: + target = None if not target: raise ApiNotAvailable @@ -136,8 +145,8 @@ class Bot(BaseBot): result = response.json() if isinstance(result, dict): if result.get("errcode") != 0: - raise ApiError(errcode=result.get("errcode"), - errmsg=result.get("errmsg")) + raise ActionFailed(errcode=result.get("errcode"), + errmsg=result.get("errmsg")) return result raise NetworkError(f"HTTP request received unexpected " f"status code: {response.status_code}") @@ -176,7 +185,8 @@ class Bot(BaseBot): msg = message if isinstance(message, Message) else Message(message) at_sender = at_sender and bool(event.user_id) - params = {"raw_event": event.raw_event} + params = {} + params["event"] = event params.update(kwargs) if at_sender and event.detail_type != "private": diff --git a/nonebot/adapters/ding/event.py b/nonebot/adapters/ding/event.py index 9c9fb50f..876ad493 100644 --- a/nonebot/adapters/ding/event.py +++ b/nonebot/adapters/ding/event.py @@ -1,6 +1,5 @@ -from typing import Literal, Union, Optional - from nonebot.adapters import BaseEvent +from nonebot.typing import Union, Optional from .message import Message from .model import MessageModel, ConversationType, TextMessage @@ -67,7 +66,7 @@ class Event(BaseEvent): pass @property - def detail_type(self) -> Literal["private", "group"]: + def detail_type(self) -> str: """ - 类型: ``str`` - 说明: 事件详细类型 @@ -125,10 +124,6 @@ class Event(BaseEvent): """ return self.detail_type == "private" or self.raw_event.isInAtList - @to_me.setter - def to_me(self, value) -> None: - pass - @property def message(self) -> Optional["Message"]: """ diff --git a/nonebot/adapters/ding/exception.py b/nonebot/adapters/ding/exception.py index ad6f4a20..b1d74d14 100644 --- a/nonebot/adapters/ding/exception.py +++ b/nonebot/adapters/ding/exception.py @@ -1,4 +1,8 @@ -from nonebot.exception import AdapterException, ActionFailed, ApiNotAvailable +from nonebot.typing import Optional +from nonebot.exception import (AdapterException, ActionFailed as + BaseActionFailed, ApiNotAvailable as + BaseApiNotAvailable, NetworkError as + BaseNetworkError) class DingAdapterException(AdapterException): @@ -6,22 +10,27 @@ class DingAdapterException(AdapterException): :说明: 钉钉 Adapter 错误基类 - """ def __init__(self) -> None: super().__init__("ding") -class ApiError(DingAdapterException, ActionFailed): +class ActionFailed(BaseActionFailed, DingAdapterException): """ :说明: API 请求返回错误信息。 + :参数: + + * ``errcode: Optional[int]``: 错误码 + * ``errmsg: Optional[str]``: 错误信息 """ - def __init__(self, errcode: int, errmsg: str): + def __init__(self, + errcode: Optional[int] = None, + errmsg: Optional[str] = None): super().__init__() self.errcode = errcode self.errmsg = errmsg @@ -30,12 +39,37 @@ class ApiError(DingAdapterException, ActionFailed): return f"" -class SessionExpired(DingAdapterException, ApiNotAvailable): +class ApiNotAvailable(BaseApiNotAvailable, DingAdapterException): + pass + + +class NetworkError(BaseNetworkError, DingAdapterException): + """ + :说明: + + 网络错误。 + + :参数: + + * ``retcode: Optional[int]``: 错误码 + """ + + def __init__(self, msg: Optional[str] = None): + super().__init__() + self.msg = msg + + def __repr__(self): + return f"" + + def __str__(self): + return self.__repr__() + + +class SessionExpired(BaseApiNotAvailable, DingAdapterException): """ :说明: 发消息的 session 已经过期。 - """ def __repr__(self) -> str: diff --git a/nonebot/adapters/ding/utils.py b/nonebot/adapters/ding/utils.py index 8c644683..eb4145bc 100644 --- a/nonebot/adapters/ding/utils.py +++ b/nonebot/adapters/ding/utils.py @@ -1,35 +1,3 @@ -import base64 -import hashlib -import hmac -from typing import TYPE_CHECKING - from nonebot.utils import logger_wrapper -if TYPE_CHECKING: - from nonebot.drivers import BaseDriver log = logger_wrapper("DING") - - -def check_legal(timestamp, remote_sign, driver: "BaseDriver"): - """ - 1. timestamp 与系统当前时间戳如果相差1小时以上,则认为是非法的请求。 - - 2. sign 与开发者自己计算的结果不一致,则认为是非法的请求。 - - 必须当timestamp和sign同时验证通过,才能认为是来自钉钉的合法请求。 - """ - # 目前先设置成 secret - # TODO 后面可能可以从 secret[adapter_name] 获取 - app_secret = driver.config.secret # 机器人的 appSecret - if not app_secret: - # TODO warning - log("WARNING", "No ding secrets set, won't check sign") - return True - app_secret_enc = app_secret.encode('utf-8') - string_to_sign = '{}\n{}'.format(timestamp, app_secret) - string_to_sign_enc = string_to_sign.encode('utf-8') - hmac_code = hmac.new(app_secret_enc, - string_to_sign_enc, - digestmod=hashlib.sha256).digest() - sign = base64.b64encode(hmac_code).decode('utf-8') - return remote_sign == sign diff --git a/nonebot/utils.py b/nonebot/utils.py index 7e59e2aa..7ef93769 100644 --- a/nonebot/utils.py +++ b/nonebot/utils.py @@ -5,7 +5,7 @@ import dataclasses from functools import wraps, partial from nonebot.log import logger -from nonebot.typing import Any, Callable, Awaitable, overrides +from nonebot.typing import Any, Optional, Callable, Awaitable, overrides def escape_tag(s: str) -> str: @@ -65,19 +65,20 @@ class DataclassEncoder(json.JSONEncoder): def logger_wrapper(logger_name: str): + """ + :说明: - def log(level: str, message: str): - """ - :说明: + 用于打印 adapter 的日志。 - 用于打印 adapter 的日志。 + :log 参数: - :参数: + * ``level: Literal['WARNING', 'DEBUG', 'INFO']``: 日志等级 + * ``message: str``: 日志信息 + * ``exception: Optional[Exception]``: 异常信息 + """ - * ``level: Literal['WARNING', 'DEBUG', 'INFO']``: 日志等级 - * ``message: str``: 日志信息 - """ - return logger.opt(colors=True).log(level, - f"{logger_name} | " + message) + def log(level: str, message: str, exception: Optional[Exception] = None): + return logger.opt(colors=True, exception=exception).log( + level, f"{logger_name} | " + message) return log