- 新增钉钉工具调用审计日志功能 - 新增参数校验装饰器与基础工具集(文档、审批、多维表格) - 完善事件类型映射与会话ID生成逻辑 - 新增消息去重、访问策略匹配、配置校验与UI提示 - 新增酷应用卡片、目录管理、表情反应支持 - 优化消息发送逻辑,添加401重试机制 - 新增配置化的权限控制与流式输出支持
191 lines
6.4 KiB
Python
191 lines
6.4 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections import defaultdict
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import httpx
|
|
|
|
from yuxi.channels.models import DeliveryResult
|
|
|
|
if TYPE_CHECKING:
|
|
from yuxi.channels.adapters.dingding.token import DingDingTokenManager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DINGTALK_API_BASE = "https://api.dingtalk.com"
|
|
|
|
|
|
class CoolAppRouter:
|
|
def __init__(self, config: dict[str, Any] | None = None):
|
|
self._routes: dict[str, str] = {}
|
|
self._sticky_cards: dict[str, dict[str, Any]] = defaultdict(dict)
|
|
self._shortcuts: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
self._configure(config)
|
|
|
|
def _configure(self, config: dict[str, Any] | None) -> None:
|
|
if not config:
|
|
return
|
|
cool_app_config = config.get("cool_app", {})
|
|
if not isinstance(cool_app_config, dict):
|
|
return
|
|
routes = cool_app_config.get("routes", {})
|
|
if isinstance(routes, dict):
|
|
for cool_code, agent_name in routes.items():
|
|
self.register(cool_code, agent_name)
|
|
|
|
def register(self, cool_app_code: str, agent_name: str) -> None:
|
|
self._routes[cool_app_code] = agent_name
|
|
logger.info("[DingDing CoolApp] Registered route: %s → %s", cool_app_code, agent_name)
|
|
|
|
def unregister(self, cool_app_code: str) -> None:
|
|
self._routes.pop(cool_app_code, None)
|
|
|
|
def resolve(self, cool_app_code: str) -> str | None:
|
|
return self._routes.get(cool_app_code)
|
|
|
|
def list_routes(self) -> dict[str, str]:
|
|
return dict(self._routes)
|
|
|
|
def set_sticky_card(self, chat_id: str, card_data: dict[str, Any]) -> None:
|
|
self._sticky_cards[chat_id] = card_data
|
|
|
|
def get_sticky_card(self, chat_id: str) -> dict[str, Any] | None:
|
|
return self._sticky_cards.get(chat_id)
|
|
|
|
def remove_sticky_card(self, chat_id: str) -> None:
|
|
self._sticky_cards.pop(chat_id, None)
|
|
|
|
def set_shortcuts(self, chat_id: str, items: list[dict[str, Any]]) -> None:
|
|
self._shortcuts[chat_id] = items
|
|
|
|
def get_shortcuts(self, chat_id: str) -> list[dict[str, Any]]:
|
|
return list(self._shortcuts.get(chat_id, []))
|
|
|
|
|
|
async def send_cool_app_card(
|
|
token_manager: DingDingTokenManager,
|
|
robot_code: str,
|
|
card_data: dict[str, Any],
|
|
*,
|
|
open_conversation_id: str = "",
|
|
user_ids: list[str] | None = None,
|
|
cool_app_code: str = "",
|
|
http_client: httpx.AsyncClient | None = None,
|
|
) -> DeliveryResult:
|
|
token = await token_manager.get_token()
|
|
url = f"{DINGTALK_API_BASE}/v1.0/card/instances"
|
|
headers = {
|
|
"x-acs-dingtalk-access-token": token,
|
|
"Content-Type": "application/json",
|
|
}
|
|
payload: dict[str, Any] = {
|
|
"robotCode": robot_code,
|
|
"cardData": card_data.get("cardData", card_data),
|
|
}
|
|
if open_conversation_id:
|
|
payload["openConversationId"] = open_conversation_id
|
|
if user_ids:
|
|
payload["userIds"] = user_ids
|
|
if cool_app_code:
|
|
payload["coolAppCode"] = cool_app_code
|
|
card_biz_id = card_data.get("cardBizId", "")
|
|
if card_biz_id:
|
|
payload["cardBizId"] = card_biz_id
|
|
private_data = card_data.get("privateData", {})
|
|
if private_data:
|
|
payload["privateData"] = private_data
|
|
|
|
try:
|
|
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:
|
|
return DeliveryResult(
|
|
success=False, error=f"Cool App card API HTTP {resp.status_code}: {resp.text[:200]}"
|
|
)
|
|
|
|
data = resp.json()
|
|
return DeliveryResult(
|
|
success=True,
|
|
message_id=data.get("processQueryKey", data.get("cardInstanceId", "")),
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"[DingDing CoolApp] send_cool_app_card failed: {e}")
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
|
|
async def update_cool_app_card(
|
|
token_manager: DingDingTokenManager,
|
|
robot_code: str,
|
|
card_instance_id: str,
|
|
card_data: dict[str, Any],
|
|
*,
|
|
open_conversation_id: str = "",
|
|
http_client: httpx.AsyncClient | None = None,
|
|
) -> DeliveryResult:
|
|
token = await token_manager.get_token()
|
|
url = f"{DINGTALK_API_BASE}/v1.0/card/instances/{card_instance_id}"
|
|
headers = {
|
|
"x-acs-dingtalk-access-token": token,
|
|
"Content-Type": "application/json",
|
|
}
|
|
payload: dict[str, Any] = {
|
|
"robotCode": robot_code,
|
|
"cardData": card_data.get("cardData", card_data),
|
|
}
|
|
if open_conversation_id:
|
|
payload["openConversationId"] = open_conversation_id
|
|
update_options = card_data.get("updateOptions", card_data.get("sendOptions"))
|
|
if update_options:
|
|
payload["updateOptions"] = update_options
|
|
if "sendOptions" not in payload:
|
|
payload["sendOptions"] = update_options
|
|
private_data = card_data.get("privateData", {})
|
|
if private_data:
|
|
payload["privateData"] = private_data
|
|
|
|
try:
|
|
if http_client is not None:
|
|
resp = await http_client.put(url, json=payload, headers=headers)
|
|
else:
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(15)) as client:
|
|
resp = await client.put(url, json=payload, headers=headers)
|
|
|
|
if resp.status_code != 200:
|
|
return DeliveryResult(
|
|
success=False, error=f"Cool App card update API HTTP {resp.status_code}: {resp.text[:200]}"
|
|
)
|
|
|
|
return DeliveryResult(success=True)
|
|
except Exception as e:
|
|
logger.error(f"[DingDing CoolApp] update_cool_app_card failed: {e}")
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
|
|
def parse_cool_app_code(raw: dict[str, Any]) -> str:
|
|
return raw.get("coolAppCode", raw.get("outTrackId", ""))
|
|
|
|
|
|
def parse_card_callback_data(raw: dict[str, Any]) -> dict[str, Any]:
|
|
content = raw.get("content", "") or raw.get("text", "")
|
|
import json
|
|
|
|
if isinstance(content, str):
|
|
try:
|
|
parsed = json.loads(content)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return {}
|
|
return parsed
|
|
if isinstance(content, dict):
|
|
return content
|
|
return {}
|
|
|
|
|
|
def parse_card_private_data(raw: dict[str, Any]) -> dict[str, Any]:
|
|
card_data = parse_card_callback_data(raw)
|
|
return card_data.get("cardPrivateData", card_data.get("privateData", {}))
|