|
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
"""
|
|
|
|
|
|
===================================
|
|
|
|
|
|
钉钉平台适配器
|
|
|
|
|
|
===================================
|
|
|
|
|
|
|
|
|
|
|
|
处理钉钉机器人的 Webhook 回调。
|
|
|
|
|
|
|
|
|
|
|
|
钉钉机器人文档:
|
|
|
|
|
|
https://open.dingtalk.com/document/robots/robot-overview
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import hmac
|
|
|
|
|
|
import base64
|
|
|
|
|
|
import time
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from typing import Dict, Any, Optional
|
|
|
|
|
|
from urllib.parse import quote_plus
|
|
|
|
|
|
|
|
|
|
|
|
from bot.platforms.base import BotPlatform
|
|
|
|
|
|
from bot.models import BotMessage, BotResponse, WebhookResponse, ChatType
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DingtalkPlatform(BotPlatform):
|
|
|
|
|
|
"""
|
|
|
|
|
|
钉钉平台适配器
|
|
|
|
|
|
|
|
|
|
|
|
支持:
|
|
|
|
|
|
- 企业内部机器人回调
|
|
|
|
|
|
- 群机器人 Outgoing 回调
|
|
|
|
|
|
- 消息签名验证
|
|
|
|
|
|
|
|
|
|
|
|
配置要求:
|
|
|
|
|
|
- DINGTALK_APP_KEY: 应用 AppKey
|
|
|
|
|
|
- DINGTALK_APP_SECRET: 应用 AppSecret(用于签名验证)
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
from src.config import get_config
|
|
|
|
|
|
config = get_config()
|
|
|
|
|
|
|
|
|
|
|
|
self._app_key = getattr(config, 'dingtalk_app_key', None)
|
|
|
|
|
|
self._app_secret = getattr(config, 'dingtalk_app_secret', None)
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def platform_name(self) -> str:
|
|
|
|
|
|
return "dingtalk"
|
|
|
|
|
|
|
|
|
|
|
|
def verify_request(self, headers: Dict[str, str], body: bytes) -> bool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
验证钉钉请求签名
|
|
|
|
|
|
|
|
|
|
|
|
钉钉签名算法:
|
|
|
|
|
|
1. 获取 timestamp 和 sign
|
|
|
|
|
|
2. 计算:base64(hmac_sha256(timestamp + "\n" + app_secret))
|
|
|
|
|
|
3. 比对签名
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not self._app_secret:
|
|
|
|
|
|
logger.warning("[DingTalk] 未配置 app_secret,跳过签名验证")
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
timestamp = headers.get('timestamp', '')
|
|
|
|
|
|
sign = headers.get('sign', '')
|
|
|
|
|
|
|
|
|
|
|
|
if not timestamp or not sign:
|
|
|
|
|
|
logger.warning("[DingTalk] 缺少签名参数")
|
|
|
|
|
|
return True # 可能是不需要签名的请求
|
|
|
|
|
|
|
|
|
|
|
|
# 验证时间戳(1小时内有效)
|
|
|
|
|
|
try:
|
|
|
|
|
|
request_time = int(timestamp)
|
|
|
|
|
|
current_time = int(time.time() * 1000)
|
|
|
|
|
|
if abs(current_time - request_time) > 3600 * 1000:
|
|
|
|
|
|
logger.warning("[DingTalk] 时间戳过期")
|
|
|
|
|
|
return False
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
logger.warning("[DingTalk] 无效的时间戳")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# 计算签名
|
|
|
|
|
|
string_to_sign = f"{timestamp}\n{self._app_secret}"
|
|
|
|
|
|
hmac_code = hmac.new(
|
|
|
|
|
|
self._app_secret.encode('utf-8'),
|
|
|
|
|
|
string_to_sign.encode('utf-8'),
|
|
|
|
|
|
digestmod=hashlib.sha256
|
|
|
|
|
|
).digest()
|
|
|
|
|
|
expected_sign = base64.b64encode(hmac_code).decode('utf-8')
|
|
|
|
|
|
|
|
|
|
|
|
if sign != expected_sign:
|
|
|
|
|
|
logger.warning(f"[DingTalk] 签名验证失败")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
def handle_challenge(self, data: Dict[str, Any]) -> Optional[WebhookResponse]:
|
|
|
|
|
|
"""钉钉不需要 URL 验证"""
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def parse_message(self, data: Dict[str, Any]) -> Optional[BotMessage]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
解析钉钉消息
|
|
|
|
|
|
|
|
|
|
|
|
钉钉 Outgoing 机器人消息格式:
|
|
|
|
|
|
{
|
|
|
|
|
|
"msgtype": "text",
|
|
|
|
|
|
"text": {
|
|
|
|
|
|
"content": "@机器人 /analyze 600519"
|
|
|
|
|
|
},
|
|
|
|
|
|
"msgId": "xxx",
|
|
|
|
|
|
"createAt": "1234567890",
|
|
|
|
|
|
"conversationType": "2", # 1=单聊, 2=群聊
|
|
|
|
|
|
"conversationId": "xxx",
|
|
|
|
|
|
"conversationTitle": "群名",
|
|
|
|
|
|
"senderId": "xxx",
|
|
|
|
|
|
"senderNick": "用户昵称",
|
|
|
|
|
|
"senderCorpId": "xxx",
|
|
|
|
|
|
"senderStaffId": "xxx",
|
|
|
|
|
|
"chatbotUserId": "xxx",
|
|
|
|
|
|
"atUsers": [{"dingtalkId": "xxx", "staffId": "xxx"}],
|
|
|
|
|
|
"isAdmin": false,
|
|
|
|
|
|
"sessionWebhook": "https://oapi.dingtalk.com/robot/sendBySession?session=xxx",
|
|
|
|
|
|
"sessionWebhookExpiredTime": 1234567890
|
|
|
|
|
|
}
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 检查消息类型
|
|
|
|
|
|
msg_type = data.get('msgtype', '')
|
|
|
|
|
|
if msg_type != 'text':
|
|
|
|
|
|
logger.debug(f"[DingTalk] 忽略非文本消息: {msg_type}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# 获取消息内容
|
|
|
|
|
|
text_content = data.get('text', {})
|
|
|
|
|
|
raw_content = text_content.get('content', '')
|
|
|
|
|
|
|
|
|
|
|
|
# 提取命令(去除 @机器人)
|
|
|
|
|
|
content = self._extract_command(raw_content)
|
|
|
|
|
|
|
|
|
|
|
|
# 检查是否 @了机器人
|
|
|
|
|
|
at_users = data.get('atUsers', [])
|
|
|
|
|
|
mentioned = len(at_users) > 0
|
|
|
|
|
|
|
|
|
|
|
|
# 会话类型
|
|
|
|
|
|
conversation_type = data.get('conversationType', '')
|
|
|
|
|
|
if conversation_type == '1':
|
|
|
|
|
|
chat_type = ChatType.PRIVATE
|
|
|
|
|
|
elif conversation_type == '2':
|
|
|
|
|
|
chat_type = ChatType.GROUP
|
|
|
|
|
|
else:
|
|
|
|
|
|
chat_type = ChatType.UNKNOWN
|
|
|
|
|
|
|
|
|
|
|
|
# 创建时间
|
|
|
|
|
|
create_at = data.get('createAt', '')
|
|
|
|
|
|
try:
|
|
|
|
|
|
timestamp = datetime.fromtimestamp(int(create_at) / 1000)
|
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
|
timestamp = datetime.now()
|
|
|
|
|
|
|
|
|
|
|
|
# 保存 session webhook 用于回复
|
|
|
|
|
|
session_webhook = data.get('sessionWebhook', '')
|
|
|
|
|
|
|
|
|
|
|
|
return BotMessage(
|
|
|
|
|
|
platform=self.platform_name,
|
|
|
|
|
|
message_id=data.get('msgId', ''),
|
|
|
|
|
|
user_id=data.get('senderId', ''),
|
|
|
|
|
|
user_name=data.get('senderNick', ''),
|
|
|
|
|
|
chat_id=data.get('conversationId', ''),
|
|
|
|
|
|
chat_type=chat_type,
|
|
|
|
|
|
content=content,
|
|
|
|
|
|
raw_content=raw_content,
|
|
|
|
|
|
mentioned=mentioned,
|
|
|
|
|
|
mentions=[u.get('dingtalkId', '') for u in at_users],
|
|
|
|
|
|
timestamp=timestamp,
|
|
|
|
|
|
raw_data={
|
|
|
|
|
|
**data,
|
|
|
|
|
|
'_session_webhook': session_webhook,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_command(self, text: str) -> str:
|
|
|
|
|
|
"""
|
|
|
|
|
|
提取命令内容(去除 @机器人)
|
|
|
|
|
|
|
|
|
|
|
|
钉钉的 @用户 格式通常是 @昵称 后跟空格
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 简单处理:移除开头的 @xxx 部分
|
|
|
|
|
|
import re
|
|
|
|
|
|
# 匹配开头的 @xxx(中英文都可能)
|
|
|
|
|
|
text = re.sub(r'^@[\S]+\s*', '', text.strip())
|
|
|
|
|
|
return text.strip()
|
|
|
|
|
|
|
|
|
|
|
|
def format_response(
|
|
|
|
|
|
self,
|
|
|
|
|
|
response: BotResponse,
|
|
|
|
|
|
message: BotMessage
|
|
|
|
|
|
) -> WebhookResponse:
|
|
|
|
|
|
"""
|
|
|
|
|
|
格式化钉钉响应
|
|
|
|
|
|
|
|
|
|
|
|
钉钉 Outgoing 机器人可以直接在响应中返回消息。
|
|
|
|
|
|
也可以使用 sessionWebhook 异步发送。
|
|
|
|
|
|
|
|
|
|
|
|
响应格式:
|
|
|
|
|
|
{
|
|
|
|
|
|
"msgtype": "text" | "markdown",
|
|
|
|
|
|
"text": {"content": "xxx"},
|
|
|
|
|
|
"markdown": {"title": "xxx", "text": "xxx"},
|
|
|
|
|
|
"at": {"atUserIds": ["xxx"], "isAtAll": false}
|
|
|
|
|
|
}
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not response.text:
|
|
|
|
|
|
return WebhookResponse.success()
|
|
|
|
|
|
|
|
|
|
|
|
# 构建响应
|
|
|
|
|
|
if response.markdown:
|
|
|
|
|
|
body = {
|
|
|
|
|
|
"msgtype": "markdown",
|
|
|
|
|
|
"markdown": {
|
|
|
|
|
|
"title": "股票分析助手",
|
|
|
|
|
|
"text": response.text,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
else:
|
|
|
|
|
|
body = {
|
|
|
|
|
|
"msgtype": "text",
|
|
|
|
|
|
"text": {
|
|
|
|
|
|
"content": response.text,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# @发送者
|
|
|
|
|
|
if response.at_user and message.user_id:
|
|
|
|
|
|
body["at"] = {
|
|
|
|
|
|
"atUserIds": [message.user_id],
|
|
|
|
|
|
"isAtAll": False,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return WebhookResponse.success(body)
|
|
|
|
|
|
|
|
|
|
|
|
def send_by_session_webhook(
|
|
|
|
|
|
self,
|
|
|
|
|
|
session_webhook: str,
|
|
|
|
|
|
response: BotResponse,
|
|
|
|
|
|
message: BotMessage
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
通过 sessionWebhook 发送消息
|
|
|
|
|
|
|
|
|
|
|
|
适用于需要异步发送或多条消息的场景。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
session_webhook: 钉钉提供的会话 Webhook URL
|
|
|
|
|
|
response: 响应对象
|
|
|
|
|
|
message: 原始消息对象
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
是否发送成功
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not session_webhook:
|
|
|
|
|
|
logger.warning("[DingTalk] 没有可用的 sessionWebhook")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
import requests
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 构建消息
|
|
|
|
|
|
if response.markdown:
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"msgtype": "markdown",
|
|
|
|
|
|
"markdown": {
|
|
|
|
|
|
"title": "股票分析助手",
|
|
|
|
|
|
"text": response.text,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
else:
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"msgtype": "text",
|
|
|
|
|
|
"text": {
|
|
|
|
|
|
"content": response.text,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# @发送者
|
|
|
|
|
|
if response.at_user and message.user_id:
|
|
|
|
|
|
payload["at"] = {
|
|
|
|
|
|
"atUserIds": [message.user_id],
|
|
|
|
|
|
"isAtAll": False,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 发送请求
|
|
|
|
|
|
resp = requests.post(
|
|
|
|
|
|
session_webhook,
|
|
|
|
|
|
json=payload,
|
|
|
|
|
|
timeout=10
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if resp.status_code == 200:
|
|
|
|
|
|
result = resp.json()
|
|
|
|
|
|
if result.get('errcode') == 0:
|
|
|
|
|
|
logger.info("[DingTalk] sessionWebhook 发送成功")
|
|
|
|
|
|
return True
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(f"[DingTalk] sessionWebhook 发送失败: {result}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(f"[DingTalk] sessionWebhook 请求失败: {resp.status_code}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"[DingTalk] sessionWebhook 发送异常: {e}")
|
|
|
|
|
|
return False
|