Skip to content

Commit

Permalink
chore(docs): update api docs with sphinx-apidoc
Browse files Browse the repository at this point in the history
  • Loading branch information
HsiangNianian authored and github-actions[bot] committed Aug 11, 2024
1 parent 8f8fe77 commit e8d49a8
Show file tree
Hide file tree
Showing 11 changed files with 502 additions and 0 deletions.
7 changes: 7 additions & 0 deletions docs/source/pages/api/iamai.adapter.dingtalk.config.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
iamai.adapter.dingtalk.config module
====================================

.. automodule:: iamai.adapter.dingtalk.config
:members:
:undoc-members:
:show-inheritance:
7 changes: 7 additions & 0 deletions docs/source/pages/api/iamai.adapter.dingtalk.event.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
iamai.adapter.dingtalk.event module
===================================

.. automodule:: iamai.adapter.dingtalk.event
:members:
:undoc-members:
:show-inheritance:
7 changes: 7 additions & 0 deletions docs/source/pages/api/iamai.adapter.dingtalk.exceptions.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
iamai.adapter.dingtalk.exceptions module
========================================

.. automodule:: iamai.adapter.dingtalk.exceptions
:members:
:undoc-members:
:show-inheritance:
7 changes: 7 additions & 0 deletions docs/source/pages/api/iamai.adapter.dingtalk.message.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
iamai.adapter.dingtalk.message module
=====================================

.. automodule:: iamai.adapter.dingtalk.message
:members:
:undoc-members:
:show-inheritance:
21 changes: 21 additions & 0 deletions docs/source/pages/api/iamai.adapter.dingtalk.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
iamai.adapter.dingtalk package
==============================

Submodules
----------

.. toctree::
:maxdepth: 4

iamai.adapter.dingtalk.config
iamai.adapter.dingtalk.event
iamai.adapter.dingtalk.exceptions
iamai.adapter.dingtalk.message

Module contents
---------------

