Files
nginx-portal/backend/notifier.py
qorgh529 8774bbf128
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled
feat: 알림 채널 B방식 다중 발송 적용
2026-04-27 20:46:28 +09:00

158 lines
6.6 KiB
Python
Executable File

import os
import smtplib
import httpx
import asyncio
import psycopg2
import psycopg2.extras
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime
# ── DB 설정 ───────────────────────────────────────────────
DB_CONFIG = {
"host": os.getenv("DB_HOST", "postgres-service"),
"port": int(os.getenv("DB_PORT", "5432")),
"database": os.getenv("DB_NAME", "portaldb"),
"user": os.getenv("DB_USER", "portaluser"),
"password": os.getenv("DB_PASSWORD", "portalpass"),
}
# ── 환경변수 fallback ─────────────────────────────────────
DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "")
GMAIL_USER = os.getenv("GMAIL_USER", "")
GMAIL_APP_PASSWORD = os.getenv("GMAIL_APP_PASSWORD", "")
ALERT_EMAIL_TO = os.getenv("ALERT_EMAIL_TO", "")
# ── DB에서 활성 채널 목록 조회 ────────────────────────────
def get_active_channels():
try:
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM notify_channels WHERE enabled=TRUE ORDER BY id")
rows = cur.fetchall()
conn.close()
if rows:
return [dict(r) for r in rows]
except Exception as e:
print(f"[NOTIFIER] DB channel load failed: {e}")
# fallback: 환경변수로 단일 채널 구성
if DISCORD_WEBHOOK_URL or GMAIL_USER:
return [{
"id": 0, "name": "default", "type": "both",
"discord_webhook_url": DISCORD_WEBHOOK_URL,
"gmail_user": GMAIL_USER,
"gmail_app_password": GMAIL_APP_PASSWORD,
"alert_email_to": ALERT_EMAIL_TO,
"enabled": True,
}]
return []
# ── Discord 단건 발송 ─────────────────────────────────────
async def _send_discord(webhook_url: str, title: str, message: str, color: int):
if not webhook_url:
return
payload = {
"embeds": [{
"title": title,
"description": message,
"color": color,
"footer": {"text": "Web Portal Monitor"},
"timestamp": datetime.utcnow().isoformat()
}]
}
try:
async with httpx.AsyncClient() as client:
res = await client.post(webhook_url, json=payload, timeout=10)
if res.status_code not in (200, 204):
print(f"[NOTIFIER] Discord error: {res.status_code} {res.text}")
else:
print(f"[NOTIFIER] Discord sent: {title}")
except Exception as e:
print(f"[NOTIFIER] Discord exception: {e}")
# ── Gmail 단건 발송 ───────────────────────────────────────
def _send_email(gmail_user: str, gmail_app_password: str, alert_email_to: str,
subject: str, body: str):
if not all([gmail_user, gmail_app_password, alert_email_to]):
print("[NOTIFIER] Gmail config not set, skipping")
return
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = f"[Web Portal] {subject}"
msg["From"] = gmail_user
msg["To"] = alert_email_to
html = f"""
<html><body style="font-family:sans-serif;padding:20px">
<div style="background:#e74c3c;color:white;padding:12px 20px;border-radius:8px 8px 0 0">
<h2 style="margin:0">⚠️ {subject}</h2>
</div>
<div style="border:1px solid #ddd;border-top:none;padding:20px;border-radius:0 0 8px 8px">
<p style="white-space:pre-line">{body}</p>
<hr/>
<small style="color:#999">Web Portal Monitor · {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</small>
</div>
</body></html>
"""
msg.attach(MIMEText(html, "html"))
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as smtp:
smtp.login(gmail_user, gmail_app_password)
smtp.sendmail(gmail_user, alert_email_to, msg.as_string())
print(f"[NOTIFIER] Email sent: {subject}")
except Exception as e:
print(f"[NOTIFIER] Email exception: {e}")
# ── 채널 유형별 발송 로직 ─────────────────────────────────
async def _dispatch_channel(ch: dict, title: str, message: str, color: int, mode: str):
ch_type = ch.get("type", "both")
send_discord_flag = (
mode in ("both", "discord") and
ch_type in ("both", "discord") and
ch.get("discord_webhook_url")
)
send_email_flag = (
mode in ("both", "email") and
ch_type in ("both", "email") and
ch.get("gmail_user")
)
if send_discord_flag:
await _send_discord(ch["discord_webhook_url"], title, message, color)
if send_email_flag:
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None, _send_email,
ch["gmail_user"], ch["gmail_app_password"],
ch["alert_email_to"], title, message
)
# ── 공개 알림 함수 ────────────────────────────────────────
async def notify_both(title: str, message: str, color: int = 0xe74c3c):
"""Discord + Gmail 타입 채널 모두에 발송 (Pod 이상/복구)"""
channels = get_active_channels()
if not channels:
print("[NOTIFIER] No active channels, skipping")
return
for ch in channels:
await _dispatch_channel(ch, title, message, color, mode="both")
async def notify_email_only(title: str, message: str, color: int = 0xf39c12):
"""Gmail 타입 채널에만 발송 (인증서 만료 임박)"""
channels = get_active_channels()
if not channels:
print("[NOTIFIER] No active channels, skipping")
return
for ch in channels:
await _dispatch_channel(ch, title, message, color, mode="email")
async def notify_discord_only(title: str, message: str, color: int = 0xe74c3c):
"""Discord 타입 채널에만 발송 (계정 잠금, 임시 비밀번호 발급)"""
channels = get_active_channels()
if not channels:
print("[NOTIFIER] No active channels, skipping")
return
for ch in channels:
await _dispatch_channel(ch, title, message, color, mode="discord")