Files
Synculous-2/core/notifications.py
chelsea 1cb929a776 Switch Discord notifications from webhook to user ID DMs
Uses the existing bot token to send DMs to users by their Discord user ID
instead of posting to a channel webhook.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:14:46 -06:00

160 lines
5.4 KiB
Python

"""
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