🔀 Merge pull request #94

Add dingding adapter
This commit is contained in:
Ju4tCode 2020-12-03 19:33:58 +08:00 committed by GitHub
commit b24649b6af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1361 additions and 384 deletions

View File

@ -198,6 +198,10 @@ module.exports = context => ({
{
title: "nonebot.adapters.cqhttp 模块",
path: "adapters/cqhttp"
},
{
title: "nonebot.adapters.ding 模块",
path: "adapters/ding"
}
]
}

View File

@ -47,3 +47,6 @@
* [nonebot.adapters.cqhttp](adapters/cqhttp.html)
* [nonebot.adapters.ding](adapters/ding.html)

View File

@ -176,7 +176,7 @@ await bot.send_msg(message="hello world")
## _class_ `BaseEvent`
基类:`abc.ABC`
基类:`abc.ABC`, `typing.Generic`
Event 基类。提供上报信息的关键信息,其余信息可从原始上报消息获取。
@ -187,7 +187,7 @@ Event 基类。提供上报信息的关键信息,其余信息可从原始上
* **参数**
* `raw_event: dict`: 原始上报消息
* `raw_event: Union[dict, T]`: 原始上报消息
@ -309,7 +309,7 @@ Event 基类。提供上报信息的关键信息,其余信息可从原始上
* **参数**
* `message: Union[str, dict, list, MessageSegment, Message]`: 消息内容
* `message: Union[str, dict, list, BaseModel, MessageSegment, Message]`: 消息内容
@ -350,7 +350,7 @@ Event 基类。提供上报信息的关键信息,其余信息可从原始上
* **说明**
缩减消息数组,即拼接相邻纯文本消息段
缩减消息数组,即按 MessageSegment 的实现拼接相邻消息段

View File

