""" 钉钉通知服务 - 通过群机器人发送消息 """ import httpx import hmac import hashlib import base64 import json from typing import Dict, Any, Optional from datetime import datetime from urllib.parse import quote from app.utils.logger import logger from app.config import get_settings class DingTalkService: """钉钉群机器人通知服务""" def __init__(self, webhook_url: str = "", secret: str = ""): """ 初始化钉钉服务 Args: webhook_url: 钉钉群机器人 Webhook URL secret: 加签密钥 """ settings = get_settings() self.webhook_url = webhook_url or getattr(settings, 'dingtalk_webhook_url', '') self.secret = secret or getattr(settings, 'dingtalk_secret', '') # 检查配置开关和必要参数是否都有效 config_enabled = getattr(settings, 'dingtalk_enabled', True) self.enabled = config_enabled and bool(self.webhook_url) if not config_enabled: logger.info("钉钉通知已通过配置禁用") elif self.enabled: logger.info(f"钉钉通知服务初始化完成") else: logger.warning("钉钉 Webhook URL 未配置,通知功能已禁用") def _generate_sign(self, timestamp: int) -> str: """ 生成钉钉签名 Args: timestamp: 当前时间戳(毫秒) Returns: 签名字符串 """ if not self.secret: return "" secret_enc = self.secret.encode('utf-8') string_to_sign = f'{timestamp}\n{self.secret}' string_to_sign_enc = string_to_sign.encode('utf-8') hmac_code = hmac.new(secret_enc, string_to_sign_enc, digestmod=hashlib.sha256).digest() sign = base64.b64encode(hmac_code).decode('utf-8') return sign def _build_url(self) -> str: """ 构建带签名的 Webhook URL Returns: 完整的 Webhook URL """ if not self.secret: return self.webhook_url timestamp = int(datetime.now().timestamp() * 1000) sign = self._generate_sign(timestamp) sign_encoded = quote(sign, safe='') return f"{self.webhook_url}×tamp={timestamp}&sign={sign_encoded}" async def send_text(self, content: str, at_mobiles: list = None, at_user_ids: list = None) -> bool: """ 发送文本消息 Args: content: 消息内容 at_mobiles: @的手机号列表 at_user_ids: @的用户ID列表 Returns: 是否发送成功 """ if not self.enabled: logger.warning("钉钉服务未启用,跳过发送") return False data = { "msgtype": "text", "text": { "content": content } } # 添加 @ 信息 if at_mobiles or at_user_ids: data["at"] = { "atMobiles": at_mobiles or [], "atUserIds": at_user_ids or [], "isAtAll": False } return await self._send(data) async def send_markdown(self, title: str, content: str) -> bool: """ 发送 Markdown 消息 Args: title: 消息标题 content: Markdown 格式的内容 Returns: 是否发送成功 """ if not self.enabled: logger.warning("钉钉服务未启用,跳过发送") return False data = { "msgtype": "markdown", "markdown": { "title": title, "text": content } } return await self._send(data) async def send_link(self, title: str, text: str, message_url: str, pic_url: str = "") -> bool: """ 发送链接消息 Args: title: 消息标题 text: 消息内容 message_url: 点击消息跳转的 URL pic_url: 图片 URL Returns: 是否发送成功 """ if not self.enabled: logger.warning("钉钉服务未启用,跳过发送") return False data = { "msgtype": "link", "link": { "title": title, "text": text, "messageUrl": message_url } } if pic_url: data["link"]["picUrl"] = pic_url return await self._send(data) async def send_action_card(self, title: str, content: str, btn_orientation: str = "0", btn_title: str = "", btn_url: str = "") -> bool: """ 发送 ActionCard 消息(卡片消息) Args: title: 标题 content: Markdown 格式的内容 btn_orientation: 按钮排列方向,0-竖直,1-横向 btn_title: 按钮标题 btn_url: 按钮跳转链接 Returns: 是否发送成功 """ if not self.enabled: logger.warning("钉钉服务未启用,跳过发送") return False data = { "msgtype": "actionCard", "actionCard": { "title": title, "text": content, "btnOrientation": btn_orientation } } if btn_title and btn_url: data["actionCard"]["btns"] = [ { "title": btn_title, "actionURL": btn_url } ] return await self._send(data) async def send_feed_card(self, links: list) -> bool: """ 发送 FeedCard 消息(多条链接) Args: links: 链接列表,每个元素包含 title, messageURL, picURL Returns: 是否发送成功 """ if not self.enabled: logger.warning("钉钉服务未启用,跳过发送") return False data = { "msgtype": "feedCard", "feedCard": { "links": links } } return await self._send(data) async def send_trading_signal(self, signal: Dict[str, Any]) -> bool: """ 发送交易信号消息(Markdown 格式) Args: signal: 交易信号数据 Returns: 是否发送成功 """ if not self.enabled: logger.warning("钉钉服务未启用,跳过发送") return False action = signal.get('action', 'hold') symbol = signal.get('symbol', 'UNKNOWN') price = signal.get('price', 0) trend = signal.get('trend', 'neutral') confidence = signal.get('confidence', 0) agent_type = signal.get('agent_type', 'crypto') # 操作方向映射 action_map = { 'buy': '🟢 做多', 'sell': '🔴 做空', 'hold': '⏸️ 观望', 'close': '❌ 平仓' } # 市场类型映射 market_map = { 'crypto': '[加密货币]', 'stock': '[股票]' } action_text = action_map.get(action, action) market_text = market_map.get(agent_type, '') title = f"{market_text} {symbol} {action_text} 信号" content = f"""### {title} > **操作**: {action_text} > **价格**: ${price:,.2f} > **趋势**: {trend} > **信心度**: {confidence}% *信号来源: Stock Agent* """ return await self.send_markdown(title, content) async def _send(self, data: Dict[str, Any]) -> bool: """ 发送消息到钉钉 Args: data: 消息数据 Returns: 是否发送成功 """ try: url = self._build_url() async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(url, json=data) result = response.json() if result.get('errcode') == 0: logger.info(f"钉钉消息发送成功") return True else: logger.error(f"钉钉消息发送失败: {result.get('errmsg', 'Unknown error')}") return False except Exception as e: logger.error(f"钉钉消息发送异常: {e}") return False # 全局单例 _dingtalk_service: Optional[DingTalkService] = None def get_dingtalk_service() -> DingTalkService: """获取钉钉服务单例""" global _dingtalk_service if _dingtalk_service is None: _dingtalk_service = DingTalkService() return _dingtalk_service