ForcePilot/backend/package/yuxi/channels/adapters/dingding/adapter.py
Kris 9c01d480cc feat(dingding): 完整实现钉钉适配器功能模块
- 新增钉钉工具调用审计日志功能
- 新增参数校验装饰器与基础工具集(文档、审批、多维表格)
- 完善事件类型映射与会话ID生成逻辑
- 新增消息去重、访问策略匹配、配置校验与UI提示
- 新增酷应用卡片、目录管理、表情反应支持
- 优化消息发送逻辑,添加401重试机制
- 新增配置化的权限控制与流式输出支持
2026-05-13 16:06:10 +08:00

677 lines
26 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.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