Files
Cortex-Inara/cortex/tools/notify.py
Scott Idem ddf44a2aee feat: web push notifications (VAPID)
- push_utils.py: subscription storage + send helper (auto-prunes 410 endpoints)
- routers/push.py: GET /api/push/vapid-key (public), POST/DELETE /api/push/subscribe
- sw.js: push event listener shows notification; notificationclick focuses/opens tab
- app.js: subscribe/unsubscribe flow + "Enable notifications" toggle in settings dropdown
- tools/notify.py: web_push orchestrator tool (user-level, no admin required)
- VAPID keys in .env; pywebpush added to requirements.txt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 19:38:58 -04:00

149 lines
5.6 KiB
Python

"""
Notification tools — proactively send messages to user channels.
nc_talk_send routes through notification.py → channels.json.
email_send uses the server SMTP config from .env (smtp_server, smtp_from_*).
"""
import asyncio
import json
import logging
import re
from google.genai import types
from config import settings
from persona import get_user
logger = logging.getLogger(__name__)
def _load_allowlist(username: str) -> list[str]:
"""Load the per-user email allowlist. Returns empty list if not configured."""
path = settings.home_root() / username / "email_allowlist.json"
try:
return [str(p).strip() for p in json.loads(path.read_text()) if str(p).strip()]
except FileNotFoundError:
return []
except Exception as e:
logger.warning("failed to read email_allowlist.json for %s: %s", username, e)
return []
def _email_allowed(address: str, patterns: list[str]) -> bool:
"""Return True if address matches any pattern (regex, case-insensitive full match)."""
addr = address.strip()
for pattern in patterns:
try:
if re.fullmatch(pattern, addr, re.IGNORECASE):
return True
except re.error:
logger.warning("invalid regex in email allowlist: %r", pattern)
return False
async def email_send(to: str, subject: str, body: str) -> str:
"""Send an email via the server's configured SMTP account."""
username = get_user()
allowlist = _load_allowlist(username)
if not allowlist:
return (
"Email blocked — no allowlist configured. "
f"Add allowed patterns to home/{username}/email_allowlist.json as a JSON array."
)
if not _email_allowed(to, allowlist):
return f"Email blocked — {to} does not match any allowed pattern for {username}."
from email_utils import send_email
ok = await asyncio.to_thread(
send_email,
to_email=to,
subject=subject,
body_text=body,
body_html=body.replace("\n", "<br>"),
)
if ok:
return f"Email sent to {to}."
return "Failed to send email — check SMTP configuration in .env."
async def web_push(title: str, body: str, url: str = "") -> str:
"""Send a browser push notification to the current user's registered devices."""
import push_utils
username = get_user()
result = await push_utils.send_push(username, title, body, url)
if "error" in result:
return f"Push failed: {result['error']}"
return f"Push sent to {result['sent']} device(s) for {username} (pruned {result['pruned']} stale)."
async def nc_talk_send(message: str) -> str:
"""Send a message to the user via their configured notification channel.
Channel is resolved from the user's channels.json (notification_channel key).
Falls back to Nextcloud Talk if configured. No-op if no channel is set.
"""
from notification import notify
username = get_user()
try:
await notify(username, message)
return f"Message sent to {username}'s notification channel."
except Exception as e:
logger.warning("nc_talk_send error for %s: %s", username, e)
return f"Failed to send notification: {e}"
DECLARATIONS = [
types.FunctionDeclaration(
name="web_push",
description=(
"Send a browser push notification to the current user. Works even when the "
"Cortex tab is not open. Use for completing long tasks, reminders that fire "
"in the background, or anything the user should see immediately. "
"url is optional — if set, clicking the notification opens that URL."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"title": types.Schema(type=types.Type.STRING, description="Notification title (short)"),
"body": types.Schema(type=types.Type.STRING, description="Notification body text"),
"url": types.Schema(type=types.Type.STRING, description="Optional URL to open on click"),
},
required=["title", "body"],
),
),
types.FunctionDeclaration(
name="email_send",
description=(
"Send an email from the server's configured SMTP account. Use for delivering "
"summaries, reports, reminders, or any content the user wants emailed. "
"body is plain text; newlines are preserved."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"to": types.Schema(type=types.Type.STRING, description="Recipient email address"),
"subject": types.Schema(type=types.Type.STRING, description="Email subject line"),
"body": types.Schema(type=types.Type.STRING, description="Plain-text email body"),
},
required=["to", "subject", "body"],
),
),
types.FunctionDeclaration(
name="nc_talk_send",
description=(
"Send a proactive message to the user via their configured notification channel "
"(Nextcloud Talk by default). Use this to notify the user of completed background "
"tasks, important events, or anything they should know between sessions. "
"Requires notification_channel and notification_room set in channels.json."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"message": types.Schema(type=types.Type.STRING, description="The message to send to the user"),
},
required=["message"],
),
),
]