""" notifications.py - Multi-channel notification routing Supported channels: Discord webhook, ntfy, Web Push """ import os import core.postgres as postgres import uuid import requests import time import json import logging logger = logging.getLogger(__name__) def _sendToEnabledChannels(notif_settings, message, user_uuid=None): """Send message to all enabled channels. Returns True if at least one succeeded.""" sent = False if notif_settings.get("discord_enabled") and notif_settings.get("discord_user_id"): if discord.send_dm(notif_settings["discord_user_id"], message): sent = True if notif_settings.get("ntfy_enabled") and notif_settings.get("ntfy_topic"): if ntfy.send(notif_settings["ntfy_topic"], message): sent = True if notif_settings.get("web_push_enabled") and user_uuid: if web_push.send_to_user(user_uuid, message): sent = True return sent def getNotificationSettings(userUUID): settings = postgres.select_one("notifications", {"user_uuid": userUUID}) if not settings: return False return settings def setNotificationSettings(userUUID, data_dict): existing = postgres.select_one("notifications", {"user_uuid": userUUID}) allowed = [ "discord_user_id", "discord_enabled", "ntfy_topic", "ntfy_enabled", "web_push_enabled", ] updates = {k: v for k, v in data_dict.items() if k in allowed} if not updates: return False if existing: postgres.update("notifications", updates, {"user_uuid": userUUID}) else: updates["id"] = str(uuid.uuid4()) updates["user_uuid"] = userUUID postgres.insert("notifications", updates) return True class discord: @staticmethod def send_dm(user_id, message): """Send a DM to a Discord user via the bot.""" bot_token = os.environ.get("DISCORD_BOT_TOKEN") if not bot_token: logger.warning("DISCORD_BOT_TOKEN not set, skipping Discord DM") return False headers = {"Authorization": f"Bot {bot_token}", "Content-Type": "application/json"} try: # Open/get DM channel with the user dm_resp = requests.post( "https://discord.com/api/v10/users/@me/channels", headers=headers, json={"recipient_id": user_id}, ) if dm_resp.status_code != 200: logger.error(f"Failed to open DM channel for user {user_id}: {dm_resp.status_code}") return False channel_id = dm_resp.json()["id"] # Send the message msg_resp = requests.post( f"https://discord.com/api/v10/channels/{channel_id}/messages", headers=headers, json={"content": message}, ) return msg_resp.status_code == 200 except Exception as e: logger.error(f"Discord DM error: {e}") return False class ntfy: @staticmethod def send(topic, message): try: response = requests.post( f"https://ntfy.sh/{topic}", data=message.encode("utf-8") ) return response.status_code == 200 except: return False class web_push: @staticmethod def send_to_user(user_uuid, message): """Send web push notification to all subscriptions for a user.""" try: from pywebpush import webpush, WebPushException vapid_private_key = os.environ.get("VAPID_PRIVATE_KEY") vapid_claims_email = os.environ.get("VAPID_CLAIMS_EMAIL", "mailto:admin@synculous.app") if not vapid_private_key: logger.warning("VAPID_PRIVATE_KEY not set, skipping web push") return False subscriptions = postgres.select("push_subscriptions", where={"user_uuid": user_uuid}) if not subscriptions: return False payload = json.dumps({"title": "Synculous", "body": message}) sent_any = False for sub in subscriptions: try: webpush( subscription_info={ "endpoint": sub["endpoint"], "keys": { "p256dh": sub["p256dh"], "auth": sub["auth"], }, }, data=payload, vapid_private_key=vapid_private_key, vapid_claims={"sub": vapid_claims_email}, ) sent_any = True except WebPushException as e: # 410 Gone or 404 means subscription expired - clean up if hasattr(e, "response") and e.response and e.response.status_code in (404, 410): postgres.delete("push_subscriptions", {"id": sub["id"]}) else: logger.error(f"Web push failed for subscription {sub['id']}: {e}") except Exception as e: logger.error(f"Web push error: {e}") return sent_any except ImportError: logger.warning("pywebpush not installed, skipping web push") return False except Exception as e: logger.error(f"Web push send_to_user error: {e}") return False