Skip to content

Commit

Permalink
😀Recover DingTalk and Fix the CI
Browse files Browse the repository at this point in the history
  • Loading branch information
HsiangNianian committed Jul 15, 2023
1 parent 5efe258 commit e9dc76d
Show file tree
Hide file tree
Showing 7 changed files with 413 additions and 1 deletion.
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,7 @@ jobs:
- run: poetry publish --build
shell: bash
working-directory: ./packages/iamai-adapter-kook

- run: poetry publish --build
shell: bash
working-directory: ./packages/iamai-adapter-console
165 changes: 165 additions & 0 deletions packages/iamai-adapter-dingtalk/iamai/adapter/dingtalk/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""DingTalk 协议适配器。
本适配器适配了钉钉企业自建机器人协议。
协议详情请参考: [钉钉开放平台](https://developers.dingtalk.com/document/robots/robot-overview) 。
"""
import hmac
import time
import base64
import hashlib
from typing import Any, Dict, Union, Literal

import aiohttp
from aiohttp import web

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

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

__all__ = ["DingTalkAdapter"]


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

name: str = "dingtalk"
Config = Config

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

session: aiohttp.ClientSession = None

async def startup(self):
"""创建 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):
"""运行 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):
"""清理 aiohttp AppRunner。"""
if self.session is not None:
await self.session.close()
if self.site is not None:
await self.site.stop()
if self.runner is not None:
await self.runner.cleanup()

async def handler(self, request: web.Request):
"""处理 aiohttp 服务器的接收。
Args:
request: aiohttp 服务器的 Request 对象。
"""
if "timestamp" not in request.headers or "sign" not in request.headers:
logger.error(f"Illegal http header, incomplete http header")
elif abs(int(request.headers["timestamp"]) - time.time() * 1000) > 3600000:
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:
error_or_exception(
"Request parsing error:",
e,
self.bot.config.bot.log.verbose_exception,
)
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"),
"{}\n{}".format(timestamp, self.config.app_secret).encode("utf-8"),
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, DingTalkMessage],
at: Union[None, Dict, 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}")

if conversation_type == "1":
data = msg
elif conversation_type == "2":
if at is None:
data = {"msgtype": msg.type, **msg.as_dict()}
else:
data = {"msgtype": msg.type, **msg.as_dict(), **at.as_dict()}
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:
raise NetworkError
21 changes: 21 additions & 0 deletions packages/iamai-adapter-dingtalk/iamai/adapter/dingtalk/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""DingTalk 适配器配置。"""
from iamai.config import ConfigModel


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 = ""
83 changes: 83 additions & 0 deletions packages/iamai-adapter-dingtalk/iamai/adapter/dingtalk/event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""DingTalk 适配器事件。"""
import time
from typing import TYPE_CHECKING, Any, Dict, List, Union, Literal, Optional

from pydantic import Field, BaseModel, validator

from iamai.event import Event

from .message import DingTalkMessage
from .exceptions import WebhookExpiredError

if TYPE_CHECKING:
from . import DingTalkAdapter # noqa


class UserInfo(BaseModel):
dingtalkId: str
staffId: Optional[str]


class Text(BaseModel):
content: str


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

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

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

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

@validator("message", always=True)
def set_ts_now(cls, v, values, **kwargs): # noqa
return DingTalkMessage.text(values["text"].content)

async def reply(
self,
msg: Union[str, Dict, DingTalkMessage],
at: Union[None, Dict, DingTalkMessage] = None,
) -> Dict[str, Any]:
"""回复消息。
Args:
msg: 回复消息的内容,可以是 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=msg,
at=at,
)
else:
raise WebhookExpiredError
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""DingTalk 适配器异常。"""
from iamai.exceptions import AdapterException


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


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


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


class ApiTimeout(DingTalkException):
"""API 请求响应超时。"""
Loading

0 comments on commit e9dc76d

Please sign in to comment.