.. automodule:: iamai.adapter.dingtalk
:members:
:undoc-members:
:show-inheritance:
1 change: 1 addition & 0 deletions docs/source/pages/api/iamai.adapter.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Subpackages
iamai.adapter.bililive
iamai.adapter.console
iamai.adapter.cqhttp
iamai.adapter.dingtalk
iamai.adapter.gensokyo
iamai.adapter.kook
iamai.adapter.red
Expand Down
162 changes: 162 additions & 0 deletions iamai/adapter/dingtalk/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""DingTalk 协议适配器。
本适配器适配了钉钉企业自建机器人协议。
协议详情请参考:[钉钉开放平台](https://open.dingtalk.com/document/robots/robot-overview)。
"""

import base64
import hashlib
import hmac
import time
from typing import Any, Dict, Literal, Union

import aiohttp
from aiohttp import web

from iamai.adapter import Adapter
from iamai.log import logger

from .config import Config
from .event import DingTalkEvent
from .exceptions import NetworkError
from .message import DingTalkMessage

__all__ = ["DingTalkAdapter"]


class DingTalkAdapter(Adapter[DingTalkEvent, Config]):
"""钉钉协议适配器。"""

name: str = "dingtalk"
Config = Config

app: web.Application
runner: web.AppRunner
site: web.TCPSite

session: aiohttp.ClientSession

async def startup(self) -> None:
"""创建 aiohttp Application。"""
self.app = web.Application()
self.app.add_routes([web.post(self.config.url, self.handler)])

self.session = aiohttp.ClientSession()

async def run(self) -> None:
"""运行 aiohttp 服务器。"""
self.runner = web.AppRunner(self.app)
await self.runner.setup()
self.site = web.TCPSite(self.runner, self.config.host, self.config.port)
await self.site.start()

async def shutdown(self) -> None:
"""清理 aiohttp AppRunner。"""
await self.session.close()
await self.site.stop()
await self.runner.cleanup()

async def handler(self, request: web.Request) -> web.Response:
"""处理 aiohttp 服务器的接收。
Args:
request: aiohttp 服务器的 `Request` 对象。
"""
if "timestamp" not in request.headers or "sign" not in request.headers:
logger.error("Illegal http header, incomplete http header")
elif (
abs(int(request.headers["timestamp"]) - time.time() * 1000) > 60 * 60 * 1000
):
logger.error(
f'Illegal http header, timestamp: {request.headers["timestamp"]}'
)
elif request.headers["sign"] != self.get_sign(request.headers["timestamp"]):
logger.error(f'Illegal http header, sign: {request.headers["sign"]}')
else:
try:
dingtalk_event = DingTalkEvent(adapter=self, **(await request.json()))
except Exception as e:
self.bot.error_or_exception("Request parsing error:", e)
return web.Response()
await self.handle_event(dingtalk_event)
return web.Response()

def get_sign(self, timestamp: str) -> str:
"""计算签名。
Args:
timestamp: 时间戳。
Returns:
签名。
"""
hmac_code = hmac.new(
self.config.app_secret.encode("utf-8"),
f"{timestamp}\n{self.config.app_secret}".encode(),
digestmod=hashlib.sha256,
).digest()
return base64.b64encode(hmac_code).decode("utf-8")

async def send(
self,
webhook: str,
conversation_type: Literal["1", "2"],
msg: Union[str, Dict[str, Any], DingTalkMessage],
at: Union[None, Dict[str, Any], DingTalkMessage] = None,
) -> Dict[str, Any]:
"""发送消息。
Args:
webhook: Webhook 网址。
conversation_type: 聊天类型,"1" 表示单聊,"2" 表示群聊。
msg: 消息。
at: At 对象,仅在群聊时生效,默认为空。
Returns:
钉钉服务器的响应。
Raises:
TypeError: 传入参数类型错误。
ValueError: 传入参数值错误。
NetworkError: 调用 Webhook 地址时网络错误。
"""
if isinstance(msg, DingTalkMessage):
pass
elif isinstance(msg, dict):
msg = DingTalkMessage.raw(msg)
elif isinstance(msg, str):
msg = DingTalkMessage.text(msg)
else:
raise TypeError(
f"msg must be str, Dict or DingTalkMessage, not {type(msg)!r}"
)

if at is not None:
if isinstance(at, DingTalkMessage):
if at.type == "at":
pass
else:
raise ValueError(f'at.type must be "at", not {at.type}')
elif isinstance(at, dict):
at = DingTalkMessage.raw(at)
else:
raise TypeError(f"at must be Dict or DingTalkMessage, not {type(at)!r}")

data: Union[Dict[str, Any], DingTalkMessage]
if conversation_type == "1":
data = msg
elif conversation_type == "2":
if at is None:
data = {"msgtype": msg.type, **msg.model_dump()}
else:
data = {"msgtype": msg.type, **msg.model_dump(), **at.model_dump()}
else:
raise ValueError(
f'conversation_type must be "1" or "2" not {conversation_type}'
)

try:
async with self.session.post(webhook, json=data) as resp:
return await resp.json()
except aiohttp.ClientError as e:
raise NetworkError from e
24 changes: 24 additions & 0 deletions iamai/adapter/dingtalk/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""DingTalk 适配器配置。"""

from iamai.config import ConfigModel

__all__ = ["Config"]


class Config(ConfigModel):
"""DingTalk 配置类,将在适配器被加载时被混入到机器人主配置中。
Attributes:
host: 本机域名。
port: 监听的端口。
url: 路径。
api_timeout: 进行 API 调用时等待返回响应的超时时间。
app_secret: 机器人的 `appSecret`。
"""

__config_name__ = "dingtalk"
host: str = "127.0.0.1"
port: int = 8080
url: str = "/dingtalk"
api_timeout: int = 1000
app_secret: str = ""
109 changes: 109 additions & 0 deletions iamai/adapter/dingtalk/event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""DingTalk 适配器事件。"""

import time
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union
from typing_extensions import Self

from pydantic import BaseModel, Field

from iamai.event import MessageEvent

from .exceptions import WebhookExpiredError
from .message import DingTalkMessage

if TYPE_CHECKING:
pass

__all__ = ["UserInfo", "Text", "DingTalkEvent"]


class UserInfo(BaseModel):
"""用户信息"""

dingtalkId: str
staffId: Optional[str] = None


class Text(BaseModel):
"""文本消息"""

content: str


class DingTalkEvent(MessageEvent["DingTalkAdapter"]):
"""DingTalk 事件基类"""

type: Optional[str] = Field(alias="msgtype")

msgtype: str
msgId: str
createAt: str
conversationType: Literal["1", "2"]
conversationId: str
conversationTitle: Optional[str] = None
senderId: str
senderNick: str
senderCorpId: Optional[str] = None
sessionWebhook: str
sessionWebhookExpiredTime: int
isAdmin: Optional[bool] = None
chatbotCorpId: Optional[str] = None
isInAtList: Optional[bool] = None
senderStaffId: Optional[str] = None
chatbotUserId: str
atUsers: List[UserInfo]
text: Text

response_msg: Union[None, str, Dict[str, Any], DingTalkMessage] = None
response_at: Union[None, Dict[str, Any], DingTalkMessage] = None

@property
def message(self) -> DingTalkMessage:
"""返回 message 字段。"""
return DingTalkMessage.text(self.text.content)

def get_plain_text(self) -> str:
"""获取消息的纯文本内容。
Returns:
消息的纯文本内容。
"""
return self.message.get_plain_text()

async def reply(
self,
message: Union[str, Dict[str, Any], DingTalkMessage],
at: Union[None, Dict[str, Any], DingTalkMessage] = None,
) -> Dict[str, Any]:
"""回复消息。
Args:
message: 回复消息的内容,可以是 `str`, `Dict` 或 `DingTalkMessage`。
at: 回复消息时 At 的对象,必须时 at 类型的 `DingTalkMessage`,或者符合标准的 `Dict`。
Returns:
调用 Webhook 地址后钉钉服务器的响应。
Raises:
WebhookExpiredError: 当前事件的 Webhook 地址已经过期。
...: 同 `DingTalkAdapter.send()` 方法。
"""
if self.sessionWebhookExpiredTime > time.time() * 1000:
return await self.adapter.send(
webhook=self.sessionWebhook,
conversation_type=self.conversationType,
msg=message,
at=at,
)
raise WebhookExpiredError

async def is_same_sender(self, other: Self) -> bool:
"""判断自身和另一个事件是否是同一个发送者。
Args:
other: 另一个事件。
Returns:
是否是同一个发送者。
"""
return self.senderId == other.senderId
17 changes: 17 additions & 0 deletions iamai/adapter/dingtalk/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""DingTalk 适配器异常。"""

from iamai.exceptions import AdapterException

__all__ = ["DingTalkException", "NetworkError", "WebhookExpiredError"]


class DingTalkException(AdapterException):
"""DingTalk 异常基类。"""


class NetworkError(DingTalkException):
"""网络异常。"""


class WebhookExpiredError(DingTalkException):
"""Webhook 地址已到期。"""
Loading

0 comments on commit e8d49a8

Please sign in to comment.