import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart import json import os from typing import Dict, List import streamlit as st from monitor_db import monitor_db class NotificationService: """通知服务""" def __init__(self): # 强制重新加载环境变量 from dotenv import load_dotenv load_dotenv() self.config = self._load_config() def _load_config(self) -> Dict: """加载通知配置""" config = { 'email_enabled': False, 'smtp_server': '', 'smtp_port': 587, 'email_from': '', 'email_password': '', 'email_to': '', 'webhook_enabled': False, 'webhook_url': '', 'webhook_type': 'dingtalk', # dingtalk 或 feishu 'webhook_keyword': 'aiagents通知' # 钉钉自定义关键词 } # 从环境变量加载配置 if os.getenv('EMAIL_ENABLED'): config['email_enabled'] = os.getenv('EMAIL_ENABLED').lower() == 'true' if os.getenv('SMTP_SERVER'): config['smtp_server'] = os.getenv('SMTP_SERVER') if os.getenv('SMTP_PORT'): config['smtp_port'] = int(os.getenv('SMTP_PORT')) if os.getenv('EMAIL_FROM'): config['email_from'] = os.getenv('EMAIL_FROM') if os.getenv('EMAIL_PASSWORD'): config['email_password'] = os.getenv('EMAIL_PASSWORD') if os.getenv('EMAIL_TO'): config['email_to'] = os.getenv('EMAIL_TO') if os.getenv('WEBHOOK_ENABLED'): config['webhook_enabled'] = os.getenv('WEBHOOK_ENABLED').lower() == 'true' if os.getenv('WEBHOOK_URL'): config['webhook_url'] = os.getenv('WEBHOOK_URL') if os.getenv('WEBHOOK_TYPE'): config['webhook_type'] = os.getenv('WEBHOOK_TYPE').lower() if os.getenv('WEBHOOK_KEYWORD'): config['webhook_keyword'] = os.getenv('WEBHOOK_KEYWORD') return config def send_notifications(self): """发送所有待发送的通知""" notifications = monitor_db.get_pending_notifications() if not notifications: print("没有待发送的通知") return print(f"\n{'='*50}") print(f"开始发送通知,共 {len(notifications)} 条") print(f"{'='*50}") for notification in notifications: try: print(f"\n处理通知: {notification['symbol']} - {notification['type']}") if self.send_notification(notification): monitor_db.mark_notification_sent(notification['id']) print(f"✅ 通知已成功发送并标记: {notification['message']}") else: print(f"❌ 通知发送失败: {notification['message']}") except Exception as e: print(f"❌ 发送通知时出错: {e}") import traceback traceback.print_exc() print(f"{'='*50}\n") def send_notification(self, notification: Dict) -> bool: """发送单个通知""" success = False # 尝试webhook通知 if self.config['webhook_enabled']: webhook_success = self._send_webhook_notification(notification) if webhook_success: success = True # 尝试邮件通知 if self.config['email_enabled']: email_success = self._send_email_notification(notification) if email_success: success = True # 如果两者都未启用或都失败,使用界面通知作为备用 if not success: self._show_streamlit_notification(notification) success = True return success def _send_email_notification(self, notification: Dict) -> bool: """发送邮件通知""" try: # 检查邮件配置是否完整 if not all([self.config['smtp_server'], self.config['email_from'], self.config['email_password'], self.config['email_to']]): print("⚠️ 邮件配置不完整,使用界面通知") print(f" - SMTP服务器: {self.config['smtp_server'] or '未配置'}") print(f" - 发件人: {self.config['email_from'] or '未配置'}") print(f" - 收件人: {self.config['email_to'] or '未配置'}") print(f" - 密码: {'已配置' if self.config['email_password'] else '未配置'}") self._show_streamlit_notification(notification) return True # 创建邮件 msg = MIMEMultipart() msg['From'] = self.config['email_from'] msg['To'] = self.config['email_to'] msg['Subject'] = f"股票监测提醒 - {notification['symbol']}" # 邮件正文 body = f"""
股票代码: {notification['symbol']}
股票名称: {notification['name']}
提醒类型: {notification['type']}
提醒内容: {notification['message']}
触发时间: {notification['triggered_at']}
此邮件由AI股票分析系统自动发送
""" msg.attach(MIMEText(body, 'html')) print(f"📧 正在发送邮件...") print(f" - 收件人: {self.config['email_to']}") print(f" - 主题: 股票监测提醒 - {notification['symbol']}") # 根据端口选择连接方式 if self.config['smtp_port'] == 465: print(f" - 使用 SMTP_SSL 连接 {self.config['smtp_server']}:{self.config['smtp_port']}") server = smtplib.SMTP_SSL(self.config['smtp_server'], self.config['smtp_port'], timeout=15) else: print(f" - 使用 SMTP+TLS 连接 {self.config['smtp_server']}:{self.config['smtp_port']}") server = smtplib.SMTP(self.config['smtp_server'], self.config['smtp_port'], timeout=15) server.starttls() print(f" - 正在登录...") server.login(self.config['email_from'], self.config['email_password']) print(f" - 正在发送...") server.send_message(msg) server.quit() print(f"✅ 邮件发送成功: {notification['symbol']}") return True except Exception as e: print(f"邮件发送失败: {e}") # 邮件发送失败时,使用界面通知作为备用方案 print("使用界面通知作为备用方案") self._show_streamlit_notification(notification) return True def _show_streamlit_notification(self, notification: Dict): """在Streamlit界面显示通知""" # 使用session_state存储通知 if 'notifications' not in st.session_state: st.session_state.notifications = [] # 避免重复通知,使用symbol代替stock_id notification_key = f"{notification['symbol']}_{notification['type']}_{notification['triggered_at']}" if notification_key not in [n.get('key') for n in st.session_state.notifications]: st.session_state.notifications.append({ 'key': notification_key, 'symbol': notification['symbol'], 'name': notification['name'], 'type': notification['type'], 'message': notification['message'], 'timestamp': notification['triggered_at'] }) def get_streamlit_notifications(self) -> List[Dict]: """获取Streamlit界面通知""" return st.session_state.get('notifications', []) def clear_streamlit_notifications(self): """清空Streamlit界面通知""" if 'notifications' in st.session_state: st.session_state.notifications = [] def test_email_config(self) -> bool: """测试邮件配置""" if not self.config['email_enabled']: return False try: if self.config['smtp_port'] == 465: server = smtplib.SMTP_SSL(self.config['smtp_server'], self.config['smtp_port'], timeout=10) else: server = smtplib.SMTP(self.config['smtp_server'], self.config['smtp_port'], timeout=10) server.starttls() server.login(self.config['email_from'], self.config['email_password']) server.quit() return True except Exception as e: print(f"邮件配置测试失败: {e}") return False def send_test_email(self) -> tuple[bool, str]: """发送测试邮件""" try: # 检查邮件配置是否完整 if not all([self.config['smtp_server'], self.config['email_from'], self.config['email_password'], self.config['email_to']]): return False, "邮件配置不完整,请检查.env文件中的邮件设置" # 创建测试邮件 msg = MIMEMultipart() msg['From'] = self.config['email_from'] msg['To'] = self.config['email_to'] msg['Subject'] = "AI股票分析系统 - 邮件测试" # 邮件正文 body = f"""这是一封来自AI股票分析系统的测试邮件。
如果您收到这封邮件,说明邮件通知功能已正常工作。
邮件配置信息:
此邮件由AI股票分析系统自动发送
""" msg.attach(MIMEText(body, 'html')) # 根据端口选择连接方式 if self.config['smtp_port'] == 465: server = smtplib.SMTP_SSL(self.config['smtp_server'], self.config['smtp_port'], timeout=15) else: server = smtplib.SMTP(self.config['smtp_server'], self.config['smtp_port'], timeout=15) server.starttls() server.login(self.config['email_from'], self.config['email_password']) server.send_message(msg) server.quit() return True, "测试邮件发送成功!请检查收件箱(包括垃圾邮件箱)。" except smtplib.SMTPAuthenticationError: return False, "邮箱认证失败,请检查邮箱和授权码是否正确" except smtplib.SMTPException as e: return False, f"SMTP错误: {str(e)}" except Exception as e: return False, f"发送失败: {str(e)}" def get_email_config_status(self) -> Dict: """获取邮件配置状态""" return { 'enabled': self.config['email_enabled'], 'smtp_server': self.config['smtp_server'] or '未配置', 'smtp_port': self.config['smtp_port'], 'email_from': self.config['email_from'] or '未配置', 'email_to': self.config['email_to'] or '未配置', 'configured': all([ self.config['smtp_server'], self.config['email_from'], self.config['email_password'], self.config['email_to'] ]) } def _send_webhook_notification(self, notification: Dict) -> bool: """发送Webhook通知""" try: # 检查webhook配置是否完整 if not self.config['webhook_url']: print("⚠️ Webhook URL未配置") return False webhook_type = self.config['webhook_type'] if webhook_type == 'dingtalk': return self._send_dingtalk_webhook(notification) elif webhook_type == 'feishu': return self._send_feishu_webhook(notification) else: print(f"⚠️ 不支持的webhook类型: {webhook_type}") return False except Exception as e: print(f"Webhook发送失败: {e}") return False def _send_dingtalk_webhook(self, notification: Dict) -> bool: """发送钉钉Webhook通知""" try: import requests # 构建钉钉消息格式(包含自定义关键词) keyword = self.config.get('webhook_keyword', '') title_prefix = f"{keyword} - " if keyword else "" content_prefix = f"### {keyword} - " if keyword else "### " data = { "msgtype": "markdown", "markdown": { "title": f"{title_prefix}{notification['symbol']} {notification['name']}", "text": f"""{content_prefix}股票监测提醒 **股票代码**: {notification['symbol']} **股票名称**: {notification['name']} **提醒类型**: {notification['type']} **提醒内容**: {notification['message']} **触发时间**: {notification['triggered_at']} --- _此消息由AI股票分析系统自动发送_""" } } print(f"[钉钉] 正在发送Webhook...") print(f" - URL: {self.config['webhook_url'][:50]}...") response = requests.post( self.config['webhook_url'], json=data, headers={'Content-Type': 'application/json'}, timeout=10 ) if response.status_code == 200: result = response.json() if result.get('errcode') == 0: print(f"[成功] 钉钉Webhook发送成功") return True else: print(f"[失败] 钉钉Webhook返回错误: {result.get('errmsg')}") return False else: print(f"[失败] 钉钉Webhook请求失败: HTTP {response.status_code}") return False except Exception as e: print(f"钉钉Webhook发送异常: {e}") return False def _send_feishu_webhook(self, notification: Dict) -> bool: """发送飞书Webhook通知""" try: import requests # 构建飞书消息格式 data = { "msg_type": "interactive", "card": { "header": { "title": { "content": f"📊 股票监测提醒 - {notification['symbol']}", "tag": "plain_text" }, "template": "blue" }, "elements": [ { "tag": "div", "fields": [ { "is_short": True, "text": { "content": f"**股票代码**\n{notification['symbol']}", "tag": "lark_md" } }, { "is_short": True, "text": { "content": f"**股票名称**\n{notification['name']}", "tag": "lark_md" } } ] }, { "tag": "div", "fields": [ { "is_short": True, "text": { "content": f"**提醒类型**\n{notification['type']}", "tag": "lark_md" } }, { "is_short": True, "text": { "content": f"**触发时间**\n{notification['triggered_at']}", "tag": "lark_md" } } ] }, { "tag": "div", "text": { "content": f"**提醒内容**\n{notification['message']}", "tag": "lark_md" } }, { "tag": "hr" }, { "tag": "note", "elements": [ { "tag": "plain_text", "content": "此消息由AI股票分析系统自动发送" } ] } ] } } print(f"[飞书] 正在发送Webhook...") print(f" - URL: {self.config['webhook_url'][:50]}...") response = requests.post( self.config['webhook_url'], json=data, headers={'Content-Type': 'application/json'}, timeout=10 ) if response.status_code == 200: result = response.json() if result.get('code') == 0: print(f"[成功] 飞书Webhook发送成功") return True else: print(f"[失败] 飞书Webhook返回错误: {result.get('msg')}") return False else: print(f"[失败] 飞书Webhook请求失败: HTTP {response.status_code}") return False except Exception as e: print(f"飞书Webhook发送异常: {e}") return False def send_test_webhook(self) -> tuple[bool, str]: """发送测试Webhook""" try: # 检查webhook配置是否完整 if not self.config['webhook_url']: return False, "Webhook URL未配置,请检查环境变量设置" # 创建测试通知(包含关键词"aiagents通知"以通过钉钉安全设置) test_notification = { 'symbol': '测试', 'name': 'Webhook配置测试', 'type': '系统测试', 'message': '如果您收到此消息,说明Webhook配置正确!', 'triggered_at': '刚刚' } webhook_type = self.config['webhook_type'] if webhook_type == 'dingtalk': success = self._send_dingtalk_webhook(test_notification) if success: return True, "钉钉Webhook测试成功!请检查钉钉群消息。" else: return False, "钉钉Webhook发送失败,请检查URL和网络连接" elif webhook_type == 'feishu': success = self._send_feishu_webhook(test_notification) if success: return True, "飞书Webhook测试成功!请检查飞书群消息。" else: return False, "飞书Webhook发送失败,请检查URL和网络连接" else: return False, f"不支持的webhook类型: {webhook_type}" except Exception as e: return False, f"发送失败: {str(e)}" def get_webhook_config_status(self) -> Dict: """获取Webhook配置状态""" return { 'enabled': self.config['webhook_enabled'], 'webhook_type': self.config['webhook_type'], 'webhook_url': self.config['webhook_url'][:50] + '...' if self.config['webhook_url'] else '未配置', 'configured': bool(self.config['webhook_url']) } # 全局通知服务实例 notification_service = NotificationService()