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