""" Notification service for sending signal alerts via Discord/Telegram. """ import logging from datetime import datetime, timedelta import httpx from sqlalchemy.orm import Session from app.models.signal import Signal from app.models.notification import ( NotificationSetting, NotificationHistory, ChannelType, NotificationStatus, ) logger = logging.getLogger(__name__) async def send_discord(webhook_url: str, message: str) -> None: """Send a message to Discord via webhook.""" async with httpx.AsyncClient(timeout=10) as client: response = await client.post( webhook_url, json={"content": message}, ) response.raise_for_status() async def send_telegram(webhook_url: str, message: str) -> None: """Send a message to Telegram via Bot API. webhook_url format: https://api.telegram.org/bot/sendMessage?chat_id= """ async with httpx.AsyncClient(timeout=10) as client: response = await client.post( webhook_url, json={"text": message, "parse_mode": "Markdown"}, ) response.raise_for_status() def format_signal_message(signal: Signal) -> str: """Format a signal into a human-readable notification message.""" signal_type_kr = { "buy": "매수", "sell": "매도", "partial_sell": "부분매도", } type_label = signal_type_kr.get(signal.signal_type.value, signal.signal_type.value) lines = [ f"[KJB 신호] {signal.name or signal.ticker} ({signal.ticker})", f"신호: {type_label}", ] if signal.entry_price: lines.append(f"진입가: {signal.entry_price:,.0f}원") if signal.target_price: lines.append(f"목표가: {signal.target_price:,.0f}원") if signal.stop_loss_price: lines.append(f"손절가: {signal.stop_loss_price:,.0f}원") if signal.reason: lines.append(f"사유: {signal.reason}") return "\n".join(lines) def _is_duplicate(db: Session, signal_id: int, channel_type: ChannelType) -> bool: """Check if a notification was already sent for this signal+channel within 24h.""" cutoff = datetime.utcnow() - timedelta(hours=24) existing = ( db.query(NotificationHistory) .filter( NotificationHistory.signal_id == signal_id, NotificationHistory.channel_type == channel_type, NotificationHistory.status == NotificationStatus.SENT, NotificationHistory.sent_at >= cutoff, ) .first() ) return existing is not None async def send_notification(signal: Signal, db: Session) -> None: """Send notification for a signal to all enabled channels. Skips duplicate notifications (same signal_id + channel_type within 24h). """ settings = ( db.query(NotificationSetting) .filter(NotificationSetting.enabled.is_(True)) .all() ) message = format_signal_message(signal) for setting in settings: if _is_duplicate(db, signal.id, setting.channel_type): logger.info( f"Skipping duplicate notification for signal {signal.id} " f"on {setting.channel_type.value}" ) continue history = NotificationHistory( signal_id=signal.id, channel_type=setting.channel_type, message=message, ) try: if setting.channel_type == ChannelType.DISCORD: await send_discord(setting.webhook_url, message) elif setting.channel_type == ChannelType.TELEGRAM: await send_telegram(setting.webhook_url, message) history.status = NotificationStatus.SENT logger.info( f"Notification sent for signal {signal.id} " f"via {setting.channel_type.value}" ) except Exception as e: history.status = NotificationStatus.FAILED history.error_message = str(e)[:500] logger.error( f"Failed to send notification for signal {signal.id} " f"via {setting.channel_type.value}: {e}" ) db.add(history) db.commit()