🚧 update ding adapter

This commit is contained in:
yanyongyu 2020-12-03 17:08:16 +08:00
parent dc691889e3
commit afd01796aa
12 changed files with 496 additions and 99 deletions

View File

@ -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 适配。

View File

@ -54,8 +54,6 @@ sidebarDepth: 0
## _class_ `DataclassEncoder`
基类:`json.encoder.JSONEncoder`
* **说明**

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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 <y>{api}</y>")
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":

View File

@ -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"]:
"""

View File

@ -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"<ApiError errcode={self.errcode} errmsg={self.errmsg}>"
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"<NetWorkError message={self.msg}>"
def __str__(self):
return self.__repr__()
class SessionExpired(BaseApiNotAvailable, DingAdapterException):
"""
:说明:
发消息的 session 已经过期
"""
def __repr__(self) -> str:

View File

@ -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

View File

@ -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"<m>{logger_name}</m> | " + message)
def log(level: str, message: str, exception: Optional[Exception] = None):
return logger.opt(colors=True, exception=exception).log(
level, f"<m>{logger_name}</m> | " + message)
return log