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