- 新增钉钉工具调用审计日志功能 - 新增参数校验装饰器与基础工具集(文档、审批、多维表格) - 完善事件类型映射与会话ID生成逻辑 - 新增消息去重、访问策略匹配、配置校验与UI提示 - 新增酷应用卡片、目录管理、表情反应支持 - 优化消息发送逻辑,添加401重试机制 - 新增配置化的权限控制与流式输出支持
231 lines
7.5 KiB
Python
231 lines
7.5 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
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__)
|
|
|
|
DINGDING_EMOJI_MAP: dict[str, tuple[str, str]] = {
|
|
"👍": ("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_TYPE_TO_EMOJI: dict[str, str] = {emotion_type: emoji for emoji, (emotion_type, _) in DINGDING_EMOJI_MAP.items()}
|
|
EMOTION_NAME_TO_EMOJI: dict[str, str] = {name: emoji for emoji, (_, name) in DINGDING_EMOJI_MAP.items()}
|
|
|
|
|
|
def resolve_emoji(emoji_or_name: str) -> tuple[str, str] | None:
|
|
if emoji_or_name in DINGDING_EMOJI_MAP:
|
|
return DINGDING_EMOJI_MAP[emoji_or_name]
|
|
lowered = emoji_or_name.lower()
|
|
for emoji, (emotion_type, name) in DINGDING_EMOJI_MAP.items():
|
|
if name == lowered:
|
|
return (emotion_type, name)
|
|
return None
|
|
|
|
|
|
async def send_reaction(
|
|
token_manager: DingDingTokenManager,
|
|
open_conversation_id: str,
|
|
robot_code: str,
|
|
msg_id: str,
|
|
emoji: str,
|
|
*,
|
|
http_client: httpx.AsyncClient | None = None,
|
|
) -> DeliveryResult:
|
|
emotion_info = DINGDING_EMOJI_MAP.get(emoji)
|
|
if not emotion_info:
|
|
emotion_info = resolve_emoji(emoji)
|
|
if not emotion_info:
|
|
return DeliveryResult(success=False, error=f"Unsupported emoji: {emoji}")
|
|
|
|
emotion_type, emotion_name = emotion_info
|
|
|
|
try:
|
|
token = await token_manager.get_token()
|
|
|
|
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,
|
|
}
|
|
|
|
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"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 remove_reaction(
|
|
token_manager: DingDingTokenManager,
|
|
open_conversation_id: str,
|
|
robot_code: str,
|
|
msg_id: str,
|
|
emoji: str,
|
|
*,
|
|
http_client: httpx.AsyncClient | None = None,
|
|
) -> DeliveryResult:
|
|
emotion_info = DINGDING_EMOJI_MAP.get(emoji)
|
|
if not emotion_info:
|
|
emotion_info = resolve_emoji(emoji)
|
|
if not emotion_info:
|
|
return DeliveryResult(success=False, error=f"Unsupported emoji: {emoji}")
|
|
|
|
emotion_type, emotion_name = emotion_info
|
|
|
|
try:
|
|
token = await token_manager.get_token()
|
|
|
|
url = "https://api.dingtalk.com/v1.0/robot/emotion/remove"
|
|
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),
|
|
}
|
|
|
|
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"DingDing emotion remove API HTTP {resp.status_code}: {resp.text[:200]}"
|
|
)
|
|
|
|
return DeliveryResult(success=True)
|
|
except Exception as e:
|
|
logger.error(f"[DingDing] remove_reaction failed: {e}")
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
|
|
async def list_reactions(
|
|
token_manager: DingDingTokenManager,
|
|
open_conversation_id: str,
|
|
robot_code: str,
|
|
msg_id: str,
|
|
*,
|
|
http_client: httpx.AsyncClient | None = None,
|
|
) -> dict[str, Any]:
|
|
try:
|
|
token = await token_manager.get_token()
|
|
|
|
url = "https://api.dingtalk.com/v1.0/robot/emotion/query"
|
|
headers = {
|
|
"x-acs-dingtalk-access-token": token,
|
|
"Content-Type": "application/json",
|
|
}
|
|
payload = {
|
|
"robotCode": robot_code,
|
|
"openMsgId": msg_id,
|
|
"openConversationId": open_conversation_id,
|
|
}
|
|
|
|
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 {"reactions": [], "total": 0, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
|
|
|
|
data = resp.json()
|
|
items = []
|
|
for item in data.get("emotionList", data.get("items", [])):
|
|
emotion_type = str(item.get("emotionType", ""))
|
|
items.append({
|
|
"emotion_type": emotion_type,
|
|
"emoji": EMOTION_TYPE_TO_EMOJI.get(emotion_type, ""),
|
|
"emotion_name": item.get("emotionName", ""),
|
|
"count": item.get("count", 0),
|
|
"user_id": item.get("userId", ""),
|
|
})
|
|
|
|
return {
|
|
"reactions": items,
|
|
"total": data.get("totalCount", len(items)),
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"[DingDing] list_reactions failed: {e}")
|
|
return {"reactions": [], "total": 0, "error": str(e)}
|
|
|
|
|
|
async def clear_all_bot_reactions(
|
|
token_manager: DingDingTokenManager,
|
|
open_conversation_id: str,
|
|
robot_code: str,
|
|
msg_id: str,
|
|
*,
|
|
http_client: httpx.AsyncClient | None = None,
|
|
) -> DeliveryResult:
|
|
result = await list_reactions(
|
|
token_manager, open_conversation_id, robot_code, msg_id, http_client=http_client
|
|
)
|
|
items = result.get("reactions", [])
|
|
removed = 0
|
|
errors = 0
|
|
|
|
for item in items:
|
|
emotion_type = item.get("emotion_type", "")
|
|
emoji = EMOTION_TYPE_TO_EMOJI.get(emotion_type, "")
|
|
if not emoji:
|
|
continue
|
|
|
|
rm_result = await remove_reaction(
|
|
token_manager, open_conversation_id, robot_code, msg_id, emoji, http_client=http_client
|
|
)
|
|
if rm_result.success:
|
|
removed += 1
|
|
else:
|
|
errors += 1
|
|
logger.debug(f"[DingDing] Failed to remove reaction {emoji}: {rm_result.error}")
|
|
|
|
if errors > 0:
|
|
logger.warning(f"[DingDing] clear_all_bot_reactions: removed={removed}, errors={errors}")
|
|
return DeliveryResult(success=removed > 0, error=f"Removed {removed}/{removed + errors}")
|
|
return DeliveryResult(success=True)
|