ForcePilot/backend/package/yuxi/channels/adapters/dingding/adapter.py
Kris 86105d163d feat(dingding): add dingtalk channel adapter implementation
实现钉钉官方的频道适配器,包含完整的消息收发、事件处理、媒体上传下载、流式响应以及webhook支持,覆盖了认证、签名校验、速率限制、健康检查等完整功能流程。
2026-05-12 00:43:14 +08:00

568 lines
22 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import time
from collections.abc import AsyncIterator
from typing import Any
import httpx
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.capabilities import ChannelCapabilities
from yuxi.channels.meta import ChannelMeta
from yuxi.channels.exceptions import ChannelAuthenticationError
from yuxi.channels.models import (
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
DeliveryResult,
HealthStatus,
)
from yuxi.channels.registry import register_builtin_adapter
from yuxi.utils.datetime_utils import utc_now_naive
from .cards import TEXT_CHUNK_LIMIT
from .formatter import format_outbound
from .media import download_media as _download_media
from .media import send_media as _send_media
from .media import upload_media
from .normalizer import normalize_inbound
from .send import _send_via_webhook, send_card, send_markdown, send_text
from .sign import verify_webhook_signature as verify_sign
from .token import DingDingTokenManager
logger = logging.getLogger(__name__)
HAS_DINGTALK_STREAM = False
try:
import dingtalk_stream
HAS_DINGTALK_STREAM = True
except ImportError:
dingtalk_stream = None # type: ignore[assignment]
DINGDING_RATE_LIMIT_PER_MINUTE = 20
STREAM_RECONNECT_DELAY_S = 5
STREAM_MAX_RECONNECT_DELAY_S = 300
@register_builtin_adapter
class DingDingChannelAdapter(BaseChannelAdapter):
channel_id = "dingding"
channel_type = ChannelType.DINGDING
text_chunk_limit = TEXT_CHUNK_LIMIT
supports_markdown = True
supports_streaming = True
streaming_modes = ["off", "block"]
max_media_size_mb = 10
capabilities = ChannelCapabilities(
chat_types=["direct"],
reactions=True,
edit=True,
unsend=True,
reply=True,
media=True,
supports_markdown=True,
supports_streaming=True,
streaming_modes=["off", "block"],
text_chunk_limit=4096,
max_media_size_mb=10,
)
meta = ChannelMeta(id="dingding", label="DingDing")
webhook_path = "/api/channels/dingding/events"
def __init__(self, config: dict[str, Any] | None = None):
super().__init__(config)
self._status = ChannelStatus.DISCONNECTED
self._token_manager: DingDingTokenManager | None = None
self._stream_client: object = None
self._stream_task: asyncio.Task | None = None
self._connected_at: float | None = None
self._app_key: str = ""
self._app_secret: str = ""
self._http_client: httpx.AsyncClient | None = None
self._send_semaphore = asyncio.Semaphore(DINGDING_RATE_LIMIT_PER_MINUTE)
self._rate_reset_task: asyncio.Task | None = None
self._pending_streams: dict[str, str] = {}
self._session_webhooks: dict[str, str] = {}
self._stream_lock = asyncio.Lock()
async def connect(self) -> None:
if self._status == ChannelStatus.CONNECTED:
return
self._status = ChannelStatus.CONNECTING
logger.info(f"[DingDing] Starting channel '{self.config.get('name', self.channel_id)}'")
accounts = self.config.get("accounts", {})
default_account = accounts.get("default", {})
self._app_key = default_account.get("app_key", "")
self._app_secret = default_account.get("app_secret", "")
if not self._app_key or not self._app_secret:
raise ChannelAuthenticationError()
http_client = await self._get_http_client()
self._token_manager = DingDingTokenManager(self._app_key, self._app_secret, http_client=http_client)
try:
await self._token_manager.get_token()
except Exception as e:
raise ChannelAuthenticationError() from e
logger.info("[DingDing] Token acquired successfully")
if self._rate_reset_task is None:
self._rate_reset_task = asyncio.create_task(self._reset_rate_limit_loop())
mode = self.config.get("mode", "stream")
if mode == "stream":
await self._connect_stream_mode()
elif mode == "webhook":
await self._connect_webhook_mode()
self._status = ChannelStatus.CONNECTED
self._connected_at = time.time()
logger.info(f"[DingDing] Channel started, mode: {mode}")
async def _connect_stream_mode(self) -> None:
if not HAS_DINGTALK_STREAM:
logger.warning("[DingDing] dingtalk-stream SDK not installed")
return
credential = dingtalk_stream.Credential(
client_id=self._app_key,
client_secret=self._app_secret,
)
self._stream_client = dingtalk_stream.DingTalkStreamClient(credential)
from .stream_handler import DingDingCallbackHandler, DingDingChatbotHandler
chatbot_handler = DingDingChatbotHandler(self)
self._stream_client.register_callback_handler(dingtalk_stream.ChatbotMessage.TOPIC, chatbot_handler)
callback_handler = DingDingCallbackHandler(self)
self._stream_client.register_callback_handler(dingtalk_stream.CallbackHandler.TOPIC, callback_handler)
self._stream_task = asyncio.create_task(self._run_stream_with_reconnect())
logger.info("[DingDing] Stream mode task started")
async def _run_stream_with_reconnect(self) -> None:
reconnect_delay = STREAM_RECONNECT_DELAY_S
while self._status in (ChannelStatus.CONNECTED, ChannelStatus.CONNECTING):
try:
await self._stream_client.start_forever()
except asyncio.CancelledError:
break
except Exception as e:
logger.warning(f"[DingDing] Stream disconnected: {e}, reconnecting in {reconnect_delay}s")
await asyncio.sleep(reconnect_delay)
reconnect_delay = min(reconnect_delay * 2, STREAM_MAX_RECONNECT_DELAY_S)
else:
reconnect_delay = STREAM_RECONNECT_DELAY_S
async def _connect_webhook_mode(self) -> None:
logger.info("[DingDing] Webhook mode ready")
async def disconnect(self) -> None:
if self._status == ChannelStatus.DISCONNECTED:
return
logger.info(f"[DingDing] Stopping channel '{self.config.get('name', self.channel_id)}'")
if self._rate_reset_task and not self._rate_reset_task.done():
self._rate_reset_task.cancel()
try:
await self._rate_reset_task
except asyncio.CancelledError:
pass
self._rate_reset_task = None
if self._stream_task and not self._stream_task.done():
self._stream_task.cancel()
try:
await self._stream_task
except asyncio.CancelledError:
pass
self._stream_task = None
if self._stream_client and hasattr(self._stream_client, "stop"):
try:
self._stream_client.stop()
except Exception:
pass
self._stream_client = None
if self._token_manager:
self._token_manager.invalidate()
self._token_manager = None
if self._http_client and not self._http_client.is_closed:
await self._http_client.aclose()
self._http_client = None
self._pending_streams.clear()
self._session_webhooks.clear()
self._connected_at = None
self._status = ChannelStatus.DISCONNECTED
def normalize_inbound(self, raw: dict[str, Any]) -> ChannelMessage:
return normalize_inbound(self.channel_id, self.channel_type, raw)
def format_outbound(self, response: ChannelResponse) -> dict[str, Any]:
metadata = response.metadata or {}
chat_type = "group" if response.identity.channel_chat_id.startswith("group_") else "direct"
return format_outbound(
response.content,
chat_type=chat_type,
metadata={**metadata},
)
async def send(self, response: ChannelResponse) -> DeliveryResult:
if not self._token_manager:
return DeliveryResult(success=False, error="Not connected")
payload = self.format_outbound(response)
chat_id = response.identity.channel_chat_id
chat_type = "group" if chat_id.startswith("group_") else "direct"
open_conversation_id = chat_id.replace("group_", "").replace("dm_", "")
accounts = self.config.get("accounts", {})
robot_code = accounts.get("default", {}).get("robot_code", "")
content = response.content
msg_key = payload.get("msgKey", "sampleText")
http_client = await self._get_http_client()
thread_root_id = response.metadata.get("root_id") or response.metadata.get("thread_id")
async with self._send_semaphore:
if msg_key == "sampleMarkdown":
return await send_markdown(
self._token_manager,
open_conversation_id,
robot_code,
"ForcePilot",
content,
chat_type=chat_type,
thread_root_id=thread_root_id,
http_client=http_client,
)
elif msg_key not in ("sampleText",):
msg_param_raw = payload.get("msgParam", "{}")
msg_param = json.loads(msg_param_raw) if isinstance(msg_param_raw, str) else msg_param_raw
return await send_card(
self._token_manager,
open_conversation_id,
robot_code,
msg_key,
msg_param,
chat_type=chat_type,
thread_root_id=thread_root_id,
http_client=http_client,
)
return await send_text(
self._token_manager,
open_conversation_id,
robot_code,
content,
chat_type=chat_type,
thread_root_id=thread_root_id,
http_client=http_client,
)
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
if not self._token_manager:
return DeliveryResult(success=False, error="Not connected")
accounts = self.config.get("accounts", {})
robot_code = accounts.get("default", {}).get("robot_code", "")
open_conversation_id = chat_id.replace("group_", "").replace("dm_", "")
chat_type = "group" if chat_id.startswith("group_") else "direct"
http_client = await self._get_http_client()
async with self._send_semaphore:
try:
filename = f"media_{int(time.time())}"
upload_resp = await upload_media(
self._token_manager, media_type, data, filename=filename, http_client=http_client
)
media_id = upload_resp.get("media_id", upload_resp.get("mediaId", ""))
if not media_id:
return DeliveryResult(success=False, error="Failed to get media_id from upload")
return await _send_media(
self._token_manager,
open_conversation_id,
robot_code,
media_id,
media_type,
chat_type=chat_type,
http_client=http_client,
)
except Exception as e:
logger.error(f"[DingDing] send_media failed: {e}")
return DeliveryResult(success=False, error=str(e))
async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
if not self._token_manager:
return DeliveryResult(success=False, error="Not connected")
async with self._stream_lock:
if not finished:
existing = self._pending_streams.get(chat_id, "")
self._pending_streams[chat_id] = existing + chunk
return DeliveryResult(success=True, message_id=msg_id or "pending")
final_text = self._pending_streams.pop(chat_id, chunk)
accounts = self.config.get("accounts", {})
robot_code = accounts.get("default", {}).get("robot_code", "")
open_conversation_id = chat_id.replace("group_", "").replace("dm_", "")
chat_type = "group" if chat_id.startswith("group_") else "direct"
mode = self.config.get("mode", "stream")
webhook_url = self._session_webhooks.pop(chat_id.replace("group_", "").replace("dm_", ""), "")
if mode == "stream" and webhook_url:
try:
http_client = await self._get_http_client()
async with self._send_semaphore:
return await _send_via_webhook(
http_client, webhook_url, robot_code, final_text, open_conversation_id
)
except Exception as e:
logger.warning(f"[DingDing] Webhook reply failed, falling back to REST API: {e}")
http_client = await self._get_http_client()
async with self._send_semaphore:
return await send_markdown(
self._token_manager,
open_conversation_id,
robot_code,
"ForcePilot",
final_text,
chat_type=chat_type,
http_client=http_client,
)
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
if not self._token_manager:
return DeliveryResult(success=False, error="Not connected")
try:
token = await self._token_manager.get_token()
http_client = await self._get_http_client()
url = f"https://api.dingtalk.com/v1.0/robot/messages/{msg_id}"
headers = {
"x-acs-dingtalk-access-token": token,
"Content-Type": "application/json",
}
accounts = self.config.get("accounts", {})
default_account = accounts.get("default", {})
payload = {
"msgParam": json.dumps({"content": content}, ensure_ascii=False),
"msgKey": "sampleMarkdown",
"openConversationId": chat_id.replace("group_", "").replace("dm_", ""),
"robotCode": default_account.get("robot_code", ""),
}
resp = await http_client.put(url, json=payload, headers=headers)
if resp.status_code != 200:
return DeliveryResult(success=False, error=f"DingDing API HTTP {resp.status_code}")
data = resp.json()
return DeliveryResult(success=True, message_id=data.get("processQueryKey", msg_id))
except Exception as e:
logger.error(f"[DingDing] edit_message failed: {e}")
return DeliveryResult(success=False, error=str(e))
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
if not self._token_manager:
return DeliveryResult(success=False, error="Not connected")
try:
token = await self._token_manager.get_token()
http_client = await self._get_http_client()
accounts = self.config.get("accounts", {})
robot_code = accounts.get("default", {}).get("robot_code", "")
open_conversation_id = chat_id.replace("group_", "").replace("dm_", "")
url = "https://api.dingtalk.com/v1.0/robot/groupMessages/recall"
headers = {
"x-acs-dingtalk-access-token": token,
"Content-Type": "application/json",
}
payload = {
"openConversationId": open_conversation_id,
"robotCode": robot_code,
"processQueryKeys": [msg_id],
}
resp = await http_client.post(url, json=payload, headers=headers)
if resp.status_code != 200:
return DeliveryResult(success=False, error=f"DingDing API HTTP {resp.status_code}: {resp.text[:200]}")
return DeliveryResult(success=True)
except Exception as e:
logger.error(f"[DingDing] delete_message failed: {e}")
return DeliveryResult(success=False, error=str(e))
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
if not self._token_manager:
return DeliveryResult(success=False, error="Not connected")
emotion_map = {
"👍": ("101", "like"),
"👎": ("102", "dislike"),
"❤️": ("103", "heart"),
"😂": ("104", "laugh"),
"😮": ("105", "surprise"),
"😢": ("106", "sad"),
"👀": ("107", "looking"),
"": ("108", "done"),
"": ("109", "error"),
"🤔": ("110", "thinking"),
"🎉": ("111", "celebrate"),
"🔥": ("112", "fire"),
"💯": ("113", "hundred"),
"🙏": ("114", "pray"),
}
emotion_info = emotion_map.get(emoji)
if not emotion_info:
return DeliveryResult(success=False, error=f"Unsupported emoji: {emoji}")
emotion_type, emotion_name = emotion_info
try:
token = await self._token_manager.get_token()
http_client = await self._get_http_client()
accounts = self.config.get("accounts", {})
robot_code = accounts.get("default", {}).get("robot_code", "")
open_conversation_id = chat_id.replace("group_", "").replace("dm_", "")
url = "https://api.dingtalk.com/v1.0/robot/emotion/reply"
headers = {
"x-acs-dingtalk-access-token": token,
"Content-Type": "application/json",
}
payload = {
"robotCode": robot_code,
"openMsgId": msg_id,
"openConversationId": open_conversation_id,
"emotionType": int(emotion_type),
"emotionName": emotion_name,
}
resp = await http_client.post(url, json=payload, headers=headers)
if resp.status_code != 200:
return DeliveryResult(
success=False, error=f"DingDing emotion API HTTP {resp.status_code}: {resp.text[:200]}"
)
return DeliveryResult(success=True)
except Exception as e:
logger.error(f"[DingDing] send_reaction failed: {e}")
return DeliveryResult(success=False, error=str(e))
async def download_media(self, file_id: str) -> bytes:
if not self._token_manager:
raise RuntimeError("Not connected")
http_client = await self._get_http_client()
return await _download_media(self._token_manager, file_id, http_client=http_client)
async def health_check(self) -> HealthStatus:
if not self._token_manager:
return HealthStatus(status="unhealthy", last_error="Not connected")
try:
token = await self._token_manager.get_token()
return HealthStatus(
status="healthy" if token else "degraded",
metadata={
"mode": self.config.get("mode", "stream"),
"adapter_status": self._status.value,
},
last_connected_at=utc_now_naive() if token else None,
)
except Exception as e:
return HealthStatus(status="unhealthy", last_error=str(e))
async def verify_webhook_signature(self, headers: dict, body: bytes) -> bool:
accounts = self.config.get("accounts", {})
app_secret = accounts.get("default", {}).get("app_secret", "")
if not app_secret:
logger.error("[DingDing] Webhook signature verification failed: no app_secret configured")
return False
return verify_sign(headers, app_secret)
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
if not self._token_manager:
return {}
try:
token = await self._token_manager.get_token()
http_client = await self._get_http_client()
resp = await http_client.get(
f"https://api.dingtalk.com/v1.0/contact/users/{channel_user_id}",
headers={"x-acs-dingtalk-access-token": token},
)
if resp.status_code == 200:
data = resp.json()
return {
"user_id": data.get("openId", data.get("unionId", channel_user_id)),
"name": data.get("nick", data.get("name", "")),
"avatar_url": data.get("avatarUrl", data.get("avatar", "")),
"email": data.get("email", ""),
"mobile": data.get("mobile", ""),
"raw": data,
}
except Exception:
logger.warning(f"[DingDing] Failed to get user info for {channel_user_id}", exc_info=True)
return {}
async def receive(self) -> AsyncIterator[ChannelMessage]:
if False:
yield
async def _refresh_token_if_needed(self) -> bool:
if not self._token_manager:
return False
try:
await self._token_manager.get_token()
return True
except Exception:
return False
async def _get_http_client(self) -> httpx.AsyncClient:
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(
timeout=httpx.Timeout(15),
limits=httpx.Limits(max_keepalive_connections=5, max_connections=20),
)
return self._http_client
async def _reset_rate_limit_loop(self) -> None:
while True:
await asyncio.sleep(60)
old_sem, self._send_semaphore = self._send_semaphore, asyncio.Semaphore(DINGDING_RATE_LIMIT_PER_MINUTE)
for _ in range(DINGDING_RATE_LIMIT_PER_MINUTE):
try:
old_sem.release()
except ValueError:
break