@ -5,29 +5,6 @@ sidebarDepth: 0
# NoneBot.adapters.cqhttp 模块
## CQHTTP (OneBot) v11 协议适配
协议详情请看: [CQHTTP](https://github.com/howmanybots/onebot/blob/master/README.md) | [OneBot](https://github.com/howmanybots/onebot/blob/master/README.md)
## `log(level, message)`
* **说明**
用于打印 CQHTTP 日志。
* **参数**
* `level: str`: 日志等级
* `message: str`: 日志信息
## `escape(s, *, escape_comma=True)`
@ -64,9 +41,50 @@ sidebarDepth: 0
## `_b2s(b)`
## _exception_ `CQHTTPAdapterException`
转换布尔值为字符串。
基类:[`nonebot.exception.AdapterException`](../exception.md#nonebot.exception.AdapterException)
## _exception_ `ActionFailed`
基类:[`nonebot.exception.ActionFailed`](../exception.md#nonebot.exception.ActionFailed), `nonebot.adapters.cqhttp.exception.CQHTTPAdapterException`
* **说明**
API 请求返回错误信息。
* **参数**
* `retcode: Optional[int]`: 错误码
## _exception_ `NetworkError`
基类:[`nonebot.exception.NetworkError`](../exception.md#nonebot.exception.NetworkError), `nonebot.adapters.cqhttp.exception.CQHTTPAdapterException`
* **说明**
网络错误。
* **参数**
* `retcode: Optional[int]`: 错误码
## _exception_ `ApiNotAvailable`
基类:[`nonebot.exception.ApiNotAvailable`](../exception.md#nonebot.exception.ApiNotAvailable), `nonebot.adapters.cqhttp.exception.CQHTTPAdapterException`
## _async_ `_check_reply(bot, event)`

380
docs/api/adapters/ding.md Normal file
View File

@ -0,0 +1,380 @@
---
contentSidebar: true
sidebarDepth: 0
---
# NoneBot.adapters.ding 模块
## _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

@ -11,11 +11,22 @@ sidebarDepth: 0
这些异常并非所有需要用户处理,在 NoneBot 内部运行时被捕获,并进行对应操作。
## _exception_ `IgnoredException`
## _exception_ `NoneBotException`
基类:`Exception`
* **说明**
所有 NoneBot 发生的异常基类。
## _exception_ `IgnoredException`
基类:`nonebot.exception.NoneBotException`
* **说明**
指示 NoneBot 应该忽略该事件。可由 PreProcessor 抛出。
@ -31,7 +42,7 @@ sidebarDepth: 0
## _exception_ `PausedException`
基类:`Exception`
基类:`nonebot.exception.NoneBotException`
* **说明**
@ -49,7 +60,7 @@ sidebarDepth: 0
## _exception_ `RejectedException`
基类:`Exception`
基类:`nonebot.exception.NoneBotException`
* **说明**
@ -67,7 +78,7 @@ sidebarDepth: 0
## _exception_ `FinishedException`
基类:`Exception`
基类:`nonebot.exception.NoneBotException`
* **说明**
@ -85,7 +96,7 @@ sidebarDepth: 0
## _exception_ `StopPropagation`
基类:`Exception`
基类:`nonebot.exception.NoneBotException`
* **说明**
@ -102,7 +113,7 @@ sidebarDepth: 0
## _exception_ `RequestDenied`
基类:`Exception`
基类:`nonebot.exception.NoneBotException`
* **说明**
@ -121,9 +132,27 @@ sidebarDepth: 0
## _exception_ `AdapterException`
基类:`nonebot.exception.NoneBotException`
* **说明**
代表 `Adapter` 抛出的异常,所有的 `Adapter` 都要在内部继承自这个 `Exception`
* **参数**
* `adapter_name: str`: 标识 adapter
## _exception_ `ApiNotAvailable`
基类:`Exception`
基类:`nonebot.exception.AdapterException`
* **说明**
@ -134,7 +163,7 @@ sidebarDepth: 0
## _exception_ `NetworkError`
基类:`Exception`
基类:`nonebot.exception.AdapterException`
* **说明**
@ -145,16 +174,9 @@ sidebarDepth: 0
## _exception_ `ActionFailed`
基类:`Exception`
基类:`nonebot.exception.AdapterException`
* **说明**
API 请求成功返回数据,但 API 操作失败。
* **参数**
* `retcode: Optional[int]`: 错误代码

View File

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

View File

@ -17,3 +17,4 @@ NoneBot Api Reference
- `nonebot.drivers.fastapi <drivers/fastapi.html>`_
- `nonebot.adapters <adapters/>`_
- `nonebot.adapters.cqhttp <adapters/cqhttp.html>`_
- `nonebot.adapters.ding <adapters/ding.html>`_

View File

@ -6,7 +6,28 @@ sidebarDepth: 0
NoneBot.adapters.cqhttp 模块
============================
.. automodule:: nonebot.adapters.cqhttp
.. automodule:: nonebot.adapters.cqhttp.utils
:members:
:show-inheritance:
.. automodule:: nonebot.adapters.cqhttp.exception
:members:
:show-inheritance:
.. automodule:: nonebot.adapters.cqhttp.bot
:members:
:private-members:
:show-inheritance:
.. automodule:: nonebot.adapters.cqhttp.event
:members:
:private-members:
:show-inheritance:
.. automodule:: nonebot.adapters.cqhttp.message
:members:
:private-members:
:show-inheritance:

View File

@ -0,0 +1,29 @@
---
contentSidebar: true
sidebarDepth: 0
---
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

@ -9,9 +9,11 @@ import abc
from functools import reduce, partial
from dataclasses import dataclass, field
from pydantic import BaseModel
from nonebot.config import Config
from nonebot.typing import Driver, Message, WebSocket
from nonebot.typing import Any, Dict, Union, Optional, NoReturn, Callable, Iterable, Awaitable
from nonebot.typing import Any, Dict, Union, Optional, NoReturn, Callable, Iterable, Awaitable, TypeVar, Generic
class BaseBot(abc.ABC):
@ -135,16 +137,19 @@ class BaseBot(abc.ABC):
raise NotImplementedError
class BaseEvent(abc.ABC):
T = TypeVar("T", bound=BaseModel)
class BaseEvent(abc.ABC, Generic[T]):
"""
Event 基类提供上报信息的关键信息其余信息可从原始上报消息获取
"""
def __init__(self, raw_event: dict):
def __init__(self, raw_event: Union[dict, T]):
"""
:参数:
* ``raw_event: dict``: 原始上报消息
* ``raw_event: Union[dict, T]``: 原始上报消息
"""
self._raw_event = raw_event
@ -152,7 +157,7 @@ class BaseEvent(abc.ABC):
return f"<Event {self.self_id}: {self.name} {self.time}>"
@property
def raw_event(self) -> dict:
def raw_event(self) -> Union[dict, T]:
"""原始上报消息"""
return self._raw_event
@ -347,17 +352,17 @@ class BaseMessage(list, abc.ABC):
"""消息数组"""
def __init__(self,
message: Union[str, dict, list, BaseMessageSegment,
message: Union[str, dict, list, BaseModel, BaseMessageSegment,
"BaseMessage"] = None,
*args,
**kwargs):
"""
:参数:
* ``message: Union[str, dict, list, MessageSegment, Message]``: 消息内容
* ``message: Union[str, dict, list, BaseModel, MessageSegment, Message]``: 消息内容
"""
super().__init__(*args, **kwargs)
if isinstance(message, (str, dict, list)):
if isinstance(message, (str, dict, list, BaseModel)):
self.extend(self._construct(message))
elif isinstance(message, BaseMessage):
self.extend(message)

View File

@ -10,6 +10,8 @@ CQHTTP (OneBot) v11 协议适配
https://github.com/howmanybots/onebot/blob/master/README.md
"""
from .message import Message, MessageSegment
from .bot import Bot
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

@ -8,15 +8,26 @@ import httpx
from nonebot.log import logger
from nonebot.config import Config
from nonebot.message import handle_event
from nonebot.typing import overrides, Driver, WebSocket, NoReturn
from nonebot.typing import Any, Dict, Union, Optional
from nonebot.adapters import BaseBot
from nonebot.exception import NetworkError, ActionFailed, RequestDenied, ApiNotAvailable
from nonebot.message import handle_event
from nonebot.exception import RequestDenied
from nonebot.typing import Any, Dict, Union, Optional
from nonebot.typing import overrides, Driver, WebSocket, NoReturn
from .message import Message, MessageSegment
from .utils import log, get_auth_bearer
from .event import Event
from .message import Message, MessageSegment
from .exception import NetworkError, ApiNotAvailable, ActionFailed
from .utils import log
def get_auth_bearer(
access_token: Optional[str] = None) -> Union[Optional[str], NoReturn]:
if not access_token:
return None
scheme, _, param = access_token.partition(" ")
if scheme.lower() not in ["bearer", "token"]:
raise RequestDenied(401, "Not authenticated")
return param
async def _check_reply(bot: "Bot", event: "Event"):
@ -236,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"]:
@ -261,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)
@ -368,8 +379,8 @@ class Bot(BaseBot):
@overrides(BaseBot)
async def send(self,
event: "Event",
message: Union[str, "Message", "MessageSegment"],
event: Event,
message: Union[str, Message, MessageSegment],
at_sender: bool = False,
**kwargs) -> Union[Any, NoReturn]:
"""

View File

@ -1,12 +1,11 @@
import asyncio
from nonebot.config import Config
from nonebot.adapters import BaseBot, BaseEvent, BaseMessage, BaseMessageSegment
from nonebot.typing import Any, Dict, List, Union, Driver, Optional, NoReturn, WebSocket, Iterable
from nonebot.adapters import BaseBot
from nonebot.typing import Any, Dict, List, Union, Driver, Optional, NoReturn, WebSocket
def log(level: str, message: str):
...
from .event import Event
from .message import Message, MessageSegment
def get_auth_bearer(
@ -14,27 +13,15 @@ def get_auth_bearer(
...
def escape(s: str, *, escape_comma: bool = ...) -> str:
async def _check_reply(bot: "Bot", event: Event):
...
def unescape(s: str) -> str:
def _check_at_me(bot: "Bot", event: Event):
...
def _b2s(b: Optional[bool]) -> Optional[str]:
...
async def _check_reply(bot: "Bot", event: "Event"):
...
def _check_at_me(bot: "Bot", event: "Event"):
...
def _check_nickname(bot: "Bot", event: "Event"):
def _check_nickname(bot: "Bot", event: Event):
...
@ -86,8 +73,8 @@ class Bot(BaseBot):
async def call_api(self, api: str, **data) -> Union[Any, NoReturn]:
...
async def send(self, event: "Event", message: Union[str, "Message",
"MessageSegment"],
async def send(self, event: Event, message: Union[str, Message,
MessageSegment],
**kwargs) -> Union[Any, NoReturn]:
...
@ -759,242 +746,3 @@ class Bot(BaseBot):
* ``self_id``: 机器人 QQ
"""
...
class Event(BaseEvent):
def __init__(self, raw_event: dict):
...
@property
def id(self) -> Optional[int]:
...
@property
def name(self) -> str:
...
@property
def self_id(self) -> str:
...
@property
def time(self) -> int:
...
@property
def type(self) -> str:
...
@type.setter
def type(self, value) -> None:
...
@property
def detail_type(self) -> str:
...
@detail_type.setter
def detail_type(self, value) -> None:
...
@property
def sub_type(self) -> Optional[str]:
...
@sub_type.setter
def sub_type(self, value) -> None:
...
@property
def user_id(self) -> Optional[int]:
...
@user_id.setter
def user_id(self, value) -> None:
...
@property
def group_id(self) -> Optional[int]:
...
@group_id.setter
def group_id(self, value) -> None:
...
@property
def to_me(self) -> Optional[bool]:
...
@to_me.setter
def to_me(self, value) -> None:
...
@property
def message(self) -> Optional["Message"]:
...
@message.setter
def message(self, value) -> None:
...
@property
def reply(self) -> Optional[dict]:
...
@reply.setter
def reply(self, value) -> None:
...
@property
def raw_message(self) -> Optional[str]:
...
@raw_message.setter
def raw_message(self, value) -> None:
...
@property
def plain_text(self) -> Optional[str]:
...
@property
def sender(self) -> Optional[dict]:
...
@sender.setter
def sender(self, value) -> None:
...
class MessageSegment(BaseMessageSegment):
def __init__(self, type: str, data: Dict[str, Any]) -> None:
...
def __str__(self):
...
def __add__(self, other) -> "Message":
...
@staticmethod
def anonymous(ignore_failure: Optional[bool] = ...) -> "MessageSegment":
...
@staticmethod
def at(user_id: Union[int, str]) -> "MessageSegment":
...
@staticmethod
def contact_group(group_id: int) -> "MessageSegment":
...
@staticmethod
def contact_user(user_id: int) -> "MessageSegment":
...
@staticmethod
def dice() -> "MessageSegment":
...
@staticmethod
def face(id_: int) -> "MessageSegment":
...
@staticmethod
def forward(id_: str) -> "MessageSegment":
...
@staticmethod
def image(file: str,
type_: Optional[str] = ...,
cache: bool = ...,
proxy: bool = ...,
timeout: Optional[int] = ...) -> "MessageSegment":
...
@staticmethod
def json(data: str) -> "MessageSegment":
...
@staticmethod
def location(latitude: float,
longitude: float,
title: Optional[str] = ...,
content: Optional[str] = ...) -> "MessageSegment":
...
@staticmethod
def music(type_: str, id_: int) -> "MessageSegment":
...
@staticmethod
def music_custom(url: str,
audio: str,
title: str,
content: Optional[str] = ...,
img_url: Optional[str] = ...) -> "MessageSegment":
...
@staticmethod
def node(id_: int) -> "MessageSegment":
...
@staticmethod
def node_custom(user_id: int, nickname: str,
content: Union[str, "Message"]) -> "MessageSegment":
...
@staticmethod
def poke(type_: str, id_: str) -> "MessageSegment":
...
@staticmethod
def record(file: str,
magic: Optional[bool] = ...,
cache: Optional[bool] = ...,
proxy: Optional[bool] = ...,
timeout: Optional[int] = ...) -> "MessageSegment":
...
@staticmethod
def reply(id_: int) -> "MessageSegment":
...
@staticmethod
def rps() -> "MessageSegment":
...
@staticmethod
def shake() -> "MessageSegment":
...
@staticmethod
def share(url: str = ...,
title: str = ...,
content: Optional[str] = ...,
img_url: Optional[str] = ...) -> "MessageSegment":
...
@staticmethod
def text(text: str) -> "MessageSegment":
...
@staticmethod
def video(file: str,
cache: Optional[bool] = ...,
proxy: Optional[bool] = ...,
timeout: Optional[int] = ...) -> "MessageSegment":
...
@staticmethod
def xml(data: str) -> "MessageSegment":
...
class Message(BaseMessage):
@staticmethod
def _construct(msg: Union[str, dict, list]) -> Iterable[MessageSegment]:
...

View File

@ -1,6 +1,5 @@
from nonebot.typing import overrides
from nonebot.typing import Optional
from nonebot.adapters import BaseEvent
from nonebot.typing import Optional, overrides
from .message import Message

View File

@ -0,0 +1,59 @@
from nonebot.typing import Optional
from nonebot.exception import (AdapterException, ActionFailed as
BaseActionFailed, NetworkError as
BaseNetworkError, ApiNotAvailable as
BaseApiNotAvailable)
class CQHTTPAdapterException(AdapterException):
def __init__(self):
super().__init__("cqhttp")
class ActionFailed(BaseActionFailed, CQHTTPAdapterException):
"""
:说明:
API 请求返回错误信息
:参数:
* ``retcode: Optional[int]``: 错误码
"""
def __init__(self, retcode: Optional[int] = None):
super().__init__()
self.retcode = retcode
def __repr__(self):
return f"<ActionFailed retcode={self.retcode}>"
def __str__(self):
return self.__repr__()
class NetworkError(BaseNetworkError, CQHTTPAdapterException):
"""
:说明:
网络错误
:参数:
* ``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 ApiNotAvailable(BaseApiNotAvailable, CQHTTPAdapterException):
pass

View File

@ -1,8 +1,8 @@
import re
from nonebot.typing import overrides
from nonebot.typing import Any, Dict, Union, Tuple, Iterable, Optional
from nonebot.typing import Any, Dict, Union, Tuple, Iterable, Optional, overrides
from nonebot.adapters import BaseMessage, BaseMessageSegment
from .utils import log, escape, unescape, _b2s

View File

@ -1,21 +1,9 @@
from nonebot.typing import NoReturn
from nonebot.typing import Union, Optional
from nonebot.exception import RequestDenied
from nonebot.typing import Optional
from nonebot.utils import logger_wrapper
log = logger_wrapper("CQHTTP")
def get_auth_bearer(
access_token: Optional[str] = None) -> Union[Optional[str], NoReturn]:
if not access_token:
return None
scheme, _, param = access_token.partition(" ")
if scheme.lower() not in ["bearer", "token"]:
raise RequestDenied(401, "Not authenticated")
return param
def escape(s: str, *, escape_comma: bool = True) -> str:
"""
:说明:

View File

@ -0,0 +1,17 @@
"""
钉钉群机器人 协议适配
============================
协议详情请看: `钉钉文档`_
.. _钉钉文档:
https://ding-doc.dingtalk.com/doc#/serverapi2/krgddi
"""
from .utils import log
from .bot import Bot
from .event import Event
from .message import Message, MessageSegment
from .exception import (DingAdapterException, ApiNotAvailable, NetworkError,
ActionFailed, SessionExpired)

View File

@ -0,0 +1,197 @@
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.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 .message import Message, MessageSegment
from .exception import NetworkError, ApiNotAvailable, ActionFailed, SessionExpired
class Bot(BaseBot):
"""
钉钉 协议 Bot 适配继承属性参考 `BaseBot <./#class-basebot>`_ 。
"""
def __init__(self, driver: Driver, connection_type: str, config: Config,
self_id: str, **kwargs):
super().__init__(driver, connection_type, config, self_id, **kwargs)
@property
def type(self) -> str:
"""
- 返回: ``"ding"``
"""
return "ding"
@classmethod
async def check_permission(cls, driver: Driver, connection_type: str,
headers: dict, body: Optional[dict]) -> str:
"""
:说明:
钉钉协议鉴权参考 `鉴权 <https://ding-doc.dingtalk.com/doc#/serverapi2/elzz1p>`_
"""
timestamp = headers.get("timestamp")
sign = headers.get("sign")
# 检查连接方式
if connection_type not in ["http"]:
raise RequestDenied(405, "Unsupported connection type")
# 检查 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)
if not message:
return
try:
event = Event(message)
await handle_event(self, event)
except Exception as e:
logger.opt(colors=True, exception=e).error(
f"<r><bg #f8bbd0>Failed to handle event. Raw: {message}</bg #f8bbd0></r>"
)
return
async def call_api(self,
api: str,
event: Optional[Event] = None,
**data) -> Union[Any, NoReturn]:
"""
:说明:
调用 钉钉 协议 API
:参数:
* ``api: str``: API 名称
* ``**data: Any``: API 参数
:返回:
- ``Any``: API 调用返回数据
:异常:
- ``NetworkError``: 网络错误
- ``ActionFailed``: API 调用失败
"""
if self.connection_type != "http":
log("ERROR", "Only support http connection.")
return
if "self_id" in data:
self_id = data.pop("self_id")
if self_id:
bot = self.driver.bots[str(self_id)]
return await bot.call_api(api, **data)
log("DEBUG", f"Calling API <y>{api}</y>")
if api == "send_message":
if event:
# 确保 sessionWebhook 没有过期
if int(datetime.now().timestamp()) > int(
event.raw_event.sessionWebhookExpiredTime / 1000):
raise SessionExpired
target = event.raw_event.sessionWebhook
else:
target = None
if not target:
raise ApiNotAvailable
headers = {}
segment: MessageSegment = data["message"][0]
try:
async with httpx.AsyncClient(headers=headers) as client:
response = await client.post(
target,
params={"access_token": self.config.access_token},
json=segment.data,
timeout=self.config.api_timeout)
if 200 <= response.status_code < 300:
result = response.json()
if isinstance(result, dict):
if result.get("errcode") != 0:
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}")
except httpx.InvalidURL:
raise NetworkError("API root url invalid")
except httpx.HTTPError:
raise NetworkError("HTTP request failed")
async def send(self,
event: Event,
message: Union[str, "Message", "MessageSegment"],
at_sender: bool = False,
**kwargs) -> Union[Any, NoReturn]:
"""
:说明:
根据 ``event`` 向触发事件的主体发送消息
:参数:
* ``event: Event``: Event 对象
* ``message: Union[str, Message, MessageSegment]``: 要发送的消息
* ``at_sender: bool``: 是否 @ 事件主体
* ``**kwargs``: 覆盖默认参数
:返回:
- ``Any``: API 调用返回数据
:异常:
- ``ValueError``: 缺少 ``user_id``, ``group_id``
- ``NetworkError``: 网络错误
- ``ActionFailed``: API 调用失败
"""
msg = message if isinstance(message, Message) else Message(message)
at_sender = at_sender and bool(event.user_id)
params = {}
params["event"] = event
params.update(kwargs)
if at_sender and event.detail_type != "private":
params["message"] = f"@{event.user_id} " + msg
else:
params["message"] = msg
return await self.call_api("send_message", **params)

View File

@ -0,0 +1,196 @@
from nonebot.adapters import BaseEvent
from nonebot.typing import Union, Optional
from .message import Message
from .model import MessageModel, ConversationType, TextMessage
class Event(BaseEvent):
"""
钉钉 协议 Event 适配继承属性参考 `BaseEvent <./#class-baseevent>`_ 。
"""
def __init__(self, message: MessageModel):
super().__init__(message)
# 其实目前钉钉机器人只能接收到 text 类型的消息
self._message = Message(getattr(message, message.msgtype or "text"))
@property
def raw_event(self) -> MessageModel:
"""原始上报消息"""
return self._raw_event
@property
def id(self) -> Optional[str]:
"""
- 类型: ``Optional[str]``
- 说明: 消息 ID
"""
return self.raw_event.msgId
@property
def name(self) -> str:
"""
- 类型: ``str``
- 说明: 事件名称 `type`.`detail_type` 组合而成
"""
return self.type + "." + self.detail_type
@property
def self_id(self) -> str:
"""
- 类型: ``str``
- 说明: 机器人自身 ID
"""
return str(self.raw_event.chatbotUserId)
@property
def time(self) -> int:
"""
- 类型: ``int``
- 说明: 消息的时间戳单位 s
"""
# 单位 ms -> s
return int(self.raw_event.createAt / 1000)
@property
def type(self) -> str:
"""
- 类型: ``str``
- 说明: 事件类型
"""
return "message"
@type.setter
def type(self, value) -> None:
pass
@property
def detail_type(self) -> str:
"""
- 类型: ``str``
- 说明: 事件详细类型
"""
return self.raw_event.conversationType.name
@detail_type.setter
def detail_type(self, value) -> None:
if value == "private":
self.raw_event.conversationType = ConversationType.private
if value == "group":
self.raw_event.conversationType = ConversationType.group
@property
def sub_type(self) -> None:
"""
- 类型: ``None``
- 说明: 钉钉适配器无事件子类型
"""
return None
@sub_type.setter
def sub_type(self, value) -> None:
pass
@property
def user_id(self) -> Optional[str]:
"""
- 类型: ``Optional[str]``
- 说明: 发送者 ID
"""
return self.raw_event.senderId
@user_id.setter
def user_id(self, value) -> None:
self.raw_event.senderId = value
@property
def group_id(self) -> Optional[str]:
"""
- 类型: ``Optional[str]``
- 说明: 事件主体群 ID
"""
return self.raw_event.conversationId
@group_id.setter
def group_id(self, value) -> None:
self.raw_event.conversationId = value
@property
def to_me(self) -> Optional[bool]:
"""
- 类型: ``Optional[bool]``
- 说明: 消息是否与机器人相关
"""
return self.detail_type == "private" or self.raw_event.isInAtList
@property
def message(self) -> Optional["Message"]:
"""
- 类型: ``Optional[Message]``
- 说明: 消息内容
"""
return self._message
@message.setter
def message(self, value) -> None:
self._message = value
@property
def reply(self) -> None:
"""
- 类型: ``None``
- 说明: 回复消息详情
"""
raise ValueError("暂不支持 reply")
@property
def raw_message(self) -> Optional[Union[TextMessage]]:
"""
- 类型: ``Optional[str]``
- 说明: 原始消息
"""
return getattr(self.raw_event, self.raw_event.msgtype)
@raw_message.setter
def raw_message(self, value) -> None:
setattr(self.raw_event, self.raw_event.msgtype, value)
@property
def plain_text(self) -> Optional[str]:
"""
- 类型: ``Optional[str]``
- 说明: 纯文本消息内容
"""
return self.message and self.message.extract_plain_text().strip()
@property
def sender(self) -> Optional[dict]:
"""
- 类型: ``Optional[dict]``
- 说明: 消息发送者信息
"""
result = {
# 加密的发送者ID。
"senderId": self.raw_event.senderId,
# 发送者昵称。
"senderNick": self.raw_event.senderNick,
# 企业内部群有的发送者当前群的企业 corpId。
"senderCorpId": self.raw_event.senderCorpId,
# 企业内部群有的发送者在企业内的 userId。
"senderStaffId": self.raw_event.senderStaffId,
"role": "admin" if self.raw_event.isAdmin else "member"
}
return result
@sender.setter
def sender(self, value) -> None:
def set_wrapper(name):
if value.get(name):
setattr(self.raw_event, name, value.get(name))
set_wrapper("senderId")
set_wrapper("senderNick")
set_wrapper("senderCorpId")
set_wrapper("senderStaffId")

View File

@ -0,0 +1,76 @@
from nonebot.typing import Optional
from nonebot.exception import (AdapterException, ActionFailed as
BaseActionFailed, ApiNotAvailable as
BaseApiNotAvailable, NetworkError as
BaseNetworkError)
class DingAdapterException(AdapterException):
"""
:说明:
钉钉 Adapter 错误基类
"""
def __init__(self) -> None:
super().__init__("ding")
class ActionFailed(BaseActionFailed, DingAdapterException):
"""
:说明:
API 请求返回错误信息
:参数:
* ``errcode: Optional[int]``: 错误码
* ``errmsg: Optional[str]``: 错误信息
"""
def __init__(self,
errcode: Optional[int] = None,
errmsg: Optional[str] = None):
super().__init__()
self.errcode = errcode
self.errmsg = errmsg
def __repr__(self):
return f"<ApiError errcode={self.errcode} errmsg={self.errmsg}>"
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:
return f"<Session Webhook is Expired>"

View File

@ -0,0 +1,133 @@
from nonebot.typing import Any, Dict, Union, Iterable
from nonebot.adapters import BaseMessage, BaseMessageSegment
from .utils import log
from .model import TextMessage
class MessageSegment(BaseMessageSegment):
"""
钉钉 协议 MessageSegment 适配具体方法参考协议消息段类型或源码
"""
def __init__(self, type_: str, msg: Dict[str, Any]) -> None:
data = {
"msgtype": type_,
}
if msg:
data.update(msg)
log("DEBUG", f"data {data}")
super().__init__(type=type_, data=data)
@classmethod
def from_segment(cls, segment: "MessageSegment"):
return MessageSegment(segment.type, segment.data)
def __str__(self):
log("DEBUG", f"__str__: self.type {self.type} data {self.data}")
if self.type == "text":
return str(self.data["text"]["content"].strip())
return ""
def __add__(self, other) -> "Message":
if isinstance(other, str):
if self.type == 'text':
self.data['text']['content'] += other
return MessageSegment.from_segment(self)
return Message(self) + other
def atMobile(self, mobileNumber):
self.data.setdefault("at", {})
self.data["at"].setdefault("atMobiles", [])
self.data["at"]["atMobiles"].append(mobileNumber)
def atAll(self, value):
self.data.setdefault("at", {})
self.data["at"]["isAtAll"] = value
@staticmethod
def text(text_: str) -> "MessageSegment":
return MessageSegment("text", {"text": {"content": text_.strip()}})
@staticmethod
def markdown(title: str, text: str) -> "MessageSegment":
return MessageSegment("markdown", {
"markdown": {
"title": title,
"text": text,
},
})
@staticmethod
def actionCardSingleBtn(title: str, text: str, btnTitle: str,
btnUrl) -> "MessageSegment":
return MessageSegment(
"actionCard", {
"actionCard": {
"title": title,
"text": text,
"singleTitle": btnTitle,
"singleURL": btnUrl
}
})
@staticmethod
def actionCardSingleMultiBtns(
title: str,
text: str,
btns: list = [],
hideAvatar: bool = False,
btnOrientation: str = '1',
) -> "MessageSegment":
"""
:参数:
* ``btnOrientation``: 0按钮竖直排列 1按钮横向排列
* ``btns``: [{ "title": title, "actionURL": actionURL }, ...]
"""
return MessageSegment(
"actionCard", {
"actionCard": {
"title": title,
"text": text,
"hideAvatar": "1" if hideAvatar else "0",
"btnOrientation": btnOrientation,
"btns": btns
}
})
@staticmethod
def feedCard(links: list = [],) -> "MessageSegment":
"""
:参数:
* ``links``: [{ "title": xxx, "messageURL": xxx, "picURL": xxx }, ...]
"""
return MessageSegment("feedCard", {"feedCard": {"links": links}})
@staticmethod
def empty() -> "MessageSegment":
"""不想回复消息到群里"""
return MessageSegment("empty")
class Message(BaseMessage):
"""
钉钉 协议 Message 适配
"""
@staticmethod
def _construct(
msg: Union[str, dict, list,
TextMessage]) -> Iterable[MessageSegment]:
if isinstance(msg, dict):
yield MessageSegment(msg["type"], msg.get("data") or {})
return
elif isinstance(msg, list):
for seg in msg:
yield MessageSegment(seg["type"], seg.get("data") or {})
return
elif isinstance(msg, TextMessage):
yield MessageSegment("text", {"text": msg.dict()})
elif isinstance(msg, str):
yield MessageSegment.text(msg)

View File

@ -0,0 +1,47 @@
from typing import List, Optional
from enum import Enum
from pydantic import BaseModel
class Headers(BaseModel):
sign: str
token: str
# ms
timestamp: int
class TextMessage(BaseModel):
content: str
class AtUsersItem(BaseModel):
dingtalkId: str
staffId: Optional[str]
class ConversationType(str, Enum):
private = '1'
group = '2'
class MessageModel(BaseModel):
msgtype: str = None
text: Optional[TextMessage] = None
msgId: str
# ms
createAt: int = None
conversationType: ConversationType = None
conversationId: str = None
conversationTitle: str = None
senderId: str = None
senderNick: str = None
senderCorpId: str = None
senderStaffId: str = None
chatbotUserId: str = None
chatbotCorpId: str = None
atUsers: List[AtUsersItem] = None
sessionWebhook: str = None
# ms
sessionWebhookExpiredTime: int = None
isAdmin: bool = None
isInAtList: bool = None

View File

@ -0,0 +1,3 @@
from nonebot.utils import logger_wrapper
log = logger_wrapper("DING")

View File

@ -6,10 +6,17 @@
这些异常并非所有需要用户处理 NoneBot 内部运行时被捕获并进行对应操作
"""
from nonebot.typing import List, Type, Optional
class NoneBotException(Exception):
"""
:说明:
所有 NoneBot 发生的异常基类
"""
pass
class IgnoredException(Exception):
class IgnoredException(NoneBotException):
"""
:说明:
@ -30,7 +37,7 @@ class IgnoredException(Exception):
return self.__repr__()
class PausedException(Exception):
class PausedException(NoneBotException):
"""
:说明:
@ -44,7 +51,7 @@ class PausedException(Exception):
pass
class RejectedException(Exception):
class RejectedException(NoneBotException):
"""
:说明:
@ -58,7 +65,7 @@ class RejectedException(Exception):
pass
class FinishedException(Exception):
class FinishedException(NoneBotException):
"""
:说明:
@ -72,7 +79,7 @@ class FinishedException(Exception):
pass
class StopPropagation(Exception):
class StopPropagation(NoneBotException):
"""
:说明:
@ -85,7 +92,7 @@ class StopPropagation(Exception):
pass
class RequestDenied(Exception):
class RequestDenied(NoneBotException):
"""
:说明:
@ -108,7 +115,22 @@ class RequestDenied(Exception):
return self.__repr__()
class ApiNotAvailable(Exception):
class AdapterException(NoneBotException):
"""
:说明:
代表 ``Adapter`` 抛出的异常所有的 ``Adapter`` 都要在内部继承自这个 ``Exception``
:参数:
* ``adapter_name: str``: 标识 adapter
"""
def __init__(self, adapter_name: str) -> None:
self.adapter_name = adapter_name
class ApiNotAvailable(AdapterException):
"""
:说明:
@ -117,7 +139,7 @@ class ApiNotAvailable(Exception):
pass
class NetworkError(Exception):
class NetworkError(AdapterException):
"""
:说明:
@ -126,22 +148,10 @@ class NetworkError(Exception):
pass
class ActionFailed(Exception):
class ActionFailed(AdapterException):
"""
:说明:
API 请求成功返回数据 API 操作失败
:参数:
* ``retcode: Optional[int]``: 错误代码
"""
def __init__(self, retcode: Optional[int]):
self.retcode = retcode
def __repr__(self):
return f"<ActionFailed, retcode={self.retcode}>"
def __str__(self):
return self.__repr__()
pass

View File

@ -21,7 +21,7 @@
from types import ModuleType
from typing import NoReturn, TYPE_CHECKING
from typing import Any, Set, List, Dict, Type, Tuple, Mapping
from typing import Union, TypeVar, Optional, Iterable, Callable, Awaitable
from typing import Union, TypeVar, Optional, Iterable, Callable, Awaitable, Generic
# import some modules needed when checking types
if TYPE_CHECKING:

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 的日志
:参数:
:log 参数:
* ``level: Literal['WARNING', 'DEBUG', 'INFO']``: 日志等级
* ``message: str``: 日志信息
* ``exception: Optional[Exception]``: 异常信息
"""
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

View File

@ -5,6 +5,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.log import logger, default_format
# test custom log
@ -18,6 +19,7 @@ nonebot.init(custom_config2="config on init")
app = nonebot.get_asgi()
driver = nonebot.get_driver()
driver.register_adapter("cqhttp", Bot)
driver.register_adapter("ding", DingBot)
# load builtin plugin
nonebot.load_builtin_plugins()

View File

@ -2,6 +2,7 @@ from nonebot.rule import to_me
from nonebot.typing import Event
from nonebot.plugin import on_startswith
from nonebot.adapters.cqhttp import Bot
from nonebot.adapters.ding import Bot as DingBot, Event as DingEvent
from nonebot.permission import GROUP_ADMIN
test_command = on_startswith("hello", to_me(), permission=GROUP_ADMIN)
@ -9,4 +10,9 @@ test_command = on_startswith("hello", to_me(), permission=GROUP_ADMIN)
@test_command.handle()
async def test_handler(bot: Bot, event: Event, state: dict):
await test_command.finish("hello")
await test_command.finish("cqhttp hello")
@test_command.handle()
async def test_handler(bot: DingBot, event: DingEvent, state: dict):
await test_command.finish("ding hello")