feat: 多渠道通知支持 (企业微信/飞书/Telegram/邮件同时推送)

This commit is contained in:
zhulinsen
2026-01-11 22:37:03 +08:00
parent 5d5c095be5
commit 4df277e109
7 changed files with 718 additions and 63 deletions

View File

@@ -50,8 +50,35 @@ TAVILY_API_KEYS=your_tavily_key_here
# SerpAPI Keys支持多个逗号分隔
SERPAPI_KEYS=your_serpapi_key_here
# 企业微信机器人 Webhook URL用于推送日报
WECHAT_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your_key_here
# ===================================
# 通知渠道配置(可同时配置多个,全部推送)
# ===================================
#
# 【方式一】企业微信机器人
# 在企业微信群 -> 设置 -> 群机器人 -> 添加 -> 复制 Webhook 地址
#
# WECHAT_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your_key_here
#
# 【方式二】飞书机器人
# 在飞书群 -> 设置 -> 群机器人 -> 添加机器人 -> 自定义机器人 -> 复制 Webhook 地址
#
# FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_key_here
#
# 【方式三】Telegram 机器人(需同时配置两项)
# 1. 在 Telegram 找 @BotFather -> /newbot -> 获取 Bot Token
# 2. 获取 Chat ID发消息给 @userinfobot 或访问 https://api.telegram.org/bot<token>/getUpdates
#
# TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
# TELEGRAM_CHAT_ID=123456789
#
# 【方式四】邮件推送(只需 2 项配置SMTP 自动识别)
# 支持 QQ邮箱、163邮箱、Gmail、Outlook 等主流邮箱
# 1. 获取授权码以QQ邮箱为例设置 -> 账户 -> POP3/SMTP服务 -> 开启 -> 获取授权码
# 2. 填写下面两项即可:
#
# EMAIL_SENDER=your_email@qq.com
# EMAIL_PASSWORD=your_email_auth_code
# EMAIL_RECEIVERS=receiver@example.com # 可选,留空则发给自己
# 数据库路径
DATABASE_PATH=./data/stock_analysis.db

View File

@@ -56,8 +56,23 @@ jobs:
TAVILY_API_KEYS: ${{ secrets.TAVILY_API_KEYS }}
SERPAPI_API_KEYS: ${{ secrets.SERPAPI_API_KEYS }}
# 企业微信通知
# OpenAI 兼容 API备选
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
# 通知渠道(可同时配置多个,全部推送)
# 方式一:企业微信
WECHAT_WEBHOOK_URL: ${{ secrets.WECHAT_WEBHOOK_URL }}
# 方式二:飞书
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
# 方式三Telegram需同时配置 Bot Token 和 Chat ID
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
# 方式四邮件只需邮箱和授权码SMTP自动识别
EMAIL_SENDER: ${{ secrets.EMAIL_SENDER }}
EMAIL_PASSWORD: ${{ secrets.EMAIL_PASSWORD }}
EMAIL_RECEIVERS: ${{ secrets.EMAIL_RECEIVERS }}
# 自选股列表 (从 secrets 或使用默认值)
STOCK_LIST: ${{ secrets.STOCK_LIST || '600519' }}

View File

@@ -9,9 +9,21 @@
### 计划中
- 钉钉机器人支持
- 飞书机器人支持
- Web 管理界面
## [1.2.0] - 2026-01-11
### 新增
- 📢 多渠道推送支持
- 企业微信 Webhook
- 飞书 Webhook新增
- 邮件 SMTP新增
- 自动识别渠道类型,配置更简单
### 改进
- 统一使用 `NOTIFICATION_URL` 配置,兼容旧的 `WECHAT_WEBHOOK_URL`
- 邮件支持 Markdown 转 HTML 渲染
## [1.1.0] - 2026-01-11
### 新增

View File

