Add complete project structure and all files
This commit is contained in:
470
backend/app/services/notification_channels.py
Normal file
470
backend/app/services/notification_channels.py
Normal file
@@ -0,0 +1,470 @@
|
||||
"""
|
||||
Notification Channels - Email (SMTP) and Telegram Bot
|
||||
Settings are loaded from database (panel_settings table)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.image import MIMEImage
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
from datetime import datetime
|
||||
from loguru import logger
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
httpx = None
|
||||
|
||||
|
||||
def get_local_time() -> str:
|
||||
"""Get current local time formatted string"""
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def get_notification_setting(key: str, default: str = None) -> Optional[str]:
|
||||
"""Get notification setting from database"""
|
||||
from ..db import SessionLocal
|
||||
from ..models import PanelSettings
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
setting = db.query(PanelSettings).filter(PanelSettings.key == key).first()
|
||||
return setting.value if setting else default
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_panel_name() -> str:
|
||||
"""Get panel name from database"""
|
||||
return get_notification_setting('panel_name', 'PVEmanager') or 'PVEmanager'
|
||||
|
||||
|
||||
def get_logo_base64() -> Optional[str]:
|
||||
"""Get logo as base64 for email embedding"""
|
||||
import base64
|
||||
import os
|
||||
|
||||
logo_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'img', 'logo.png')
|
||||
|
||||
if os.path.exists(logo_path):
|
||||
try:
|
||||
with open(logo_path, 'rb') as f:
|
||||
logo_data = f.read()
|
||||
return base64.b64encode(logo_data).decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read logo: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_logo_bytes() -> Optional[bytes]:
|
||||
"""Get logo as bytes for email attachment"""
|
||||
import os
|
||||
|
||||
logo_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'img', 'logo.png')
|
||||
|
||||
if os.path.exists(logo_path):
|
||||
try:
|
||||
with open(logo_path, 'rb') as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read logo: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class EmailChannel:
|
||||
"""SMTP Email notification channel - settings from database"""
|
||||
|
||||
def __init__(self):
|
||||
self._load_settings()
|
||||
|
||||
def _load_settings(self):
|
||||
"""Load SMTP settings from database"""
|
||||
self.smtp_host = get_notification_setting('smtp_host')
|
||||
self.smtp_port = int(get_notification_setting('smtp_port', '587') or '587')
|
||||
self.smtp_user = get_notification_setting('smtp_user')
|
||||
self.smtp_password = get_notification_setting('smtp_password')
|
||||
self.smtp_from = get_notification_setting('smtp_from') or self.smtp_user
|
||||
self.smtp_tls = get_notification_setting('smtp_tls', 'true').lower() == 'true'
|
||||
self.enabled = bool(self.smtp_host and self.smtp_user and self.smtp_password)
|
||||
|
||||
def reload_settings(self):
|
||||
"""Reload settings from database"""
|
||||
self._load_settings()
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if SMTP is configured"""
|
||||
self._load_settings() # Always reload to get latest settings
|
||||
return self.enabled
|
||||
|
||||
async def send(
|
||||
self,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
html_body: Optional[str] = None,
|
||||
embedded_images: Optional[Dict[str, bytes]] = None
|
||||
) -> bool:
|
||||
"""Send email notification
|
||||
|
||||
Args:
|
||||
to_email: Recipient email
|
||||
subject: Email subject
|
||||
body: Plain text body
|
||||
html_body: HTML body (optional)
|
||||
embedded_images: Dict of {cid: image_bytes} for embedding (optional)
|
||||
"""
|
||||
if not self.is_configured():
|
||||
logger.warning("SMTP not configured, skipping email notification")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Create message - use 'related' for embedded images
|
||||
if embedded_images:
|
||||
msg = MIMEMultipart("related")
|
||||
msg_alt = MIMEMultipart("alternative")
|
||||
msg.attach(msg_alt)
|
||||
else:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg_alt = msg
|
||||
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = self.smtp_from
|
||||
msg["To"] = to_email
|
||||
|
||||
# Add plain text
|
||||
msg_alt.attach(MIMEText(body, "plain", "utf-8"))
|
||||
|
||||
# Add HTML if provided
|
||||
if html_body:
|
||||
msg_alt.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
|
||||
# Attach embedded images
|
||||
if embedded_images:
|
||||
for cid, image_data in embedded_images.items():
|
||||
img = MIMEImage(image_data)
|
||||
img.add_header('Content-ID', f'<{cid}>')
|
||||
img.add_header('Content-Disposition', 'inline', filename=f'{cid}.png')
|
||||
msg.attach(img)
|
||||
|
||||
# Send in thread pool to not block async
|
||||
# Use asyncio.to_thread for better compatibility with FastAPI
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.to_thread(self._send_sync, to_email, msg),
|
||||
timeout=15.0 # 15 second timeout for entire operation
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Email sending to {to_email} timed out after 15 seconds")
|
||||
return False
|
||||
|
||||
logger.info(f"Email notification sent to {to_email}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {to_email}: {e}")
|
||||
return False
|
||||
|
||||
def _send_sync(self, to_email: str, msg: MIMEMultipart):
|
||||
"""Synchronous email sending with timeout"""
|
||||
context = ssl.create_default_context()
|
||||
timeout = 10 # 10 seconds timeout
|
||||
|
||||
# Port 465 always uses SSL (SMTPS)
|
||||
# Port 587 uses STARTTLS (SMTP with upgrade)
|
||||
# Port 25 uses plain SMTP (or STARTTLS if smtp_tls=True)
|
||||
use_ssl = self.smtp_port == 465
|
||||
|
||||
logger.info(f"Sending email via {self.smtp_host}:{self.smtp_port} (SSL={use_ssl}, TLS={self.smtp_tls})")
|
||||
|
||||
if use_ssl:
|
||||
# SSL connection (port 465 - SMTPS)
|
||||
with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, context=context, timeout=timeout) as server:
|
||||
server.login(self.smtp_user, self.smtp_password)
|
||||
server.sendmail(self.smtp_from, to_email, msg.as_string())
|
||||
else:
|
||||
# STARTTLS connection (port 587, 25, or custom)
|
||||
with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=timeout) as server:
|
||||
if self.smtp_tls:
|
||||
server.starttls(context=context)
|
||||
server.login(self.smtp_user, self.smtp_password)
|
||||
server.sendmail(self.smtp_from, to_email, msg.as_string())
|
||||
|
||||
def format_notification(self, notification: Dict[str, Any]) -> tuple:
|
||||
"""Format notification for email - returns (subject, body, html_body, embedded_images)"""
|
||||
level_emoji = {
|
||||
"critical": "🔴",
|
||||
"warning": "🟠",
|
||||
"info": "🔵",
|
||||
"success": "🟢"
|
||||
}
|
||||
|
||||
level = notification.get("level", "info")
|
||||
emoji = level_emoji.get(level, "🔵")
|
||||
title = notification.get("title", "Notification")
|
||||
message = notification.get("message", "")
|
||||
source = notification.get("source", "system")
|
||||
created_at = notification.get("created_at", "now")
|
||||
|
||||
# Получаем имя панели
|
||||
panel_name = get_panel_name()
|
||||
|
||||
subject = f"{emoji} [{panel_name}] {title}"
|
||||
|
||||
body = f"""
|
||||
{title}
|
||||
|
||||
{message}
|
||||
|
||||
Level: {level.upper()}
|
||||
Source: {source}
|
||||
Time: {created_at}
|
||||
|
||||
---
|
||||
{panel_name}
|
||||
""".strip()
|
||||
|
||||
# Получаем логотип для встраивания
|
||||
logo_bytes = get_logo_bytes()
|
||||
embedded_images = {}
|
||||
|
||||
if logo_bytes:
|
||||
embedded_images['logo'] = logo_bytes
|
||||
logo_html = f'<img src="cid:logo" alt="{panel_name}" style="max-height: 128px;">'
|
||||
else:
|
||||
logo_html = f'<span style="font-size: 64px;">🖥️</span>'
|
||||
|
||||
html_body = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #eee; padding: 20px; margin: 0; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; background: #16213e; border-radius: 12px; overflow: hidden; }}
|
||||
.header {{ background: linear-gradient(135deg, #667eea 0%, #4f46e5 100%); padding: 24px; text-align: center; }}
|
||||
.logo {{ display: flex; align-items: center; justify-content: center; }}
|
||||
.content {{ padding: 24px; }}
|
||||
.level {{ display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: bold; margin-bottom: 16px; }}
|
||||
.level.critical {{ background: #f44336; color: white; }}
|
||||
.level.warning {{ background: #ff9800; color: white; }}
|
||||
.level.info {{ background: #2196f3; color: white; }}
|
||||
.level.success {{ background: #4caf50; color: white; }}
|
||||
.title {{ font-size: 20px; font-weight: bold; margin-bottom: 12px; color: #fff; }}
|
||||
.message {{ color: #b0b0b0; line-height: 1.6; margin-bottom: 20px; }}
|
||||
.meta {{ color: #666; font-size: 12px; }}
|
||||
.footer {{ padding: 16px 24px; background: #0f0f23; color: #666; font-size: 12px; text-align: center; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">{logo_html}</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<span class="level {level}">{level.upper()}</span>
|
||||
<div class="title">{emoji} {title}</div>
|
||||
<div class="message">{message}</div>
|
||||
<div class="meta">
|
||||
Source: {source} | Time: {created_at}
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
{panel_name} Notifications
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return subject, body, html_body, embedded_images
|
||||
|
||||
|
||||
class TelegramChannel:
|
||||
"""Telegram Bot notification channel - settings from database"""
|
||||
|
||||
def __init__(self):
|
||||
self._load_settings()
|
||||
|
||||
def _load_settings(self):
|
||||
"""Load Telegram settings from database"""
|
||||
self.bot_token = get_notification_setting('telegram_bot_token')
|
||||
self.enabled = bool(self.bot_token)
|
||||
self.api_url = f"https://api.telegram.org/bot{self.bot_token}" if self.bot_token else ""
|
||||
|
||||
def reload_settings(self):
|
||||
"""Reload settings from database"""
|
||||
self._load_settings()
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if Telegram bot is configured"""
|
||||
self._load_settings() # Always reload to get latest settings
|
||||
return self.enabled
|
||||
|
||||
async def send(self, chat_id: str, message: str, parse_mode: str = "HTML") -> bool:
|
||||
"""Send Telegram message"""
|
||||
if not self.is_configured():
|
||||
logger.warning("Telegram bot not configured, skipping notification")
|
||||
return False
|
||||
|
||||
if httpx is None:
|
||||
logger.error("httpx library not installed")
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/sendMessage",
|
||||
json={
|
||||
"chat_id": chat_id,
|
||||
"text": message,
|
||||
"parse_mode": parse_mode,
|
||||
"disable_web_page_preview": True
|
||||
},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"Telegram notification sent to {chat_id}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Telegram API error: {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Telegram message to {chat_id}: {e}")
|
||||
return False
|
||||
|
||||
async def send_photo(self, chat_id: str, photo_bytes: bytes, caption: str, parse_mode: str = "HTML") -> bool:
|
||||
"""Send Telegram message with photo"""
|
||||
if not self.is_configured():
|
||||
logger.warning("Telegram bot not configured, skipping notification")
|
||||
return False
|
||||
|
||||
if httpx is None:
|
||||
logger.error("httpx library not installed")
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
files = {'photo': ('logo.png', photo_bytes, 'image/png')}
|
||||
data = {
|
||||
'chat_id': chat_id,
|
||||
'caption': caption,
|
||||
'parse_mode': parse_mode
|
||||
}
|
||||
response = await client.post(
|
||||
f"{self.api_url}/sendPhoto",
|
||||
data=data,
|
||||
files=files,
|
||||
timeout=15.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"Telegram photo notification sent to {chat_id}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Telegram API error: {response.text}")
|
||||
# Fallback to text message
|
||||
return await self.send(chat_id, caption, parse_mode)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Telegram photo to {chat_id}: {e}")
|
||||
# Fallback to text message
|
||||
return await self.send(chat_id, caption, parse_mode)
|
||||
|
||||
async def get_bot_info(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get bot information"""
|
||||
if not self.is_configured() or httpx is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(f"{self.api_url}/getMe", timeout=10.0)
|
||||
if response.status_code == 200:
|
||||
return response.json().get("result")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get bot info: {e}")
|
||||
return None
|
||||
|
||||
async def verify_chat_id(self, chat_id: str) -> bool:
|
||||
"""Verify chat_id by sending a test message"""
|
||||
local_time = get_local_time()
|
||||
panel_name = get_panel_name()
|
||||
return await self.send(
|
||||
chat_id,
|
||||
f"✅ <b>{panel_name}</b>\n\n"
|
||||
f"Telegram notifications configured successfully!\n\n"
|
||||
f"🕐 Server time: {local_time}",
|
||||
"HTML"
|
||||
)
|
||||
|
||||
def format_notification(self, notification: Dict[str, Any]) -> Tuple[str, Optional[bytes]]:
|
||||
"""Format notification for Telegram - returns (message, logo_bytes)"""
|
||||
level_emoji = {
|
||||
"critical": "🔴",
|
||||
"warning": "🟠",
|
||||
"info": "🔵",
|
||||
"success": "🟢"
|
||||
}
|
||||
|
||||
level = notification.get("level", "info")
|
||||
emoji = level_emoji.get(level, "🔵")
|
||||
title = notification.get("title", "Notification")
|
||||
message = notification.get("message", "")
|
||||
source = notification.get("source", "system")
|
||||
created_at = notification.get("created_at", "now")
|
||||
|
||||
# Получаем имя панели
|
||||
panel_name = get_panel_name()
|
||||
|
||||
# Получаем логотип
|
||||
logo_bytes = get_logo_bytes()
|
||||
|
||||
text = f"""
|
||||
{emoji} <b>[{panel_name}] {title}</b>
|
||||
|
||||
{message}
|
||||
|
||||
<i>Level:</i> {level.upper()}
|
||||
<i>Source:</i> {source}
|
||||
<i>Time:</i> {created_at}
|
||||
""".strip()
|
||||
|
||||
return text, logo_bytes
|
||||
|
||||
|
||||
# Singleton instances - initialized lazily
|
||||
_email_channel: Optional[EmailChannel] = None
|
||||
_telegram_channel: Optional[TelegramChannel] = None
|
||||
|
||||
|
||||
def get_email_channel() -> EmailChannel:
|
||||
"""Get or create EmailChannel singleton"""
|
||||
global _email_channel
|
||||
if _email_channel is None:
|
||||
_email_channel = EmailChannel()
|
||||
else:
|
||||
_email_channel.reload_settings()
|
||||
return _email_channel
|
||||
|
||||
|
||||
def get_telegram_channel() -> TelegramChannel:
|
||||
"""Get or create TelegramChannel singleton"""
|
||||
global _telegram_channel
|
||||
if _telegram_channel is None:
|
||||
_telegram_channel = TelegramChannel()
|
||||
else:
|
||||
_telegram_channel.reload_settings()
|
||||
return _telegram_channel
|
||||
|
||||
|
||||
def reload_notification_channels():
|
||||
"""Force reload all notification channel settings"""
|
||||
global _email_channel, _telegram_channel
|
||||
if _email_channel:
|
||||
_email_channel.reload_settings()
|
||||
if _telegram_channel:
|
||||
_telegram_channel.reload_settings()
|
||||
Reference in New Issue
Block a user