- 新增钉钉工具调用审计日志功能 - 新增参数校验装饰器与基础工具集(文档、审批、多维表格) - 完善事件类型映射与会话ID生成逻辑 - 新增消息去重、访问策略匹配、配置校验与UI提示 - 新增酷应用卡片、目录管理、表情反应支持 - 优化消息发送逻辑,添加401重试机制 - 新增配置化的权限控制与流式输出支持
677 lines
26 KiB
Python
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
|