@@ -5,7 +5,7 @@
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![GitHub Actions](https://img.shields.io/badge/GitHub%20Actions-Ready-2088FF?logo=github-actions&logoColor=white)](https://github.com/features/actions)
> 🤖 基于 AI 大模型的 A 股自选股智能分析系统,每日自动分析并推送「决策仪表盘」到企业微信/钉钉/飞书
> 🤖 基于 AI 大模型的 A 股自选股智能分析系统,每日自动分析并推送「决策仪表盘」到企业微信/飞书/Telegram/邮箱
![运行效果演示](./sources/2026-01-10_155341_daily_analysis.gif)
@@ -15,7 +15,7 @@
- **AI 决策仪表盘** - 一句话核心结论 + 精确买卖点位 + 检查清单
- **多维度分析** - 技术面 + 筹码分布 + 舆情情报 + 实时行情
- **大盘复盘** - 每日市场概览、板块涨跌、北向资金
- **定时推送** - 支持企业微信机器人自动推送
- **多渠道推送** - 支持企业微信、飞书、Telegram、邮件自动识别
- **零成本部署** - GitHub Actions 免费运行,无需服务器
- **💰 白嫖 Gemini API** - Google AI Studio 提供免费额度,个人使用完全够用
- **🔄 多模型支持** - 支持 OpenAI 兼容 APIDeepSeek、通义千问等作为备选
@@ -47,19 +47,38 @@
进入你 Fork 的仓库 → `Settings``Secrets and variables``Actions``New repository secret`
**AI 模型配置(二选一)**
| Secret 名称 | 说明 | 必填 |
|------------|------|:----:|
| `GEMINI_API_KEY` | [Google AI Studio](https://aistudio.google.com/) 获取免费 Key | ✅* |
| `OPENAI_API_KEY` | OpenAI 兼容 API Key支持 DeepSeek、通义千问等 | 可选 |
| `OPENAI_BASE_URL` | OpenAI 兼容 API 地址(如 `https://api.deepseek.com/v1` | 可选 |
| `OPENAI_MODEL` | 模型名称(如 `deepseek-chat` | 可选 |
> *注:`GEMINI_API_KEY` 和 `OPENAI_API_KEY` 至少配置一个
**通知渠道配置(可同时配置多个,全部推送)**
| Secret 名称 | 说明 | 必填 |
|------------|------|:----:|
| `WECHAT_WEBHOOK_URL` | 企业微信 Webhook URL | 可选 |
| `FEISHU_WEBHOOK_URL` | 飞书 Webhook URL | 可选 |
| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token@BotFather 获取) | 可选 |
| `TELEGRAM_CHAT_ID` | Telegram Chat ID | 可选 |
| `EMAIL_SENDER` | 发件人邮箱(如 `xxx@qq.com` | 可选 |
| `EMAIL_PASSWORD` | 邮箱授权码(非登录密码) | 可选 |
| `EMAIL_RECEIVERS` | 收件人邮箱(留空则发给自己) | 可选 |
> *注:至少配置一个渠道,配置多个则同时推送到所有渠道
**其他配置**
| Secret 名称 | 说明 | 必填 |
|------------|------|:----:|
| `GEMINI_API_KEY` | [Google AI Studio](https://aistudio.google.com/) 获取 | ✅* |
| `WECHAT_WEBHOOK_URL` | 企业微信机器人 Webhook | ✅ |
| `STOCK_LIST` | 自选股代码,如 `600519,300750,002594` | ✅ |
| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) 搜索 API新闻搜索 | 推荐 |
| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/) Key | 可选 |
| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/) Token | 可选 |
| `OPENAI_API_KEY` | OpenAI 兼容 API Key备选 | 可选 |
| `OPENAI_BASE_URL` | OpenAI 兼容 API 地址 | 可选 |
| `OPENAI_MODEL` | OpenAI 兼容模型名称 | 可选 |
> *注:`GEMINI_API_KEY` 和 `OPENAI_API_KEY` 至少配置一个,系统优先使用 Gemini
#### 3. 启用 Actions
@@ -208,22 +227,25 @@ daily_stock_analysis/
### 🔔 通知渠道扩展
- [x] 企业微信机器人
- [x] 飞书机器人
- [x] Telegram Bot
- [x] 邮件通知SMTP
- [ ] 钉钉机器人
- [ ] 飞书机器人
- [ ] Telegram Bot
- [ ] Discord Webhook
- [ ] Slack Webhook
- [ ] 邮件通知
- [ ] iOS/Android 推送Bark/Pushover
### 🤖 AI 模型支持
- [x] Google Gemini
- [ ] OpenAI GPT-4
- [x] Google Gemini(主力,免费额度)
- [x] OpenAI 兼容 API支持以下模型
- [x] OpenAI GPT-4/4o
- [x] DeepSeek
- [x] 通义千问
- [x] Moonshot月之暗面
- [x] 智谱 GLM
- [ ] Claude
- [ ] 通义千问
- [ ] 文心一言
- [ ] DeepSeek
- [ ] 本地模型Ollama
- [x] 本地模型Ollama
### 📊 数据源扩展
- [x] AkShare免费

View File

@@ -53,9 +53,23 @@ class Config:
tavily_api_keys: List[str] = field(default_factory=list) # Tavily API Keys
serpapi_keys: List[str] = field(default_factory=list) # SerpAPI Keys
# === 通知配置 ===
# === 通知配置(可同时配置多个,全部推送)===
# 企业微信 Webhook
wechat_webhook_url: Optional[str] = None
# 飞书 Webhook
feishu_webhook_url: Optional[str] = None
# Telegram 配置(需要同时配置 Bot Token 和 Chat ID
telegram_bot_token: Optional[str] = None # Bot Token@BotFather 获取)
telegram_chat_id: Optional[str] = None # Chat ID
# 邮件配置只需邮箱和授权码SMTP 自动识别)
email_sender: Optional[str] = None # 发件人邮箱
email_password: Optional[str] = None # 邮箱密码/授权码
email_receivers: List[str] = field(default_factory=list) # 收件人列表(留空则发给自己)
# === 数据库配置 ===
database_path: str = "./data/stock_analysis.db"
@@ -150,6 +164,12 @@ class Config:
tavily_api_keys=tavily_api_keys,
serpapi_keys=serpapi_keys,
wechat_webhook_url=os.getenv('WECHAT_WEBHOOK_URL'),
feishu_webhook_url=os.getenv('FEISHU_WEBHOOK_URL'),
telegram_bot_token=os.getenv('TELEGRAM_BOT_TOKEN'),
telegram_chat_id=os.getenv('TELEGRAM_CHAT_ID'),
email_sender=os.getenv('EMAIL_SENDER'),
email_password=os.getenv('EMAIL_PASSWORD'),
email_receivers=[r.strip() for r in os.getenv('EMAIL_RECEIVERS', '').split(',') if r.strip()],
database_path=os.getenv('DATABASE_PATH', './data/stock_analysis.db'),
log_dir=os.getenv('LOG_DIR', './logs'),
log_level=os.getenv('LOG_LEVEL', 'INFO'),
@@ -188,8 +208,15 @@ class Config:
if not self.tavily_api_keys and not self.serpapi_keys:
warnings.append("提示:未配置搜索引擎 API Key (Tavily/SerpAPI),新闻搜索功能将不可用")
if not self.wechat_webhook_url:
warnings.append("提示:未配置企业微信 Webhook将不发送推送通知")
# 检查通知配置
has_notification = (
self.wechat_webhook_url or
self.feishu_webhook_url or
(self.telegram_bot_token and self.telegram_chat_id) or
(self.email_sender and self.email_password)
)
if not has_notification:
warnings.append("提示:未配置通知渠道,将不发送推送通知")
return warnings

24
main.py
View File

@@ -574,20 +574,20 @@ class StockAnalysisPipeline:
filepath = self.notifier.save_report_to_file(report)
logger.info(f"决策仪表盘日报已保存: {filepath}")
# 推送到企业微信(使用精简版决策仪表盘)
# 推送通知
if self.notifier.is_available():
# 生成精简版决策仪表盘用于微信推送
wechat_dashboard = self.notifier.generate_wechat_dashboard(results)
logger.info(f"微信决策仪表盘长度: {len(wechat_dashboard)} 字符")
logger.debug(f"微信推送内容:\n{wechat_dashboard}")
# 生成精简版决策仪表盘用于推送
dashboard_content = self.notifier.generate_wechat_dashboard(results)
logger.info(f"决策仪表盘长度: {len(dashboard_content)} 字符")
logger.debug(f"推送内容:\n{dashboard_content}")
success = self.notifier.send_to_wechat(wechat_dashboard)
success = self.notifier.send(dashboard_content)
if success:
logger.info("决策仪表盘推送成功")
else:
logger.warning("决策仪表盘推送失败")
else:
logger.info("企业微信未配置,跳过推送")
logger.info("通知渠道未配置,跳过推送")
except Exception as e:
logger.error(f"发送通知失败: {e}")
@@ -686,14 +686,12 @@ def run_market_review(notifier: NotificationService, analyzer=None, search_servi
review_report = market_analyzer.run_daily_review()
if review_report:
# 推送到微信
# 推送通知
if notifier.is_available():
# 添加标题
wechat_report = f"## 🎯 大盘复盘\n\n{review_report}"
if len(wechat_report) > 3800:
wechat_report = wechat_report[:3800] + "\n...(已截断)"
report_content = f"🎯 大盘复盘\n\n{review_report}"
success = notifier.send_to_wechat(wechat_report)
success = notifier.send(report_content)
if success:
logger.info("大盘复盘推送成功")
else:
@@ -791,7 +789,7 @@ def main() -> int:
# 模式1: 仅大盘复盘
if args.market_review:
logger.info("模式: 仅大盘复盘")
notifier = NotificationService(config.wechat_webhook_url)
notifier = NotificationService()
# 初始化搜索服务和分析器(如果有配置)
search_service = None

View File

@@ -7,12 +7,22 @@ A股自选股智能分析系统 - 通知层
职责:
1. 汇总分析结果生成日报
2. 支持 Markdown 格式输出
3. 推送到企业微信 Webhook
3. 多渠道推送(自动识别):
- 企业微信 Webhook
- 飞书 Webhook
- Telegram Bot
- 邮件 SMTP
"""
import logging
import smtplib
import re
from datetime import datetime
from typing import List, Dict, Any, Optional
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import Header
from enum import Enum
import requests
@@ -22,31 +32,157 @@ from analyzer import AnalysisResult
logger = logging.getLogger(__name__)
class NotificationChannel(Enum):
"""通知渠道类型"""
WECHAT = "wechat" # 企业微信
FEISHU = "feishu" # 飞书
TELEGRAM = "telegram" # Telegram
EMAIL = "email" # 邮件
UNKNOWN = "unknown" # 未知
# SMTP 服务器配置(自动识别)
SMTP_CONFIGS = {
# QQ邮箱
"qq.com": {"server": "smtp.qq.com", "port": 465, "ssl": True},
# 网易邮箱
"163.com": {"server": "smtp.163.com", "port": 465, "ssl": True},
"126.com": {"server": "smtp.126.com", "port": 465, "ssl": True},
# Gmail
"gmail.com": {"server": "smtp.gmail.com", "port": 587, "ssl": False},
# Outlook
"outlook.com": {"server": "smtp-mail.outlook.com", "port": 587, "ssl": False},
"hotmail.com": {"server": "smtp-mail.outlook.com", "port": 587, "ssl": False},
"live.com": {"server": "smtp-mail.outlook.com", "port": 587, "ssl": False},
# 新浪
"sina.com": {"server": "smtp.sina.com", "port": 465, "ssl": True},
# 搜狐
"sohu.com": {"server": "smtp.sohu.com", "port": 465, "ssl": True},
# 阿里云
"aliyun.com": {"server": "smtp.aliyun.com", "port": 465, "ssl": True},
# 139邮箱
"139.com": {"server": "smtp.139.com", "port": 465, "ssl": True},
}
class ChannelDetector:
"""
渠道检测器 - 简化版
根据配置直接判断渠道类型(不再需要 URL 解析)
"""
@staticmethod
def get_channel_name(channel: NotificationChannel) -> str:
"""获取渠道中文名称"""
names = {
NotificationChannel.WECHAT: "企业微信",
NotificationChannel.FEISHU: "飞书",
NotificationChannel.TELEGRAM: "Telegram",
NotificationChannel.EMAIL: "邮件",
NotificationChannel.UNKNOWN: "未知渠道",
}
return names.get(channel, "未知渠道")
class NotificationService:
"""
通知服务
职责:
1. 生成 Markdown 格式的分析日报
2. 推送消息到企业微信机器人
2. 向所有已配置的渠道推送消息(多渠道并发)
3. 支持本地保存日报
支持的渠道:
- 企业微信 Webhook
- 飞书 Webhook
- Telegram Bot
- 邮件 SMTP
注意:所有已配置的渠道都会收到推送
"""
def __init__(self, webhook_url: Optional[str] = None):
def __init__(self):
"""
初始化通知服务
Args:
webhook_url: 企业微信 Webhook URL可选默认从配置读取
检测所有已配置的渠道,推送时会向所有渠道发送
"""
self._webhook_url = webhook_url or get_config().wechat_webhook_url
config = get_config()
if not self._webhook_url:
logger.warning("企业微信 Webhook URL 未配置,将不发送推送通知")
# 各渠道的 Webhook URL
self._wechat_url = config.wechat_webhook_url
self._feishu_url = getattr(config, 'feishu_webhook_url', None)
# Telegram 配置
self._telegram_config = {
'bot_token': getattr(config, 'telegram_bot_token', None),
'chat_id': getattr(config, 'telegram_chat_id', None),
}
# 邮件配置
self._email_config = {
'sender': config.email_sender,
'password': config.email_password,
'receivers': config.email_receivers or ([config.email_sender] if config.email_sender else []),
}
# 检测所有已配置的渠道
self._available_channels = self._detect_all_channels()
if not self._available_channels:
logger.warning("未配置有效的通知渠道,将不发送推送通知")
else:
channel_names = [ChannelDetector.get_channel_name(ch) for ch in self._available_channels]
logger.info(f"已配置 {len(self._available_channels)} 个通知渠道:{', '.join(channel_names)}")
def _detect_all_channels(self) -> List[NotificationChannel]:
"""
检测所有已配置的渠道
Returns:
已配置的渠道列表
"""
channels = []
# 企业微信
if self._wechat_url:
channels.append(NotificationChannel.WECHAT)
# 飞书
if self._feishu_url:
channels.append(NotificationChannel.FEISHU)
# Telegram
if self._is_telegram_configured():
channels.append(NotificationChannel.TELEGRAM)
# 邮件
if self._is_email_configured():
channels.append(NotificationChannel.EMAIL)
return channels
def _is_telegram_configured(self) -> bool:
"""检查 Telegram 配置是否完整"""
return bool(self._telegram_config['bot_token'] and self._telegram_config['chat_id'])
def _is_email_configured(self) -> bool:
"""检查邮件配置是否完整(只需邮箱和授权码)"""
return bool(self._email_config['sender'] and self._email_config['password'])
def is_available(self) -> bool:
"""检查通知服务是否可用"""
return bool(self._webhook_url)
"""检查通知服务是否可用(至少有一个渠道)"""
return len(self._available_channels) > 0
def get_available_channels(self) -> List[NotificationChannel]:
"""获取所有已配置的渠道"""
return self._available_channels
def get_channel_names(self) -> str:
"""获取所有已配置渠道的名称"""
return ', '.join([ChannelDetector.get_channel_name(ch) for ch in self._available_channels])
def generate_daily_report(
self,
@@ -761,7 +897,7 @@ class NotificationService:
Returns:
是否发送成功
"""
if not self.is_available():
if not self._wechat_url:
logger.warning("企业微信 Webhook 未配置,跳过推送")
return False
@@ -771,13 +907,13 @@ class NotificationService:
content = content[:3950] + "\n\n...(内容过长已截断,详见完整报告)"
try:
return self._send_single_message(content)
return self._send_wechat_message(content)
except Exception as e:
logger.error(f"发送企业微信消息失败: {e}")
return False
def _send_single_message(self, content: str) -> bool:
"""发送单条消息"""
def _send_wechat_message(self, content: str) -> bool:
"""发送企业微信消息"""
payload = {
"msgtype": "markdown",
"markdown": {
@@ -786,7 +922,7 @@ class NotificationService:
}
response = requests.post(
self._webhook_url,
self._wechat_url,
json=payload,
timeout=10
)
@@ -803,6 +939,418 @@ class NotificationService:
logger.error(f"企业微信请求失败: {response.status_code}")
return False
def send_to_feishu(self, content: str) -> bool:
"""
推送消息到飞书机器人
飞书自定义机器人 Webhook 消息格式:
{
"msg_type": "text",
"content": {
"text": "文本内容"
}
}
注意:飞书文本消息无严格长度限制,但建议控制在合理范围
Args:
content: 消息内容Markdown 会转为纯文本)
Returns:
是否发送成功
"""
if not self._feishu_url:
logger.warning("飞书 Webhook 未配置,跳过推送")
return False
try:
# 飞书自定义机器人的消息格式
# 支持 text 和 post富文本两种类型
# 使用 post 富文本可以支持更好的格式显示
# 将 Markdown 转换为飞书 post 格式
# 简化处理:使用 text 类型,保持原有格式
payload = {
"msg_type": "text",
"content": {
"text": content
}
}
logger.debug(f"飞书请求 URL: {self._feishu_url}")
logger.debug(f"飞书请求 payload: {payload}")
response = requests.post(
self._feishu_url,
json=payload,
timeout=10
)
logger.debug(f"飞书响应状态码: {response.status_code}")
logger.debug(f"飞书响应内容: {response.text}")
if response.status_code == 200:
result = response.json()
# 飞书成功响应:
# - 新版: {"code": 0, "msg": "success"}
# - 旧版: {"StatusCode": 0, "StatusMessage": "success"}
code = result.get('code') if 'code' in result else result.get('StatusCode')
if code == 0:
logger.info("飞书消息发送成功")
return True
else:
error_msg = result.get('msg') or result.get('StatusMessage', '未知错误')
error_code = result.get('code') or result.get('StatusCode', 'N/A')
logger.error(f"飞书返回错误 [code={error_code}]: {error_msg}")
logger.error(f"完整响应: {result}")
return False
else:
logger.error(f"飞书请求失败: HTTP {response.status_code}")
logger.error(f"响应内容: {response.text}")
return False
except Exception as e:
logger.error(f"发送飞书消息失败: {e}")
import traceback
logger.debug(traceback.format_exc())
return False
def send_to_email(self, content: str, subject: Optional[str] = None) -> bool:
"""
通过 SMTP 发送邮件(自动识别 SMTP 服务器)
Args:
content: 邮件内容(支持 Markdown会转换为 HTML
subject: 邮件主题(可选,默认自动生成)
Returns:
是否发送成功
"""
if not self._is_email_configured():
logger.warning("邮件配置不完整,跳过推送")
return False
sender = self._email_config['sender']
password = self._email_config['password']
receivers = self._email_config['receivers']
try:
# 生成主题
if subject is None:
date_str = datetime.now().strftime('%Y-%m-%d')
subject = f"📈 A股智能分析报告 - {date_str}"
# 将 Markdown 转换为简单 HTML
html_content = self._markdown_to_html(content)
# 构建邮件
msg = MIMEMultipart('alternative')
msg['Subject'] = Header(subject, 'utf-8')
msg['From'] = sender
msg['To'] = ', '.join(receivers)
# 添加纯文本和 HTML 两个版本
text_part = MIMEText(content, 'plain', 'utf-8')
html_part = MIMEText(html_content, 'html', 'utf-8')
msg.attach(text_part)
msg.attach(html_part)
# 自动识别 SMTP 配置
domain = sender.split('@')[-1].lower()
smtp_config = SMTP_CONFIGS.get(domain)
if smtp_config:
smtp_server = smtp_config['server']
smtp_port = smtp_config['port']
use_ssl = smtp_config['ssl']
logger.info(f"自动识别邮箱类型: {domain} -> {smtp_server}:{smtp_port}")
else:
# 未知邮箱,尝试通用配置
smtp_server = f"smtp.{domain}"
smtp_port = 465
use_ssl = True
logger.warning(f"未知邮箱类型 {domain},尝试通用配置: {smtp_server}:{smtp_port}")
# 根据配置选择连接方式
if use_ssl:
# SSL 连接(端口 465
server = smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=30)
else:
# TLS 连接(端口 587
server = smtplib.SMTP(smtp_server, smtp_port, timeout=30)
server.starttls()
server.login(sender, password)
server.send_message(msg)
server.quit()
logger.info(f"邮件发送成功,收件人: {receivers}")
return True
except smtplib.SMTPAuthenticationError:
logger.error("邮件发送失败:认证错误,请检查邮箱和授权码是否正确")
return False
except smtplib.SMTPConnectError as e:
logger.error(f"邮件发送失败:无法连接 SMTP 服务器 - {e}")
return False
except Exception as e:
logger.error(f"发送邮件失败: {e}")
return False
def _markdown_to_html(self, markdown_text: str) -> str:
"""
将 Markdown 转换为简单的 HTML
支持:标题、加粗、列表、分隔线
"""
html = markdown_text
# 转义 HTML 特殊字符
html = html.replace('&', '&amp;')
html = html.replace('<', '&lt;')
html = html.replace('>', '&gt;')
# 标题 (# ## ###)
html = re.sub(r'^### (.+)$', r'<h3>\1</h3>', html, flags=re.MULTILINE)
html = re.sub(r'^## (.+)$', r'<h2>\1</h2>', html, flags=re.MULTILINE)
html = re.sub(r'^# (.+)$', r'<h1>\1</h1>', html, flags=re.MULTILINE)
# 加粗 **text**
html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
# 斜体 *text*
html = re.sub(r'\*(.+?)\*', r'<em>\1</em>', html)
# 分隔线 ---
html = re.sub(r'^---$', r'<hr>', html, flags=re.MULTILINE)
# 列表项 - item
html = re.sub(r'^- (.+)$', r'<li>\1</li>', html, flags=re.MULTILINE)
# 引用 > text
html = re.sub(r'^&gt; (.+)$', r'<blockquote>\1</blockquote>', html, flags=re.MULTILINE)
# 换行
html = html.replace('\n', '<br>\n')
# 包装 HTML
return f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; padding: 20px; max-width: 800px; margin: 0 auto; }}
h1, h2, h3 {{ color: #333; }}
hr {{ border: none; border-top: 1px solid #ddd; margin: 20px 0; }}
blockquote {{ border-left: 4px solid #ddd; padding-left: 16px; color: #666; }}
li {{ margin: 4px 0; }}
</style>
</head>
<body>
{html}
</body>
</html>
"""
def send_to_telegram(self, content: str) -> bool:
"""
推送消息到 Telegram 机器人
Telegram Bot API 格式:
POST https://api.telegram.org/bot<token>/sendMessage
{
"chat_id": "xxx",
"text": "消息内容",
"parse_mode": "Markdown"
}
Args:
content: 消息内容Markdown 格式)
Returns:
是否发送成功
"""
if not self._is_telegram_configured():
logger.warning("Telegram 配置不完整,跳过推送")
return False
bot_token = self._telegram_config['bot_token']
chat_id = self._telegram_config['chat_id']
try:
# Telegram API 端点
api_url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
# Telegram 消息最大长度 4096 字符
max_length = 4096
if len(content) <= max_length:
# 单条消息发送
return self._send_telegram_message(api_url, chat_id, content)
else:
# 分段发送长消息
return self._send_telegram_chunked(api_url, chat_id, content, max_length)
except Exception as e:
logger.error(f"发送 Telegram 消息失败: {e}")
import traceback
logger.debug(traceback.format_exc())
return False
def _send_telegram_message(self, api_url: str, chat_id: str, text: str) -> bool:
"""发送单条 Telegram 消息"""
# 转换 Markdown 为 Telegram 支持的格式
# Telegram 的 Markdown 格式稍有不同,做简单处理
telegram_text = self._convert_to_telegram_markdown(text)
payload = {
"chat_id": chat_id,
"text": telegram_text,
"parse_mode": "Markdown",
"disable_web_page_preview": True
}
response = requests.post(api_url, json=payload, timeout=10)
if response.status_code == 200:
result = response.json()
if result.get('ok'):
logger.info("Telegram 消息发送成功")
return True
else:
error_desc = result.get('description', '未知错误')
logger.error(f"Telegram 返回错误: {error_desc}")
# 如果 Markdown 解析失败,尝试纯文本发送
if 'parse' in error_desc.lower() or 'markdown' in error_desc.lower():
logger.info("尝试使用纯文本格式重新发送...")
payload['parse_mode'] = None
payload['text'] = text # 使用原始文本
del payload['parse_mode']
response = requests.post(api_url, json=payload, timeout=10)
if response.status_code == 200 and response.json().get('ok'):
logger.info("Telegram 消息发送成功(纯文本)")
return True
return False
else:
logger.error(f"Telegram 请求失败: HTTP {response.status_code}")
logger.error(f"响应内容: {response.text}")
return False
def _send_telegram_chunked(self, api_url: str, chat_id: str, content: str, max_length: int) -> bool:
"""分段发送长 Telegram 消息"""
# 按段落分割
sections = content.split("\n---\n")
current_chunk = []
current_length = 0
all_success = True
chunk_index = 1
for section in sections:
section_length = len(section) + 5 # +5 for "\n---\n"
if current_length + section_length > max_length:
# 发送当前块
if current_chunk:
chunk_content = "\n---\n".join(current_chunk)
logger.info(f"发送 Telegram 消息块 {chunk_index}...")
if not self._send_telegram_message(api_url, chat_id, chunk_content):
all_success = False
chunk_index += 1
# 重置
current_chunk = [section]
current_length = section_length
else:
current_chunk.append(section)
current_length += section_length
# 发送最后一块
if current_chunk:
chunk_content = "\n---\n".join(current_chunk)
logger.info(f"发送 Telegram 消息块 {chunk_index}(最后)...")
if not self._send_telegram_message(api_url, chat_id, chunk_content):
all_success = False
return all_success
def _convert_to_telegram_markdown(self, text: str) -> str:
"""
将标准 Markdown 转换为 Telegram 支持的格式
Telegram Markdown 限制:
- 不支持 # 标题
- 使用 *bold* 而非 **bold**
- 使用 _italic_
"""
result = text
# 移除 # 标题标记Telegram 不支持)
result = re.sub(r'^#{1,6}\s+', '', result, flags=re.MULTILINE)
# 转换 **bold** 为 *bold*
result = re.sub(r'\*\*(.+?)\*\*', r'*\1*', result)
# 转义特殊字符Telegram Markdown 需要)
# 注意:不转义已经用于格式的 * _ `
for char in ['[', ']', '(', ')']:
result = result.replace(char, f'\\{char}')
return result
def send(self, content: str) -> bool:
"""
统一发送接口 - 向所有已配置的渠道发送
遍历所有已配置的渠道,逐一发送消息
Args:
content: 消息内容Markdown 格式)
Returns:
是否至少有一个渠道发送成功
"""
if not self.is_available():
logger.warning("通知服务不可用,跳过推送")
return False
channel_names = self.get_channel_names()
logger.info(f"正在向 {len(self._available_channels)} 个渠道发送通知:{channel_names}")
success_count = 0
fail_count = 0
for channel in self._available_channels:
channel_name = ChannelDetector.get_channel_name(channel)
try:
if channel == NotificationChannel.WECHAT:
result = self.send_to_wechat(content)
elif channel == NotificationChannel.FEISHU:
result = self.send_to_feishu(content)
elif channel == NotificationChannel.TELEGRAM:
result = self.send_to_telegram(content)
elif channel == NotificationChannel.EMAIL:
result = self.send_to_email(content)
else:
logger.warning(f"不支持的通知渠道: {channel}")
result = False
if result:
success_count += 1
else:
fail_count += 1
except Exception as e:
logger.error(f"{channel_name} 发送失败: {e}")
fail_count += 1
logger.info(f"通知发送完成:成功 {success_count} 个,失败 {fail_count}")
return success_count > 0
def _send_chunked_messages(self, content: str, max_length: int) -> bool:
"""
分段发送长消息
@@ -826,7 +1374,7 @@ class NotificationService:
if current_chunk:
chunk_content = "\n---\n".join(current_chunk)
logger.info(f"发送消息块 {chunk_index}...")
if not self._send_single_message(chunk_content):
if not self.send(chunk_content):
all_success = False
chunk_index += 1
@@ -841,7 +1389,7 @@ class NotificationService:
if current_chunk:
chunk_content = "\n---\n".join(current_chunk)
logger.info(f"发送消息块 {chunk_index}(最后)...")
if not self._send_single_message(chunk_content):
if not self.send(chunk_content):
all_success = False
return all_success
@@ -937,7 +1485,7 @@ def send_daily_report(results: List[AnalysisResult]) -> bool:
"""
发送每日报告的快捷方式
自动生成报告并推送到企业微信
自动识别渠道并推送
"""
service = get_notification_service()
@@ -947,8 +1495,8 @@ def send_daily_report(results: List[AnalysisResult]) -> bool:
# 保存到本地
service.save_report_to_file(report)
# 推送到企业微信
return service.send_to_wechat(report)
# 推送到配置的渠道(自动识别)
return service.send(report)
if __name__ == "__main__":
@@ -991,8 +1539,14 @@ if __name__ == "__main__":
service = NotificationService()
# 显示检测到的渠道
print(f"=== 通知渠道检测 ===")
print(f"当前渠道: {service.get_channel_name()}")
print(f"渠道类型: {service.get_channel()}")
print(f"服务可用: {service.is_available()}")
# 生成日报
print("=== 生成日报测试 ===")
print("\n=== 生成日报测试 ===")
report = service.generate_daily_report(test_results)
print(report)
@@ -1001,10 +1555,10 @@ if __name__ == "__main__":
filepath = service.save_report_to_file(report)
print(f"保存成功: {filepath}")
# 推送测试(仅当配置了 Webhook 时)
# 推送测试
if service.is_available():
print("\n=== 推送测试 ===")
success = service.send_to_wechat(report)
print(f"\n=== 推送测试{service.get_channel_name()}===")
success = service.send(report)
print(f"推送结果: {'成功' if success else '失败'}")
else:
print("\n企业微信 Webhook 未配置,跳过推送测试")
print("\n通知渠道未配置,跳过推送测试")