556 lines
17 KiB
Python
556 lines
17 KiB
Python
"""
|
|
企业微信通知模块
|
|
提供企业微信消息发送功能,支持文本和Markdown格式
|
|
"""
|
|
|
|
import requests
|
|
import json
|
|
import time
|
|
import hashlib
|
|
from typing import Optional, Dict, Any, List
|
|
from datetime import datetime
|
|
|
|
try:
|
|
from ..core.config_manager import get_config
|
|
from ..core.logger import get_logger
|
|
from ..core.models import Announcement
|
|
from ..core.reliability import retry_on_exception, RetryConfig, safe_execute
|
|
from ..storage.md_generator import AnnouncementMarkdownFormatter
|
|
except ImportError:
|
|
from core.config_manager import get_config
|
|
from core.logger import get_logger
|
|
from core.models import Announcement
|
|
from core.reliability import retry_on_exception, RetryConfig, safe_execute
|
|
from storage.md_generator import AnnouncementMarkdownFormatter
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
class WeChatService:
|
|
"""企业微信服务"""
|
|
|
|
def __init__(self):
|
|
self.config = get_config().wechat_app
|
|
self._access_token = None
|
|
self._token_expires_at = 0
|
|
|
|
logger.info("企业微信服务初始化完成")
|
|
|
|
def _get_access_token(self) -> Optional[str]:
|
|
"""
|
|
获取访问令牌
|
|
|
|
Returns:
|
|
Optional[str]: 访问令牌
|
|
"""
|
|
current_time = time.time()
|
|
|
|
# 检查令牌是否仍然有效
|
|
if self._access_token and current_time < self._token_expires_at:
|
|
return self._access_token
|
|
|
|
try:
|
|
# 构建请求URL
|
|
if self.config.use_proxy and hasattr(self.config, 'proxy_api_url'):
|
|
url = f"{self.config.proxy_api_url}/cgi-bin/gettoken"
|
|
else:
|
|
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
|
|
|
|
params = {
|
|
"corpid": self.config.corp_id,
|
|
"corpsecret": self.config.secret
|
|
}
|
|
|
|
logger.debug("正在获取企业微信访问令牌")
|
|
|
|
response = requests.get(url, params=params, timeout=30)
|
|
result = response.json()
|
|
|
|
if result.get("errcode") == 0:
|
|
self._access_token = result.get("access_token")
|
|
# 提前5分钟过期
|
|
expires_in = result.get("expires_in", 7200) - 300
|
|
self._token_expires_at = current_time + expires_in
|
|
|
|
logger.info("成功获取企业微信访问令牌")
|
|
return self._access_token
|
|
else:
|
|
logger.error(f"获取访问令牌失败: {result}")
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"获取访问令牌异常: {str(e)}")
|
|
return None
|
|
|
|
@retry_on_exception(RetryConfig(max_retries=3))
|
|
def send_text_message(self, content: str,
|
|
to_user: str = "@all",
|
|
to_party: str = "",
|
|
to_tag: str = "") -> bool:
|
|
"""
|
|
发送文本消息
|
|
|
|
Args:
|
|
content: 消息内容
|
|
to_user: 接收者用户ID,多个用|分隔,@all表示全体
|
|
to_party: 接收者部门ID,多个用|分隔
|
|
to_tag: 接收者标签ID,多个用|分隔
|
|
|
|
Returns:
|
|
bool: 发送是否成功
|
|
"""
|
|
try:
|
|
access_token = self._get_access_token()
|
|
if not access_token:
|
|
logger.error("无法获取访问令牌,发送失败")
|
|
return False
|
|
|
|
# 构建请求URL
|
|
if self.config.use_proxy and hasattr(self.config, 'proxy_api_url'):
|
|
url = f"{self.config.proxy_api_url}/cgi-bin/message/send"
|
|
else:
|
|
url = "https://qyapi.weixin.qq.com/cgi-bin/message/send"
|
|
|
|
params = {"access_token": access_token}
|
|
|
|
data = {
|
|
"touser": to_user,
|
|
"toparty": to_party,
|
|
"totag": to_tag,
|
|
"msgtype": "text",
|
|
"agentid": self.config.agent_id,
|
|
"text": {
|
|
"content": content
|
|
}
|
|
}
|
|
|
|
logger.debug(f"发送文本消息: {content[:100]}...")
|
|
|
|
response = requests.post(url, params=params, json=data, timeout=30)
|
|
result = response.json()
|
|
|
|
if result.get("errcode") == 0:
|
|
logger.info("文本消息发送成功")
|
|
return True
|
|
else:
|
|
logger.error(f"文本消息发送失败: {result}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"发送文本消息异常: {str(e)}")
|
|
return False
|
|
|
|
@retry_on_exception(RetryConfig(max_retries=3))
|
|
def send_markdown_message(self, content: str,
|
|
to_user: str = "@all",
|
|
to_party: str = "",
|
|
to_tag: str = "") -> bool:
|
|
"""
|
|
发送Markdown消息
|
|
|
|
Args:
|
|
content: Markdown格式的消息内容
|
|
to_user: 接收者用户ID
|
|
to_party: 接收者部门ID
|
|
to_tag: 接收者标签ID
|
|
|
|
Returns:
|
|
bool: 发送是否成功
|
|
"""
|
|
try:
|
|
access_token = self._get_access_token()
|
|
if not access_token:
|
|
logger.error("无法获取访问令牌,发送失败")
|
|
return False
|
|
|
|
# 构建请求URL
|
|
if self.config.use_proxy and hasattr(self.config, 'proxy_api_url'):
|
|
url = f"{self.config.proxy_api_url}/cgi-bin/message/send"
|
|
else:
|
|
url = "https://qyapi.weixin.qq.com/cgi-bin/message/send"
|
|
|
|
params = {"access_token": access_token}
|
|
|
|
data = {
|
|
"touser": to_user,
|
|
"toparty": to_party,
|
|
"totag": to_tag,
|
|
"msgtype": "markdown",
|
|
"agentid": self.config.agent_id,
|
|
"markdown": {
|
|
"content": content
|
|
}
|
|
}
|
|
|
|
logger.debug("发送Markdown消息")
|
|
|
|
response = requests.post(url, params=params, json=data, timeout=30)
|
|
result = response.json()
|
|
|
|
if result.get("errcode") == 0:
|
|
logger.info("Markdown消息发送成功")
|
|
return True
|
|
else:
|
|
logger.error(f"Markdown消息发送失败: {result}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"发送Markdown消息异常: {str(e)}")
|
|
return False
|
|
|
|
def send_announcement_notification(self, announcements: List[Announcement],
|
|
max_count: int = 20) -> bool:
|
|
"""
|
|
发送公告通知
|
|
|
|
Args:
|
|
announcements: 公告列表
|
|
max_count: 最大显示数量
|
|
|
|
Returns:
|
|
bool: 发送是否成功
|
|
"""
|
|
if not announcements:
|
|
logger.info("没有新公告,跳过通知")
|
|
return True
|
|
|
|
try:
|
|
# 生成通知内容
|
|
notification_content = self._generate_announcement_notification(announcements, max_count)
|
|
|
|
# 发送Markdown消息
|
|
return self.send_markdown_message(notification_content)
|
|
|
|
except Exception as e:
|
|
logger.error(f"发送公告通知失败: {str(e)}")
|
|
return False
|
|
|
|
def _generate_announcement_notification(self, announcements: List[Announcement],
|
|
max_count: int) -> str:
|
|
"""
|
|
生成公告通知内容
|
|
|
|
Args:
|
|
announcements: 公告列表
|
|
max_count: 最大显示数量
|
|
|
|
Returns:
|
|
str: Markdown格式的通知内容
|
|
"""
|
|
# 按日期分组
|
|
today_announcements = []
|
|
other_announcements = []
|
|
|
|
today = datetime.now().date()
|
|
|
|
for announcement in announcements:
|
|
if announcement.publish_date and announcement.publish_date.date() == today:
|
|
today_announcements.append(announcement)
|
|
else:
|
|
other_announcements.append(announcement)
|
|
|
|
lines = []
|
|
|
|
# 标题
|
|
total_count = len(announcements)
|
|
lines.append(f"# 🔔 广西政府采购网公告更新")
|
|
lines.append("")
|
|
lines.append(f"**发现 {total_count} 条新公告**")
|
|
lines.append("")
|
|
|
|
# 今日公告
|
|
if today_announcements:
|
|
lines.append(f"## 📅 今日公告 ({len(today_announcements)}条)")
|
|
lines.append("")
|
|
display_today = today_announcements[:max_count//2]
|
|
for announcement in display_today:
|
|
title = announcement.title
|
|
if len(title) > 40:
|
|
title = title[:40] + "..."
|
|
publish_time = announcement.publish_date.strftime("%H:%M") if announcement.publish_date else "N/A"
|
|
lines.append(f"• [{title}]({announcement.content_url}) - {publish_time}")
|
|
|
|
if len(today_announcements) > len(display_today):
|
|
lines.append(f"• ... 还有 {len(today_announcements) - len(display_today)} 条今日公告")
|
|
|
|
lines.append("")
|
|
|
|
# 其他公告
|
|
if other_announcements:
|
|
lines.append(f"## 📄 其他公告 ({len(other_announcements)}条)")
|
|
lines.append("")
|
|
remaining_slots = max_count - len(today_announcements) if today_announcements else max_count
|
|
display_other = other_announcements[:remaining_slots]
|
|
|
|
for announcement in display_other:
|
|
title = announcement.title
|
|
if len(title) > 40:
|
|
title = title[:40] + "..."
|
|
publish_date = announcement.publish_date.strftime("%m-%d") if announcement.publish_date else "N/A"
|
|
lines.append(f"• [{title}]({announcement.content_url}) - {publish_date}")
|
|
|
|
if len(other_announcements) > len(display_other):
|
|
lines.append(f"• ... 还有 {len(other_announcements) - len(display_other)} 条公告")
|
|
|
|
lines.append("")
|
|
|
|
# 统计信息
|
|
source_stats = {}
|
|
for announcement in announcements:
|
|
source = announcement.source_name
|
|
source_stats[source] = source_stats.get(source, 0) + 1
|
|
|
|
lines.append("## 📊 统计信息")
|
|
lines.append("")
|
|
for source, count in sorted(source_stats.items()):
|
|
lines.append(f"• {source}: {count}条")
|
|
|
|
lines.append("")
|
|
lines.append("---")
|
|
lines.append("")
|
|
lines.append(f"*更新时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*")
|
|
lines.append("*点击公告标题查看详情*")
|
|
|
|
return "\n".join(lines)
|
|
|
|
def send_system_notification(self, title: str, content: str,
|
|
message_type: str = "text") -> bool:
|
|
"""
|
|
发送系统通知
|
|
|
|
Args:
|
|
title: 通知标题
|
|
content: 通知内容
|
|
message_type: 消息类型 (text/markdown)
|
|
|
|
Returns:
|
|
bool: 发送是否成功
|
|
"""
|
|
try:
|
|
if message_type == "markdown":
|
|
full_content = f"# {title}\n\n{content}"
|
|
return self.send_markdown_message(full_content)
|
|
else:
|
|
full_content = f"{title}\n\n{content}"
|
|
return self.send_text_message(full_content)
|
|
|
|
except Exception as e:
|
|
logger.error(f"发送系统通知失败: {str(e)}")
|
|
return False
|
|
|
|
def send_error_notification(self, error_message: str, error_details: Optional[str] = None) -> bool:
|
|
"""
|
|
发送错误通知
|
|
|
|
Args:
|
|
error_message: 错误消息
|
|
error_details: 错误详情
|
|
|
|
Returns:
|
|
bool: 发送是否成功
|
|
"""
|
|
content = f"## ❌ 系统错误\n\n**错误信息**: {error_message}"
|
|
|
|
if error_details:
|
|
content += f"\n\n**错误详情**:\n```\n{error_details}\n```"
|
|
|
|
content += f"\n\n*发生时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*"
|
|
|
|
return self.send_markdown_message(content)
|
|
|
|
def test_connection(self) -> bool:
|
|
"""
|
|
测试连接
|
|
|
|
Returns:
|
|
bool: 连接是否正常
|
|
"""
|
|
try:
|
|
token = self._get_access_token()
|
|
return token is not None
|
|
except Exception as e:
|
|
logger.error(f"企业微信连接测试失败: {str(e)}")
|
|
return False
|
|
|
|
def get_service_status(self) -> Dict[str, Any]:
|
|
"""
|
|
获取服务状态
|
|
|
|
Returns:
|
|
Dict[str, Any]: 服务状态信息
|
|
"""
|
|
return {
|
|
"service": "wechat",
|
|
"enabled": self.config.enabled,
|
|
"corp_id": self.config.corp_id[:10] + "..." if self.config.corp_id else None,
|
|
"agent_id": self.config.agent_id,
|
|
"has_token": self._access_token is not None,
|
|
"token_expires_at": datetime.fromtimestamp(self._token_expires_at).isoformat() if self._token_expires_at > 0 else None,
|
|
"use_proxy": self.config.use_proxy,
|
|
"connection_test": self.test_connection() if self.config.enabled else False
|
|
}
|
|
|
|
|
|
class NotificationManager:
|
|
"""通知管理器"""
|
|
|
|
def __init__(self):
|
|
self.wechat = WeChatService()
|
|
self._services = {
|
|
"wechat": self.wechat
|
|
}
|
|
|
|
def send_announcement_notification(self, announcements: List[Announcement]) -> Dict[str, bool]:
|
|
"""
|
|
发送公告通知
|
|
|
|
Args:
|
|
announcements: 公告列表
|
|
|
|
Returns:
|
|
Dict[str, bool]: 各服务发送结果
|
|
"""
|
|
results = {}
|
|
|
|
# 企业微信通知
|
|
if self.wechat.config.enabled:
|
|
try:
|
|
results["wechat"] = self.wechat.send_announcement_notification(announcements)
|
|
except Exception as e:
|
|
logger.error(f"企业微信通知失败: {str(e)}")
|
|
results["wechat"] = False
|
|
else:
|
|
results["wechat"] = None # 未启用
|
|
|
|
return results
|
|
|
|
def send_system_notification(self, title: str, content: str) -> Dict[str, bool]:
|
|
"""
|
|
发送系统通知
|
|
|
|
Args:
|
|
title: 通知标题
|
|
content: 通知内容
|
|
|
|
Returns:
|
|
Dict[str, bool]: 发送结果
|
|
"""
|
|
results = {}
|
|
|
|
if self.wechat.config.enabled:
|
|
try:
|
|
results["wechat"] = self.wechat.send_system_notification(title, content, "markdown")
|
|
except Exception as e:
|
|
logger.error(f"企业微信系统通知失败: {str(e)}")
|
|
results["wechat"] = False
|
|
else:
|
|
results["wechat"] = None
|
|
|
|
return results
|
|
|
|
def send_error_notification(self, error_message: str, error_details: Optional[str] = None) -> Dict[str, bool]:
|
|
"""
|
|
发送错误通知
|
|
|
|
Args:
|
|
error_message: 错误消息
|
|
error_details: 错误详情
|
|
|
|
Returns:
|
|
Dict[str, bool]: 发送结果
|
|
"""
|
|
results = {}
|
|
|
|
if self.wechat.config.enabled:
|
|
try:
|
|
results["wechat"] = self.wechat.send_error_notification(error_message, error_details)
|
|
except Exception as e:
|
|
logger.error(f"企业微信错误通知失败: {str(e)}")
|
|
results["wechat"] = False
|
|
else:
|
|
results["wechat"] = None
|
|
|
|
return results
|
|
|
|
def get_status(self) -> Dict[str, Any]:
|
|
"""
|
|
获取通知服务状态
|
|
|
|
Returns:
|
|
Dict[str, Any]: 服务状态
|
|
"""
|
|
return {
|
|
"services": {
|
|
name: service.get_service_status() for name, service in self._services.items()
|
|
}
|
|
}
|
|
|
|
|
|
# 全局通知管理器实例
|
|
_notification_manager = None
|
|
|
|
|
|
def get_notification_manager() -> NotificationManager:
|
|
"""
|
|
获取通知管理器实例
|
|
|
|
Returns:
|
|
NotificationManager: 通知管理器实例
|
|
"""
|
|
global _notification_manager
|
|
if _notification_manager is None:
|
|
_notification_manager = NotificationManager()
|
|
return _notification_manager
|
|
|
|
|
|
def send_announcements_notification(announcements: List[Announcement]) -> bool:
|
|
"""
|
|
发送公告通知
|
|
|
|
Args:
|
|
announcements: 公告列表
|
|
|
|
Returns:
|
|
bool: 是否至少有一个服务发送成功
|
|
"""
|
|
manager = get_notification_manager()
|
|
results = manager.send_announcement_notification(announcements)
|
|
|
|
# 检查是否有服务发送成功
|
|
return any(result for result in results.values() if result is True)
|
|
|
|
|
|
def send_system_notification(title: str, content: str) -> bool:
|
|
"""
|
|
发送系统通知
|
|
|
|
Args:
|
|
title: 通知标题
|
|
content: 通知内容
|
|
|
|
Returns:
|
|
bool: 是否至少有一个服务发送成功
|
|
"""
|
|
manager = get_notification_manager()
|
|
results = manager.send_system_notification(title, content)
|
|
|
|
return any(result for result in results.values() if result is True)
|
|
|
|
|
|
def send_error_alert(error_message: str, error_details: Optional[str] = None) -> bool:
|
|
"""
|
|
发送错误警报
|
|
|
|
Args:
|
|
error_message: 错误消息
|
|
error_details: 错误详情
|
|
|
|
Returns:
|
|
bool: 是否至少有一个服务发送成功
|
|
"""
|
|
manager = get_notification_manager()
|
|
results = manager.send_error_notification(error_message, error_details)
|
|
|
|
return any(result for result in results.values() if result is True)
|