From bce7de647cc65663f10316a1892c4a5e8968bce7 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Wed, 29 Apr 2026 22:32:22 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20proactive=20notifications=20=E2=80=94?= =?UTF-8?q?=20email,=20NC=20Talk,=20Google=20Chat=20per=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit notification.py now handles all three outbound channels. Email defaults to the user's login address (google_email from auth.json); an optional override can be set in channels.json. Google Chat uses an incoming webhook URL. NC Talk was already wired, just needs notification_room set. Settings page gains a Notifications section: channel dropdown, optional email override, NC room token, and Google Chat webhook URL. All stored in per-user channels.json. Co-Authored-By: Claude Sonnet 4.6 --- cortex/notification.py | 86 ++++++++++++++++++++++++++++++------- cortex/routers/settings.py | 64 ++++++++++++++++++++++++++- cortex/static/settings.html | 55 ++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 17 deletions(-) diff --git a/cortex/notification.py b/cortex/notification.py index eea4324..32db22d 100644 --- a/cortex/notification.py +++ b/cortex/notification.py @@ -1,22 +1,21 @@ """ Outbound notification helpers — send messages to user channels proactively. -Channel config lives in home/{user}/channels.json. -Each channel that supports proactive notifications needs a notification_channel -set to its key name (e.g. "nextcloud", "google_chat") in the user's channels.json: +Channel config lives in home/{user}/channels.json: { - "notification_channel": "nextcloud", + "notification_channel": "email" | "nextcloud" | "google_chat", + "notification_email": "", "nextcloud": { - "url": "https://cloud.example.com", - "bot_secret": "...", - "notification_room": "", - ... + "url": "...", "bot_secret": "...", "notification_room": "", ... + }, + "google_chat": { + "outbound_webhook": "https://chat.googleapis.com/v1/spaces/...", ... } } If notification_channel is absent, defaults to "nextcloud" if configured. -If notification_room (for NCT) is absent, notifications are silently skipped. """ +import asyncio import hashlib import hmac import json @@ -73,34 +72,89 @@ async def _notify_nct(nct: dict, message: str, username: str) -> None: await _send_nct_message(url, secret, room, message) +async def _notify_email(username: str, message: str, email_override: str | None = None) -> None: + """Send notification via email. Address = override → google_email from auth.json.""" + from auth_utils import _read_auth + from email_utils import send_email + + to_addr = email_override or _read_auth(username).get("google_email", "").strip() + if not to_addr: + logger.warning("notify: no email address for %s — set notification_email in channels.json", username) + return + + ok = await asyncio.to_thread( + send_email, + to_email=to_addr, + subject="Cortex", + body_text=message, + body_html=message.replace("\n", "
"), + ) + if ok: + logger.info("notify email → %s (%d chars)", to_addr, len(message)) + else: + logger.warning("notify: email send failed for %s", username) + + +async def _notify_google_chat(webhook_url: str, message: str, username: str) -> None: + """POST a message to a Google Chat space via incoming webhook.""" + body = json.dumps({"text": message}, ensure_ascii=False).encode("utf-8") + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + webhook_url, + content=body, + headers={"Content-Type": "application/json"}, + timeout=15, + ) + if resp.status_code not in (200, 201): + logger.warning("notify Google Chat %s → HTTP %d: %s", username, resp.status_code, resp.text[:200]) + else: + logger.info("notify Google Chat → %s (%d chars)", username, len(message)) + except Exception as e: + logger.error("notify Google Chat error for %s: %s", username, e) + + async def notify(username: str, message: str, channel: str | None = None) -> None: """Send a notification to the user's preferred outbound channel. Channel resolution order: 1. `channel` parameter if provided 2. `notification_channel` key in channels.json - 3. "nextcloud" if configured + 3. "nextcloud" if notification_room is configured 4. Silent no-op - To configure: set `notification_channel` in home/{user}/channels.json. - For NCT: also set `notification_room` in the nextcloud section. + Configure via home/{user}/channels.json — see module docstring. """ from auth_utils import get_user_channels channels = get_user_channels(username) target = channel or channels.get("notification_channel", "").strip() if not target: - # Auto-detect: use nextcloud if configured - if "nextcloud" in channels: + # Auto-detect: nextcloud if a notification_room is set + nct = channels.get("nextcloud", {}) + if nct.get("notification_room", "").strip(): target = "nextcloud" else: return - if target == "nextcloud": + if target == "email": + email_override = channels.get("notification_email", "").strip() or None + await _notify_email(username, message, email_override=email_override) + + elif target == "nextcloud": nct = channels.get("nextcloud") if not nct: logger.debug("notify: nextcloud not configured for %s", username) return await _notify_nct(nct, message, username) + + elif target == "google_chat": + gc = channels.get("google_chat", {}) + webhook = gc.get("outbound_webhook", "").strip() + if not webhook: + logger.debug("notify: google_chat outbound_webhook not set for %s", username) + return + await _notify_google_chat(webhook, message, username) + else: - logger.debug("notify: channel %r not yet supported for outbound (user %s)", target, username) + logger.debug("notify: channel %r not supported for outbound (user %s)", target, username) diff --git a/cortex/routers/settings.py b/cortex/routers/settings.py index a01424c..68cb033 100644 --- a/cortex/routers/settings.py +++ b/cortex/routers/settings.py @@ -18,7 +18,7 @@ import jwt from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse -from auth_utils import COOKIE_NAME, decode_token, check_credentials, set_password, _read_auth, _write_auth +from auth_utils import COOKIE_NAME, decode_token, check_credentials, set_password, _read_auth, _write_auth, get_user_channels from persona import list_user_personas from config import settings as app_settings @@ -73,6 +73,17 @@ def _settings_page(username: str, personas: list[str], back_persona: str = "", s allowlist_text = "" html = html.replace("{{ email_allowlist }}", allowlist_text) + # Notification channel settings + channels = get_user_channels(username) + notify_ch = _html.escape(channels.get("notification_channel", "") or "") + notify_email = _html.escape(channels.get("notification_email", "") or "") + nc_room = _html.escape((channels.get("nextcloud") or {}).get("notification_room", "") or "") + gc_webhook = _html.escape((channels.get("google_chat") or {}).get("outbound_webhook", "") or "") + html = html.replace("{{ notify_channel }}", notify_ch) + html = html.replace("{{ notify_email_override }}", notify_email) + html = html.replace("{{ nc_notify_room }}", nc_room) + html = html.replace("{{ gc_webhook }}", gc_webhook) + persona_items = "\n".join( f'''
  • {p} @@ -240,6 +251,57 @@ async def rename_persona( return RedirectResponse("/settings", status_code=302) +@router.post("/settings/notifications", include_in_schema=False) +async def save_notifications( + request: Request, + notification_channel: str = Form(""), + notification_email: str = Form(""), + nc_notification_room: str = Form(""), + gc_outbound_webhook: str = Form(""), +): + username = _get_session_user(request) + if not username: + return RedirectResponse("/login", status_code=302) + + personas = list_user_personas(username) + back_persona = _preferred_persona(request, username) + + channels_path = app_settings.home_root() / username / "channels.json" + try: + channels = json.loads(channels_path.read_text()) + except Exception: + channels = {} + + # Top-level notification preference + notification_channel = notification_channel.strip() + if notification_channel in ("email", "nextcloud", "google_chat"): + channels["notification_channel"] = notification_channel + else: + channels.pop("notification_channel", None) + + # Optional email address override (blank = use login email) + notification_email = notification_email.strip() + if notification_email: + channels["notification_email"] = notification_email + else: + channels.pop("notification_email", None) + + # NC Talk notification room — nested under "nextcloud" + if "nextcloud" not in channels: + channels["nextcloud"] = {} + channels["nextcloud"]["notification_room"] = nc_notification_room.strip() + + # Google Chat outbound webhook — nested under "google_chat" + if "google_chat" not in channels: + channels["google_chat"] = {} + channels["google_chat"]["outbound_webhook"] = gc_outbound_webhook.strip() + + channels_path.write_text(json.dumps(channels, indent=2) + "\n") + logger.info("notifications updated for %s (channel=%s)", username, notification_channel or "none") + return HTMLResponse(_settings_page(username, personas, back_persona, + success="Notification settings saved.")) + + @router.post("/settings/email-allowlist", include_in_schema=False) async def save_email_allowlist( request: Request, diff --git a/cortex/static/settings.html b/cortex/static/settings.html index 354a46c..98f8011 100644 --- a/cortex/static/settings.html +++ b/cortex/static/settings.html @@ -344,6 +344,55 @@ + +
    +

    Notifications

    +

    + Choose how Inara reaches out proactively — cron jobs, briefs, and future alerts. + Email defaults to your login address when no override is set. +

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    +

    Browser Cache

    @@ -411,6 +460,12 @@