该提交实现了完整的钉钉聊天渠道插件,包含: 1. 基础配置、账号管理与凭证校验 2. WebSocket长连接网关与消息去重 3. 消息接收/解析/分发与安全校验 4. 媒体文件上传下载与缓存 5. 互动卡片流式更新与回调处理 6. 群管理、命令支持与诊断工具 7. 完整的插件元数据与依赖声明
208 lines
7.5 KiB
Python
208 lines
7.5 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
from typing import TYPE_CHECKING
|
|
|
|
import httpx
|
|
|
|
from yuxi.channel.extensions.dingtalk.types import DingTalkAccount
|
|
|
|
if TYPE_CHECKING:
|
|
from yuxi.channel.extensions.dingtalk.monitor import DingTalkMonitor
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
|
|
TOKEN_REFRESH_MARGIN = 300
|
|
RECONNECT_WAIT_MS = 100
|
|
RECONNECT_MAX_WAITS = 100
|
|
OPEN_CONNECTION_URL = "https://api.dingtalk.com/v1.0/gateway/connections/open"
|
|
|
|
|
|
class DingTalkTokenManager:
|
|
def __init__(self, app_key: str, app_secret: str):
|
|
self._app_key = app_key
|
|
self._app_secret = app_secret
|
|
self._token: str | None = None
|
|
self._expires_at: float = 0.0
|
|
self._lock = asyncio.Lock()
|
|
self._http: httpx.AsyncClient | None = None
|
|
|
|
def set_http(self, http: httpx.AsyncClient) -> None:
|
|
self._http = http
|
|
|
|
@property
|
|
def token(self) -> str | None:
|
|
if self._token and time.time() < self._expires_at:
|
|
return self._token
|
|
return None
|
|
|
|
async def get_access_token(self) -> str | None:
|
|
if self._token and time.time() < self._expires_at:
|
|
return self._token
|
|
|
|
async with self._lock:
|
|
if self._token and time.time() < self._expires_at:
|
|
return self._token
|
|
|
|
try:
|
|
resp = await self._http.post(
|
|
TOKEN_URL,
|
|
json={
|
|
"appKey": self._app_key,
|
|
"appSecret": self._app_secret,
|
|
},
|
|
timeout=10.0,
|
|
)
|
|
data = resp.json()
|
|
self._token = data["accessToken"]
|
|
expire_in = data.get("expireIn", 7200)
|
|
self._expires_at = time.time() + expire_in - TOKEN_REFRESH_MARGIN
|
|
logger.info("DingTalk access_token refreshed, expires_in=%ds", expire_in)
|
|
return self._token
|
|
except Exception:
|
|
logger.exception("Failed to get DingTalk access_token")
|
|
return None
|
|
|
|
|
|
class DingTalkGateway:
|
|
def __init__(self, account: DingTalkAccount, token_manager: DingTalkTokenManager):
|
|
self._account = account
|
|
self._token_manager = token_manager
|
|
self._running = False
|
|
self._event_loop: asyncio.AbstractEventLoop | None = None
|
|
self._robot_code_cache: str | None = None
|
|
self._handler: DingTalkMonitor | None = None
|
|
self._stream_client = None
|
|
self._credential = None
|
|
|
|
def set_handler(self, handler: DingTalkMonitor) -> None:
|
|
self._handler = handler
|
|
|
|
@property
|
|
def robot_code(self) -> str | None:
|
|
return self._robot_code_cache or self._account.robot_code
|
|
|
|
@property
|
|
def app_key(self) -> str:
|
|
return self._account.app_key
|
|
|
|
@property
|
|
def app_secret(self) -> str:
|
|
return self._account.app_secret
|
|
|
|
@property
|
|
def token_manager(self) -> DingTalkTokenManager:
|
|
return self._token_manager
|
|
|
|
async def start(self, ctx) -> object:
|
|
if not self._account.is_configured():
|
|
logger.warning("DingTalk account not configured, skipping start")
|
|
return {"running": False, "reason": "not-configured"}
|
|
|
|
self._running = True
|
|
self._event_loop = asyncio.get_running_loop()
|
|
|
|
try:
|
|
from dingtalk_stream import ChatbotMessage, Credential, DingTalkStreamClient
|
|
|
|
self._credential = Credential(self._account.app_key, self._account.app_secret)
|
|
|
|
self._stream_client = DingTalkStreamClient(
|
|
credential=self._credential,
|
|
)
|
|
self._stream_client.register_callback_handler(ChatbotMessage.TOPIC, self._handler)
|
|
|
|
extra_topics = getattr(self._account, "subscribe_topics", [])
|
|
for topic in extra_topics:
|
|
try:
|
|
self._stream_client.register_callback_handler(topic, self._handler)
|
|
logger.info("DingTalk extra topic registered: %s", topic)
|
|
except Exception:
|
|
logger.warning("Failed to register topic: %s", topic, exc_info=True)
|
|
|
|
try:
|
|
self._stream_client.register_callback_handler("/v1.0/card/instances/callback", self._handler)
|
|
logger.info("DingTalk card callback registered")
|
|
except Exception:
|
|
logger.debug("Failed to register card callback", exc_info=True)
|
|
|
|
self._stream_client.pre_start()
|
|
except ImportError:
|
|
logger.error("dingtalk-stream-sdk-python not installed")
|
|
return {"running": False, "reason": "sdk-missing"}
|
|
except Exception:
|
|
logger.exception("Failed to initialize DingTalk stream client")
|
|
return {"running": False, "reason": "init-failed"}
|
|
|
|
asyncio.create_task(self._connection_loop(), name="dingtalk-stream-loop")
|
|
logger.info("DingTalk gateway started for account %s", self._account.account_id)
|
|
return {"running": True, "account_id": self._account.account_id}
|
|
|
|
async def stop(self, ctx) -> None:
|
|
self._running = False
|
|
self._stream_client = None
|
|
logger.info("DingTalk gateway stopped")
|
|
|
|
async def _open_connection(self) -> tuple[str, str]:
|
|
async with httpx.AsyncClient(timeout=10.0) as http:
|
|
resp = await http.post(
|
|
OPEN_CONNECTION_URL,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"clientId": self._account.app_key,
|
|
"dingtalk-accept-encoding": "base64",
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return data["endpoint"], data["ticket"]
|
|
|
|
async def _connection_loop(self):
|
|
first_attempt = True
|
|
|
|
while self._running:
|
|
try:
|
|
endpoint, ticket = await self._open_connection()
|
|
except Exception as e:
|
|
if first_attempt:
|
|
logger.error("DingTalk first connection failed: %s", e)
|
|
first_attempt = False
|
|
await self._wait_with_check(RECONNECT_MAX_WAITS)
|
|
continue
|
|
|
|
try:
|
|
import websockets
|
|
|
|
async with websockets.connect(f"{endpoint}?ticket={ticket}") as ws:
|
|
if first_attempt:
|
|
logger.info("DingTalk stream connected")
|
|
first_attempt = False
|
|
|
|
async for raw_message in ws:
|
|
if not self._running:
|
|
break
|
|
try:
|
|
json_message = json.loads(raw_message)
|
|
result = self._stream_client.route_message(json_message)
|
|
if result == "TAG_DISCONNECT":
|
|
logger.warning("DingTalk stream: TAG_DISCONNECT received")
|
|
break
|
|
except Exception:
|
|
logger.exception("DingTalk stream message routing error")
|
|
except Exception:
|
|
logger.exception("DingTalk stream connection error")
|
|
await self._wait_with_check(RECONNECT_MAX_WAITS)
|
|
|
|
async def _wait_with_check(self, max_waits: int):
|
|
for _ in range(max_waits):
|
|
if not self._running:
|
|
break
|
|
await asyncio.sleep(RECONNECT_WAIT_MS / 1000)
|
|
|
|
async def probe(self, account: dict) -> bool:
|
|
return account.get("app_key") and account.get("app_secret")
|