feat(dingding): add dingtalk channel adapter implementation
实现钉钉官方的频道适配器,包含完整的消息收发、事件处理、媒体上传下载、流式响应以及webhook支持,覆盖了认证、签名校验、速率限制、健康检查等完整功能流程。
This commit is contained in:
parent
6aa1d52a8b
commit
86105d163d
@ -0,0 +1,3 @@
|
|||||||
|
from yuxi.channels.adapters.dingding.adapter import DingDingChannelAdapter
|
||||||
|
|
||||||
|
__all__ = ["DingDingChannelAdapter"]
|
||||||
567
backend/package/yuxi/channels/adapters/dingding/adapter.py
Normal file
567
backend/package/yuxi/channels/adapters/dingding/adapter.py
Normal file
@ -0,0 +1,567 @@
|
|||||||
|
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
|
||||||
47
backend/package/yuxi/channels/adapters/dingding/cards.py
Normal file
47
backend/package/yuxi/channels/adapters/dingding/cards.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
TEXT_CHUNK_LIMIT = 30000
|
||||||
|
|
||||||
|
|
||||||
|
def build_dingding_text_payload(content: str) -> dict[str, Any]:
|
||||||
|
return {"content": str(content)[:TEXT_CHUNK_LIMIT]}
|
||||||
|
|
||||||
|
|
||||||
|
def build_dingding_markdown_payload(title: str, text: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"title": str(title),
|
||||||
|
"text": str(text),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_dingding_action_card(
|
||||||
|
title: str,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
single_title: str | None = None,
|
||||||
|
single_url: str | None = None,
|
||||||
|
buttons: list[dict[str, str]] | None = None,
|
||||||
|
btn_orientation: str = "0",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
msg_key = "sampleActionCard1" if single_title else "sampleActionCard2"
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"title": str(title),
|
||||||
|
"text": str(text),
|
||||||
|
}
|
||||||
|
if single_title and single_url:
|
||||||
|
params["singleTitle"] = single_title
|
||||||
|
params["singleURL"] = single_url
|
||||||
|
if buttons:
|
||||||
|
params["btns"] = buttons
|
||||||
|
params["btnOrientation"] = btn_orientation
|
||||||
|
|
||||||
|
return {"msg_key": msg_key, "msg_param": params}
|
||||||
|
|
||||||
|
|
||||||
|
def build_dingding_feed_card(links: list[dict[str, str]]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"msg_key": "sampleFeedCard",
|
||||||
|
"msg_param": {"links": links},
|
||||||
|
}
|
||||||
39
backend/package/yuxi/channels/adapters/dingding/formatter.py
Normal file
39
backend/package/yuxi/channels/adapters/dingding/formatter.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def format_outbound(
|
||||||
|
content: str,
|
||||||
|
chat_type: str = "direct",
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
meta = metadata or {}
|
||||||
|
|
||||||
|
msg_key = "sampleText"
|
||||||
|
msg_param: dict[str, Any] = {"content": content}
|
||||||
|
|
||||||
|
if meta.get("use_markdown"):
|
||||||
|
msg_key = "sampleMarkdown"
|
||||||
|
msg_param = {
|
||||||
|
"title": meta.get("title", "ForcePilot"),
|
||||||
|
"text": content,
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.get("msg_key"):
|
||||||
|
msg_key = meta["msg_key"]
|
||||||
|
if meta.get("msg_param"):
|
||||||
|
msg_param = meta["msg_param"]
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"msgKey": msg_key,
|
||||||
|
"msgParam": json.dumps(msg_param, ensure_ascii=False),
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.get("openConversationId"):
|
||||||
|
result["openConversationId"] = meta["openConversationId"]
|
||||||
|
if meta.get("robotCode"):
|
||||||
|
result["robotCode"] = meta["robotCode"]
|
||||||
|
|
||||||
|
return result
|
||||||
100
backend/package/yuxi/channels/adapters/dingding/media.py
Normal file
100
backend/package/yuxi/channels/adapters/dingding/media.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from yuxi.channels.models import DeliveryResult
|
||||||
|
|
||||||
|
from .token import DingDingTokenManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_media(
|
||||||
|
token_manager: DingDingTokenManager,
|
||||||
|
media_type: str,
|
||||||
|
media_data: bytes,
|
||||||
|
filename: str = "file",
|
||||||
|
*,
|
||||||
|
http_client: httpx.AsyncClient | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
token = await token_manager.get_token()
|
||||||
|
url = "https://api.dingtalk.com/v1.0/media/upload"
|
||||||
|
|
||||||
|
headers = {"x-acs-dingtalk-access-token": token}
|
||||||
|
files = {"media": (filename, media_data)}
|
||||||
|
params = {"type": media_type}
|
||||||
|
|
||||||
|
if http_client is not None:
|
||||||
|
resp = await http_client.post(url, headers=headers, files=files, params=params)
|
||||||
|
else:
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(30)) as client:
|
||||||
|
resp = await client.post(url, headers=headers, files=files, params=params)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(f"upload_media HTTP {resp.status_code}: {resp.text[:300]}")
|
||||||
|
raise RuntimeError(f"Media upload failed: HTTP {resp.status_code}")
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def download_media(
|
||||||
|
token_manager: DingDingTokenManager,
|
||||||
|
download_code: str,
|
||||||
|
*,
|
||||||
|
http_client: httpx.AsyncClient | None = None,
|
||||||
|
) -> bytes:
|
||||||
|
token = await token_manager.get_token()
|
||||||
|
url = "https://api.dingtalk.com/v1.0/media/download"
|
||||||
|
|
||||||
|
headers = {"x-acs-dingtalk-access-token": token}
|
||||||
|
params = {"downloadCode": download_code}
|
||||||
|
|
||||||
|
if http_client is not None:
|
||||||
|
resp = await http_client.get(url, headers=headers, params=params)
|
||||||
|
else:
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(30)) as client:
|
||||||
|
resp = await client.get(url, headers=headers, params=params)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(f"download_media HTTP {resp.status_code}: {resp.text[:300]}")
|
||||||
|
raise RuntimeError(f"Media download failed: HTTP {resp.status_code}")
|
||||||
|
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
async def send_media(
|
||||||
|
token_manager: DingDingTokenManager,
|
||||||
|
open_conversation_id: str,
|
||||||
|
robot_code: str,
|
||||||
|
media_id: str,
|
||||||
|
media_type: str,
|
||||||
|
*,
|
||||||
|
chat_type: str = "direct",
|
||||||
|
http_client: httpx.AsyncClient | None = None,
|
||||||
|
) -> DeliveryResult:
|
||||||
|
try:
|
||||||
|
msg_key_map = {
|
||||||
|
"image": "sampleImageMsg",
|
||||||
|
"voice": "sampleAudio",
|
||||||
|
"file": "sampleFile",
|
||||||
|
"video": "sampleVideo",
|
||||||
|
}
|
||||||
|
msg_key = msg_key_map.get(media_type, "sampleFile")
|
||||||
|
msg_param = {"mediaId": media_id}
|
||||||
|
|
||||||
|
from .send import _send_msg
|
||||||
|
|
||||||
|
return await _send_msg(
|
||||||
|
token_manager,
|
||||||
|
open_conversation_id,
|
||||||
|
robot_code,
|
||||||
|
msg_key,
|
||||||
|
msg_param,
|
||||||
|
chat_type=chat_type,
|
||||||
|
http_client=http_client,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"send_media failed: {e}")
|
||||||
|
return DeliveryResult(success=False, error=str(e))
|
||||||
212
backend/package/yuxi/channels/adapters/dingding/normalizer.py
Normal file
212
backend/package/yuxi/channels/adapters/dingding/normalizer.py
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from yuxi.channels.models import (
|
||||||
|
Attachment,
|
||||||
|
ChannelIdentity,
|
||||||
|
ChannelMessage,
|
||||||
|
ChannelType,
|
||||||
|
ChatType,
|
||||||
|
EventType,
|
||||||
|
MentionsInfo,
|
||||||
|
MessageType,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .session import generate_dingding_chat_id, resolve_dingding_chat_type
|
||||||
|
|
||||||
|
|
||||||
|
def map_event_type(raw: dict) -> EventType:
|
||||||
|
if raw.get("event_type") == "card_action":
|
||||||
|
return EventType.CARD_ACTION
|
||||||
|
if raw.get("action_from") == "card_callback":
|
||||||
|
return EventType.CARD_ACTION
|
||||||
|
if raw.get("robotCode"):
|
||||||
|
return EventType.BOT_ADDED
|
||||||
|
return EventType.MESSAGE_RECEIVED
|
||||||
|
|
||||||
|
|
||||||
|
def map_msg_type(raw: dict) -> MessageType:
|
||||||
|
msg_type = raw.get("msgtype", "text")
|
||||||
|
_map: dict[str, MessageType] = {
|
||||||
|
"text": MessageType.TEXT,
|
||||||
|
"image": MessageType.IMAGE,
|
||||||
|
"file": MessageType.FILE,
|
||||||
|
"voice": MessageType.AUDIO,
|
||||||
|
"video": MessageType.VIDEO,
|
||||||
|
"markdown": MessageType.TEXT,
|
||||||
|
"link": MessageType.TEXT,
|
||||||
|
"actionCard": MessageType.CARD,
|
||||||
|
"feedCard": MessageType.CARD,
|
||||||
|
"oa": MessageType.TEXT,
|
||||||
|
"sticker": MessageType.STICKER,
|
||||||
|
}
|
||||||
|
return _map.get(msg_type, MessageType.TEXT)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_content(content: Any) -> dict:
|
||||||
|
if isinstance(content, dict):
|
||||||
|
return content
|
||||||
|
if isinstance(content, str):
|
||||||
|
try:
|
||||||
|
return json.loads(content)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _describe_media(msg_type: str, parsed: dict, raw: dict | None = None) -> str:
|
||||||
|
if msg_type == "image":
|
||||||
|
return "[图片]"
|
||||||
|
if msg_type == "file":
|
||||||
|
filename = parsed.get("fileName", parsed.get("file_name", ""))
|
||||||
|
if not filename and raw:
|
||||||
|
filename = raw.get("fileName", "")
|
||||||
|
return f"[文件: {filename}]" if filename else "[文件]"
|
||||||
|
if msg_type == "voice":
|
||||||
|
return "[语音]"
|
||||||
|
if msg_type == "video":
|
||||||
|
return "[视频]"
|
||||||
|
if msg_type == "sticker":
|
||||||
|
return "[贴纸]"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text(raw: dict) -> str:
|
||||||
|
msg_type = raw.get("msgtype", "text")
|
||||||
|
text_block = raw.get("text", {})
|
||||||
|
|
||||||
|
if msg_type in ("image", "file", "voice", "video", "sticker"):
|
||||||
|
parsed = _parse_content(raw.get("content", text_block))
|
||||||
|
return _describe_media(msg_type, parsed, raw)
|
||||||
|
|
||||||
|
if isinstance(text_block, dict):
|
||||||
|
return text_block.get("content", "")
|
||||||
|
if isinstance(text_block, str):
|
||||||
|
return text_block
|
||||||
|
try:
|
||||||
|
return str(text_block) if text_block else ""
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_attachments(raw: dict) -> list[Attachment]:
|
||||||
|
msg_type = raw.get("msgtype", "text")
|
||||||
|
content = raw.get("content", {})
|
||||||
|
|
||||||
|
if msg_type == "image":
|
||||||
|
download_code = raw.get("downloadCode", "") or raw.get("download_code", "")
|
||||||
|
if download_code:
|
||||||
|
return [Attachment(type="image", file_id=download_code)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
if msg_type == "file":
|
||||||
|
download_code = raw.get("downloadCode", "") or raw.get("download_code", "")
|
||||||
|
if download_code:
|
||||||
|
return [
|
||||||
|
Attachment(
|
||||||
|
type="file",
|
||||||
|
file_id=download_code,
|
||||||
|
filename=raw.get("fileName", ""),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
|
if msg_type == "voice":
|
||||||
|
download_code = raw.get("downloadCode", "") or raw.get("download_code", "")
|
||||||
|
if download_code:
|
||||||
|
return [Attachment(type="audio", file_id=download_code)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
if msg_type == "video":
|
||||||
|
download_code = raw.get("downloadCode", "") or raw.get("download_code", "")
|
||||||
|
if download_code:
|
||||||
|
return [Attachment(type="video", file_id=download_code)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
if msg_type == "sticker":
|
||||||
|
parsed = _parse_content(content)
|
||||||
|
download_code = parsed.get("downloadCode", "") or parsed.get("download_code", "")
|
||||||
|
if download_code:
|
||||||
|
return [Attachment(type="sticker", file_id=download_code)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def extract_mentions(raw: dict) -> MentionsInfo | None:
|
||||||
|
if not raw.get("isGroupChat"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
mentioned_ids: list[str] = []
|
||||||
|
is_at_all = raw.get("isAtAll", False)
|
||||||
|
|
||||||
|
at_users = raw.get("atUsers", [])
|
||||||
|
if isinstance(at_users, list):
|
||||||
|
for u in at_users:
|
||||||
|
if isinstance(u, dict):
|
||||||
|
uid = u.get("dingtalkId", "")
|
||||||
|
if uid:
|
||||||
|
mentioned_ids.append(uid)
|
||||||
|
|
||||||
|
if is_at_all:
|
||||||
|
mentioned_ids.append("@all")
|
||||||
|
|
||||||
|
raw_text = extract_text(raw).strip()
|
||||||
|
|
||||||
|
return MentionsInfo(
|
||||||
|
mentioned_user_ids=mentioned_ids,
|
||||||
|
is_bot_mentioned=bool(mentioned_ids),
|
||||||
|
raw_text=raw_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_inbound(
|
||||||
|
channel_id: str,
|
||||||
|
channel_type: ChannelType,
|
||||||
|
raw_payload: dict[str, Any],
|
||||||
|
) -> ChannelMessage:
|
||||||
|
chat_type_str = resolve_dingding_chat_type(raw_payload)
|
||||||
|
chat_type = ChatType(chat_type_str)
|
||||||
|
|
||||||
|
chat_id = generate_dingding_chat_id(raw_payload, chat_type_str)
|
||||||
|
sender_id = (
|
||||||
|
raw_payload.get("senderId", "")
|
||||||
|
or raw_payload.get("senderStaffId", "")
|
||||||
|
or raw_payload.get("senderNick", "")
|
||||||
|
or "unknown"
|
||||||
|
)
|
||||||
|
message_id = raw_payload.get("msgId", "") or raw_payload.get("openMsgId", "")
|
||||||
|
|
||||||
|
event_type = map_event_type(raw_payload)
|
||||||
|
msg_type = map_msg_type(raw_payload)
|
||||||
|
content = extract_text(raw_payload)
|
||||||
|
attachments = extract_attachments(raw_payload)
|
||||||
|
mentions = extract_mentions(raw_payload)
|
||||||
|
|
||||||
|
metadata: dict[str, Any] = {
|
||||||
|
"msgtype": raw_payload.get("msgtype", "text"),
|
||||||
|
"isGroupChat": raw_payload.get("isGroupChat", False),
|
||||||
|
}
|
||||||
|
if raw_payload.get("rootId"):
|
||||||
|
metadata["root_id"] = raw_payload["rootId"]
|
||||||
|
if raw_payload.get("conversationId"):
|
||||||
|
metadata["conversation_id"] = raw_payload["conversationId"]
|
||||||
|
|
||||||
|
return ChannelMessage(
|
||||||
|
identity=ChannelIdentity(
|
||||||
|
channel_id=channel_id,
|
||||||
|
channel_type=channel_type,
|
||||||
|
channel_user_id=sender_id,
|
||||||
|
channel_chat_id=chat_id,
|
||||||
|
channel_message_id=message_id,
|
||||||
|
),
|
||||||
|
event_type=event_type,
|
||||||
|
message_type=msg_type,
|
||||||
|
chat_type=chat_type,
|
||||||
|
content=content,
|
||||||
|
attachments=attachments,
|
||||||
|
mentions=mentions,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
189
backend/package/yuxi/channels/adapters/dingding/send.py
Normal file
189
backend/package/yuxi/channels/adapters/dingding/send.py
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from yuxi.channels.models import DeliveryResult
|
||||||
|
|
||||||
|
from .token import DingDingTokenManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_text(
|
||||||
|
token_manager: DingDingTokenManager,
|
||||||
|
open_conversation_id: str,
|
||||||
|
robot_code: str,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
chat_type: str = "direct",
|
||||||
|
msg_uuid: str | None = None,
|
||||||
|
thread_root_id: str | None = None,
|
||||||
|
http_client: httpx.AsyncClient | None = None,
|
||||||
|
) -> DeliveryResult:
|
||||||
|
try:
|
||||||
|
return await _send_msg(
|
||||||
|
token_manager,
|
||||||
|
open_conversation_id,
|
||||||
|
robot_code,
|
||||||
|
"sampleText",
|
||||||
|
{"content": content},
|
||||||
|
chat_type=chat_type,
|
||||||
|
msg_uuid=msg_uuid,
|
||||||
|
thread_root_id=thread_root_id,
|
||||||
|
http_client=http_client,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"send_text failed: {e}")
|
||||||
|
return DeliveryResult(success=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def send_markdown(
|
||||||
|
token_manager: DingDingTokenManager,
|
||||||
|
open_conversation_id: str,
|
||||||
|
robot_code: str,
|
||||||
|
title: str,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
chat_type: str = "direct",
|
||||||
|
msg_uuid: str | None = None,
|
||||||
|
thread_root_id: str | None = None,
|
||||||
|
http_client: httpx.AsyncClient | None = None,
|
||||||
|
) -> DeliveryResult:
|
||||||
|
try:
|
||||||
|
return await _send_msg(
|
||||||
|
token_manager,
|
||||||
|
open_conversation_id,
|
||||||
|
robot_code,
|
||||||
|
"sampleMarkdown",
|
||||||
|
{"title": title, "text": text},
|
||||||
|
chat_type=chat_type,
|
||||||
|
msg_uuid=msg_uuid,
|
||||||
|
thread_root_id=thread_root_id,
|
||||||
|
http_client=http_client,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"send_markdown failed: {e}")
|
||||||
|
return DeliveryResult(success=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def send_card(
|
||||||
|
token_manager: DingDingTokenManager,
|
||||||
|
open_conversation_id: str,
|
||||||
|
robot_code: str,
|
||||||
|
msg_key: str,
|
||||||
|
msg_param: dict[str, Any],
|
||||||
|
*,
|
||||||
|
chat_type: str = "direct",
|
||||||
|
msg_uuid: str | None = None,
|
||||||
|
thread_root_id: str | None = None,
|
||||||
|
http_client: httpx.AsyncClient | None = None,
|
||||||
|
) -> DeliveryResult:
|
||||||
|
try:
|
||||||
|
return await _send_msg(
|
||||||
|
token_manager,
|
||||||
|
open_conversation_id,
|
||||||
|
robot_code,
|
||||||
|
msg_key,
|
||||||
|
msg_param,
|
||||||
|
chat_type=chat_type,
|
||||||
|
msg_uuid=msg_uuid,
|
||||||
|
thread_root_id=thread_root_id,
|
||||||
|
http_client=http_client,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"send_card failed: {e}")
|
||||||
|
return DeliveryResult(success=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_msg(
|
||||||
|
token_manager: DingDingTokenManager,
|
||||||
|
open_conversation_id: str,
|
||||||
|
robot_code: str,
|
||||||
|
msg_key: str,
|
||||||
|
msg_param: dict[str, Any],
|
||||||
|
*,
|
||||||
|
chat_type: str = "direct",
|
||||||
|
msg_uuid: str | None = None,
|
||||||
|
thread_root_id: str | None = None,
|
||||||
|
http_client: httpx.AsyncClient | None = None,
|
||||||
|
) -> DeliveryResult:
|
||||||
|
token = await token_manager.get_token()
|
||||||
|
|
||||||
|
if chat_type == "group":
|
||||||
|
url = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
|
||||||
|
else:
|
||||||
|
url = "https://api.dingtalk.com/v1.0/robot/privateChatMessages/send"
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"msgParam": json.dumps(msg_param, ensure_ascii=False),
|
||||||
|
"msgKey": msg_key,
|
||||||
|
"openConversationId": open_conversation_id,
|
||||||
|
"robotCode": robot_code,
|
||||||
|
"msgUuid": msg_uuid or str(uuid.uuid4()),
|
||||||
|
}
|
||||||
|
|
||||||
|
if thread_root_id:
|
||||||
|
payload["rootId"] = thread_root_id
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"x-acs-dingtalk-access-token": token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
if http_client is not None:
|
||||||
|
resp = await http_client.post(url, json=payload, headers=headers)
|
||||||
|
else:
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(15)) as client:
|
||||||
|
resp = await client.post(url, json=payload, headers=headers)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(f"_send_msg HTTP {resp.status_code}: {resp.text[:300]} url={url}")
|
||||||
|
return DeliveryResult(
|
||||||
|
success=False,
|
||||||
|
error=f"DingDing API HTTP {resp.status_code}: {resp.text[:200]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
return DeliveryResult(
|
||||||
|
success=True,
|
||||||
|
message_id=data.get("processQueryKey") or data.get("messageId", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_via_webhook(
|
||||||
|
http_client: httpx.AsyncClient,
|
||||||
|
webhook_url: str,
|
||||||
|
robot_code: str,
|
||||||
|
content: str,
|
||||||
|
open_conversation_id: str,
|
||||||
|
) -> DeliveryResult:
|
||||||
|
payload = {
|
||||||
|
"msgKey": "sampleMarkdown",
|
||||||
|
"msgParam": json.dumps({"content": content}, ensure_ascii=False),
|
||||||
|
"robotCode": robot_code,
|
||||||
|
"openConversationId": open_conversation_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = await http_client.post(
|
||||||
|
webhook_url,
|
||||||
|
json=payload,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return DeliveryResult(
|
||||||
|
success=False,
|
||||||
|
error=f"Webhook HTTP {resp.status_code}: {resp.text[:200]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
return DeliveryResult(
|
||||||
|
success=True,
|
||||||
|
message_id=data.get("processQueryKey") or data.get("messageId", ""),
|
||||||
|
)
|
||||||
20
backend/package/yuxi/channels/adapters/dingding/session.py
Normal file
20
backend/package/yuxi/channels/adapters/dingding/session.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def generate_dingding_chat_id(raw: dict, chat_type: str) -> str:
|
||||||
|
if chat_type == "direct":
|
||||||
|
sender_id = raw.get("senderId", "")
|
||||||
|
return f"dm_{sender_id}"
|
||||||
|
conversation_id = raw.get("conversationId", "")
|
||||||
|
return f"group_{conversation_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_dingding_chat_type(raw: dict) -> str:
|
||||||
|
is_group = raw.get("isGroupChat", False)
|
||||||
|
return "group" if is_group else "direct"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_thread_key(channel_id: str, chat_id: str) -> str:
|
||||||
|
if chat_id.startswith("dm_"):
|
||||||
|
return f"{channel_id}:direct:{chat_id}"
|
||||||
|
return f"{channel_id}:group:{chat_id}"
|
||||||
32
backend/package/yuxi/channels/adapters/dingding/sign.py
Normal file
32
backend/package/yuxi/channels/adapters/dingding/sign.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
|
||||||
|
def compute_dingtalk_sign(timestamp_ms: str, app_secret: str) -> str:
|
||||||
|
secret_enc = app_secret.encode("utf-8")
|
||||||
|
string_to_sign = f"{timestamp_ms}\n{app_secret}".encode()
|
||||||
|
hmac_code = hmac.new(secret_enc, string_to_sign, digestmod=hashlib.sha256).digest()
|
||||||
|
return urllib.parse.quote_plus(base64.b64encode(hmac_code))
|
||||||
|
|
||||||
|
|
||||||
|
def verify_webhook_signature(headers: dict, app_secret: str) -> bool:
|
||||||
|
request_timestamp = headers.get("timestamp", "")
|
||||||
|
request_sign = headers.get("sign", "")
|
||||||
|
|
||||||
|
if not request_timestamp or not request_sign:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
ts = int(request_timestamp) / 1000
|
||||||
|
if abs(time.time() - ts) > 3600:
|
||||||
|
return False
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
expected_sign = compute_dingtalk_sign(request_timestamp, app_secret)
|
||||||
|
return hmac.compare_digest(request_sign, expected_sign)
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from yuxi.channels.adapters.dingding.adapter import DingDingChannelAdapter
|
||||||
|
|
||||||
|
HAS_DINGTALK_STREAM = False
|
||||||
|
try:
|
||||||
|
import dingtalk_stream # noqa: F401
|
||||||
|
|
||||||
|
HAS_DINGTALK_STREAM = True
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DingDingChatbotHandler:
|
||||||
|
def __init__(self, adapter: DingDingChannelAdapter):
|
||||||
|
self._adapter = adapter
|
||||||
|
|
||||||
|
async def process(self, callback: object) -> tuple[int, str]:
|
||||||
|
if not HAS_DINGTALK_STREAM:
|
||||||
|
return 200, "OK"
|
||||||
|
|
||||||
|
from dingtalk_stream import AckMessage
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_data = getattr(callback, "data", {})
|
||||||
|
session_webhook = getattr(callback, "sessionWebhook", None) or raw_data.get("sessionWebhook", "")
|
||||||
|
|
||||||
|
if session_webhook:
|
||||||
|
self._adapter._session_webhooks[raw_data.get("conversationId", "")] = session_webhook
|
||||||
|
|
||||||
|
msg = self._adapter.normalize_inbound(raw_data)
|
||||||
|
await self._adapter._handle_message(msg)
|
||||||
|
return AckMessage.STATUS_OK, "OK"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DingDing] ChatbotHandler error: {e}")
|
||||||
|
return AckMessage.STATUS_INTERNAL_SERVER_ERROR, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
class DingDingCallbackHandler:
|
||||||
|
"""互动卡片回传事件处理器"""
|
||||||
|
|
||||||
|
def __init__(self, adapter: DingDingChannelAdapter):
|
||||||
|
self._adapter = adapter
|
||||||
|
|
||||||
|
async def process(self, callback: object) -> tuple[int, str]:
|
||||||
|
if not HAS_DINGTALK_STREAM:
|
||||||
|
return 200, "OK"
|
||||||
|
|
||||||
|
from dingtalk_stream import AckMessage
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_data = getattr(callback, "data", {})
|
||||||
|
from yuxi.channels.models import EventType
|
||||||
|
|
||||||
|
msg = self._adapter.normalize_inbound(raw_data)
|
||||||
|
msg.event_type = EventType.CARD_ACTION
|
||||||
|
await self._adapter._handle_message(msg)
|
||||||
|
return AckMessage.STATUS_OK, "OK"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DingDing] CallbackHandler error: {e}")
|
||||||
|
return AckMessage.STATUS_INTERNAL_SERVER_ERROR, str(e)
|
||||||
79
backend/package/yuxi/channels/adapters/dingding/token.py
Normal file
79
backend/package/yuxi/channels/adapters/dingding/token.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from yuxi.utils.logging_config import logger
|
||||||
|
|
||||||
|
TOKEN_REFRESH_MARGIN_S = 200
|
||||||
|
TOKEN_VALIDITY_S = 7200
|
||||||
|
|
||||||
|
|
||||||
|
class DingDingTokenManager:
|
||||||
|
def __init__(self, app_key: str, app_secret: str, http_client: httpx.AsyncClient | None = None):
|
||||||
|
self._app_key = app_key
|
||||||
|
self._app_secret = app_secret
|
||||||
|
self._access_token: str | None = None
|
||||||
|
self._token_expires_at: float = 0
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._http_client = http_client
|
||||||
|
|
||||||
|
async def get_token(self) -> str:
|
||||||
|
if self._is_valid():
|
||||||
|
return self._access_token # type: ignore[return-value]
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
if self._is_valid():
|
||||||
|
return self._access_token # type: ignore[return-value]
|
||||||
|
await self._refresh()
|
||||||
|
|
||||||
|
if not self._access_token:
|
||||||
|
raise RuntimeError("Failed to obtain DingDing access token")
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
async def _refresh(self) -> None:
|
||||||
|
refresh_start = time.monotonic()
|
||||||
|
url = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"appKey": self._app_key,
|
||||||
|
"appSecret": self._app_secret,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self._http_client is not None:
|
||||||
|
resp = await self._http_client.post(url, json=payload)
|
||||||
|
else:
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(15)) as client:
|
||||||
|
resp = await client.post(url, json=payload)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(f"[DingDing] Token refresh HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
raise RuntimeError(f"Token refresh failed: HTTP {resp.status_code}")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
token = data.get("accessToken")
|
||||||
|
if not token:
|
||||||
|
logger.error(f"[DingDing] Token refresh response missing accessToken: {data}")
|
||||||
|
raise RuntimeError("Token refresh response missing accessToken")
|
||||||
|
|
||||||
|
expire_in = data.get("expireIn", TOKEN_VALIDITY_S)
|
||||||
|
self._access_token = token
|
||||||
|
self._token_expires_at = time.time() + expire_in - TOKEN_REFRESH_MARGIN_S
|
||||||
|
|
||||||
|
elapsed = time.monotonic() - refresh_start
|
||||||
|
if elapsed > 2.0:
|
||||||
|
logger.warning(f"[DingDing] Token refresh took {elapsed:.1f}s")
|
||||||
|
logger.info(f"[DingDing] Token refreshed, expires in {expire_in - TOKEN_REFRESH_MARGIN_S}s")
|
||||||
|
|
||||||
|
def _is_valid(self) -> bool:
|
||||||
|
return self._access_token is not None and time.time() < self._token_expires_at
|
||||||
|
|
||||||
|
def invalidate(self) -> None:
|
||||||
|
self._access_token = None
|
||||||
|
self._token_expires_at = 0
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.invalidate()
|
||||||
|
self._http_client = None
|
||||||
Loading…
Reference in New Issue
Block a user