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.exceptions import ChannelAuthenticationError from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError from yuxi.channels.meta import ChannelMeta from yuxi.channels.models import ( ChannelAccountSnapshot, ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, DeliveryResult, HealthStatus, TokenStatus, ) from yuxi.channels.registry import register_builtin_adapter from yuxi.utils.datetime_utils import utc_now_naive from .cards import TEXT_CHUNK_LIMIT from .dedup import DingDingDedupGuard 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 .policy import DingDingPolicyMatcher from .send import send_card, send_markdown, send_text from .sign import verify_webhook_signature as verify_sign from .stream import DingDingStreamManager 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", "partial"] max_media_size_mb = 10 capabilities = ChannelCapabilities( chat_types=["direct", "group"], reactions=True, edit=True, unsend=True, reply=True, media=True, threads=True, typing=False, supports_markdown=True, supports_streaming=True, streaming_modes=["off", "block", "partial"], text_chunk_limit=TEXT_CHUNK_LIMIT, max_media_size_mb=10, ) meta = ChannelMeta( id="dingding", label="DingDing", aliases=["dingtalk"], blurb="钉钉机器人,支持 Stream/Webhook 双模式、流式输出、互动卡片、Emotion 反应等", docs_path="/channels/dingding", docs_label="钉钉渠道配置", selection_docs_prefix="渠道 > 钉钉", system_image="dingding.png", markdown_capable=True, detail_label="钉钉", order=60, ) 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._session_webhooks: dict[str, str] = {} self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60, channel_id="dingding") self._dedup_guard = DingDingDedupGuard() self._policy_matcher = DingDingPolicyMatcher(self.config) self._stream_manager = DingDingStreamManager(self) self._reconnect_attempts = 0 self._last_message_at: float | None = None self._last_error: str | None = None self._busy = False self._active_runs = 0 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, DingDingEventHandler 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) if hasattr(dingtalk_stream, "EventHandler"): event_handler = DingDingEventHandler(self) event_topic = getattr(dingtalk_stream.EventHandler, "TOPIC", "event") self._stream_client.register_callback_handler(event_topic, event_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: self._reconnect_attempts += 1 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 self._reconnect_attempts = 0 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 self._stream_manager.cleanup() if self._http_client and not self._http_client.is_closed: await self._http_client.aclose() self._http_client = None 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) async def _handle_message(self, message: ChannelMessage) -> None: msg_id = message.identity.channel_message_id if msg_id and self._dedup_guard.has_processed(msg_id): logger.debug(f"[DingDing] Skipping duplicate message: {msg_id}") return chat_type_str = "group" if message.chat_type.value == "group" else "direct" allowed, deny_msg = self._policy_matcher.check_chat_access(message.identity.channel_chat_id, chat_type_str) if not allowed: logger.info(f"[DingDing] Access denied for {message.identity.channel_chat_id}: {deny_msg}") return if msg_id: self._dedup_guard.record_as_processed(msg_id) self._last_message_at = time.time() await super()._handle_message(message) 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") at_mobiles = response.metadata.get("at_mobiles") or response.metadata.get("atMobiles") at_user_ids = response.metadata.get("at_user_ids") or response.metadata.get("atUserIds") is_at_all = response.metadata.get("is_at_all", response.metadata.get("isAtAll", False)) async def _do_send() -> DeliveryResult: 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, at_mobiles=at_mobiles, at_user_ids=at_user_ids, is_at_all=is_at_all, 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, at_mobiles=at_mobiles, at_user_ids=at_user_ids, is_at_all=is_at_all, http_client=http_client, ) try: result = await self._circuit_breaker.call(_do_send) if not result.success and result.error: self._last_error = result.error return result except CircuitBreakerOpenError: self._last_error = "Circuit breaker open" return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: self._last_error = str(e) return DeliveryResult(success=False, error=str(e)) 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") return await self._stream_manager.send_chunk(chat_id, msg_id, chunk, finished) @property def block_streaming(self) -> bool: return True async def send_typing_indicator(self, chat_id: str, active: bool) -> DeliveryResult: return DeliveryResult(success=True) 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") from .reactions import send_reaction as _send_react accounts = self.config.get("accounts", {}) robot_code = accounts.get("default", {}).get("robot_code", "") open_conversation_id = chat_id.replace("group_", "").replace("dm_", "") http_client = await self._get_http_client() return await _send_react( self._token_manager, open_conversation_id, robot_code, msg_id, emoji, http_client=http_client, ) async def remove_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult: if not self._token_manager: return DeliveryResult(success=False, error="Not connected") from .reactions import remove_reaction as _remove_react accounts = self.config.get("accounts", {}) robot_code = accounts.get("default", {}).get("robot_code", "") open_conversation_id = chat_id.replace("group_", "").replace("dm_", "") http_client = await self._get_http_client() return await _remove_react( self._token_manager, open_conversation_id, robot_code, msg_id, emoji, http_client=http_client, ) async def list_reactions(self, chat_id: str, msg_id: str) -> dict[str, Any]: if not self._token_manager: return {"reactions": [], "total": 0} from .reactions import list_reactions as _list_react accounts = self.config.get("accounts", {}) robot_code = accounts.get("default", {}).get("robot_code", "") open_conversation_id = chat_id.replace("group_", "").replace("dm_", "") http_client = await self._get_http_client() return await _list_react( self._token_manager, open_conversation_id, robot_code, msg_id, http_client=http_client, ) 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 def build_snapshot(self) -> ChannelAccountSnapshot: from yuxi.channels.models import build_snapshot_from_adapter snapshot = build_snapshot_from_adapter(self) snapshot.running = self._status == ChannelStatus.CONNECTED snapshot.connected = self._status == ChannelStatus.CONNECTED snapshot.status_state = str(self._status.value) accounts = self.config.get("accounts", {}) default_account = accounts.get("default", {}) snapshot.account_id = default_account.get("robot_code", "") snapshot.name = self.config.get("name", "DingDing") snapshot.configured = bool(self._app_key and self._app_secret) if self._token_manager: try: token_valid = time.time() < getattr(self._token_manager, "_token_expires_at", 0) snapshot.token_status = TokenStatus(status="valid" if token_valid else "expired") except Exception: pass snapshot.reconnect_attempts = self._reconnect_attempts snapshot.last_error = self._last_error snapshot.last_message_at = self._last_message_at snapshot.last_connected_at_s = self._connected_at snapshot.busy = self._busy snapshot.active_runs = self._active_runs snapshot.dm_policy = self.config.get("dm_policy", self.config.get("dmPolicy", "pairing")) snapshot.group_policy = self.config.get("group_policy", self.config.get("groupPolicy", "allowlist")) allowlist = self.config.get("allowFrom", self.config.get("allowlist", [])) snapshot.allow_from_count = len(allowlist) if isinstance(allowlist, (list, set)) else 0 snapshot.webhook_path = str(self.webhook_path) snapshot.bot = { "channel": "dingding", "mode": self.config.get("mode", "stream"), "robot_code": default_account.get("robot_code", ""), "stream_mode": self.config.get("streaming", {}).get("mode", "block"), "chat_types": ["direct", "group"], "capabilities": { "reactions": True, "edit": True, "unsend": True, "reply": True, "media": True, "streaming": True, }, } dedup_stats = self._dedup_guard.stats() snapshot.audit = { "dedup_committed": dedup_stats.get("committed", 0), "dedup_pending": dedup_stats.get("pending", 0), "stream_sessions": len(getattr(self._stream_manager, "_sessions", {})), "rate_limit": DINGDING_RATE_LIMIT_PER_MINUTE, } return snapshot