521 lines
21 KiB
Python
521 lines
21 KiB
Python
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"""
|
|
<h2>股票监测提醒</h2>
|
|
<p><strong>股票代码:</strong> {notification['symbol']}</p>
|
|
<p><strong>股票名称:</strong> {notification['name']}</p>
|
|
<p><strong>提醒类型:</strong> {notification['type']}</p>
|
|
<p><strong>提醒内容:</strong> {notification['message']}</p>
|
|
<p><strong>触发时间:</strong> {notification['triggered_at']}</p>
|
|
<hr>
|
|
<p><em>此邮件由AI股票分析系统自动发送</em></p>
|
|
"""
|
|
|
|
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"""
|
|
<html>
|
|
<body>
|
|
<h2>邮件测试成功!</h2>
|
|
<p>这是一封来自AI股票分析系统的测试邮件。</p>
|
|
<p>如果您收到这封邮件,说明邮件通知功能已正常工作。</p>
|
|
<hr>
|
|
<p><strong>邮件配置信息:</strong></p>
|
|
<ul>
|
|
<li>SMTP服务器: {self.config['smtp_server']}</li>
|
|
<li>SMTP端口: {self.config['smtp_port']}</li>
|
|
<li>发送邮箱: {self.config['email_from']}</li>
|
|
<li>接收邮箱: {self.config['email_to']}</li>
|
|
</ul>
|
|
<hr>
|
|
<p><em>此邮件由AI股票分析系统自动发送</em></p>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
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() |