feat: proactive notifications — email, NC Talk, Google Chat per user

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 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-29 22:32:22 -04:00
parent 165cf3552d
commit bce7de647c
3 changed files with 188 additions and 17 deletions

View File

@@ -1,22 +1,21 @@
""" """
Outbound notification helpers — send messages to user channels proactively. Outbound notification helpers — send messages to user channels proactively.
Channel config lives in home/{user}/channels.json. 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:
{ {
"notification_channel": "nextcloud", "notification_channel": "email" | "nextcloud" | "google_chat",
"notification_email": "<override address — defaults to login email>",
"nextcloud": { "nextcloud": {
"url": "https://cloud.example.com", "url": "...", "bot_secret": "...", "notification_room": "<token>", ...
"bot_secret": "...", },
"notification_room": "<room-token>", "google_chat": {
... "outbound_webhook": "https://chat.googleapis.com/v1/spaces/...", ...
} }
} }
If notification_channel is absent, defaults to "nextcloud" if configured. 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 hashlib
import hmac import hmac
import json 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) 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", "<br>"),
)
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: async def notify(username: str, message: str, channel: str | None = None) -> None:
"""Send a notification to the user's preferred outbound channel. """Send a notification to the user's preferred outbound channel.
Channel resolution order: Channel resolution order:
1. `channel` parameter if provided 1. `channel` parameter if provided
2. `notification_channel` key in channels.json 2. `notification_channel` key in channels.json
3. "nextcloud" if configured 3. "nextcloud" if notification_room is configured
4. Silent no-op 4. Silent no-op
To configure: set `notification_channel` in home/{user}/channels.json. Configure via home/{user}/channels.json — see module docstring.
For NCT: also set `notification_room` in the nextcloud section.
""" """
from auth_utils import get_user_channels from auth_utils import get_user_channels
channels = get_user_channels(username) channels = get_user_channels(username)
target = channel or channels.get("notification_channel", "").strip() target = channel or channels.get("notification_channel", "").strip()
if not target: if not target:
# Auto-detect: use nextcloud if configured # Auto-detect: nextcloud if a notification_room is set
if "nextcloud" in channels: nct = channels.get("nextcloud", {})
if nct.get("notification_room", "").strip():
target = "nextcloud" target = "nextcloud"
else: else:
return 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") nct = channels.get("nextcloud")
if not nct: if not nct:
logger.debug("notify: nextcloud not configured for %s", username) logger.debug("notify: nextcloud not configured for %s", username)
return return
await _notify_nct(nct, message, username) 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: 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)

View File

@@ -18,7 +18,7 @@ import jwt
from fastapi import APIRouter, Form, Request from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse 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 persona import list_user_personas
from config import settings as app_settings 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 = "" allowlist_text = ""
html = html.replace("{{ email_allowlist }}", 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( persona_items = "\n".join(
f'''<li> f'''<li>
<a href="/{username}/{p}" class="persona-link">{p}</a> <a href="/{username}/{p}" class="persona-link">{p}</a>
@@ -240,6 +251,57 @@ async def rename_persona(
return RedirectResponse("/settings", status_code=302) 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) @router.post("/settings/email-allowlist", include_in_schema=False)
async def save_email_allowlist( async def save_email_allowlist(
request: Request, request: Request,

View File

@@ -344,6 +344,55 @@
</form> </form>
</div> </div>
<!-- Notifications -->
<div class="section">
<h2>Notifications</h2>
<p style="font-size:0.8rem; color:var(--pg-muted); margin-bottom:0.85rem; line-height:1.55;">
Choose how Inara reaches out proactively — cron jobs, briefs, and future alerts.
Email defaults to your login address when no override is set.
</p>
<form method="POST" action="/settings/notifications">
<div class="field">
<label for="notification_channel">Notification channel</label>
<select id="notification_channel" name="notification_channel"
data-value="{{ notify_channel }}"
style="width:100%; padding:0.65rem 0.85rem; background:var(--pg-bg);
border:1px solid var(--pg-border); border-radius:6px;
color:var(--pg-text); font-size:0.95rem; outline:none;
transition:border-color 0.15s;">
<option value="">None (disabled)</option>
<option value="email">Email</option>
<option value="nextcloud">Nextcloud Talk</option>
<option value="google_chat">Google Chat</option>
</select>
</div>
<div class="field">
<label for="notification_email">Email override
<span style="color:var(--pg-dim); font-weight:400;">(optional)</span>
</label>
<input type="email" id="notification_email" name="notification_email"
value="{{ notify_email_override }}"
placeholder="Leave blank to use login email"
autocomplete="off">
</div>
<div class="field">
<label for="nc_notification_room">Nextcloud Talk room token</label>
<input type="text" id="nc_notification_room" name="nc_notification_room"
value="{{ nc_notify_room }}"
placeholder="Token from the Talk room URL"
autocomplete="off" spellcheck="false">
</div>
<div class="field">
<label for="gc_outbound_webhook">Google Chat webhook URL</label>
<input type="url" id="gc_outbound_webhook" name="gc_outbound_webhook"
value="{{ gc_webhook }}"
placeholder="https://chat.googleapis.com/v1/spaces/…"
autocomplete="off" spellcheck="false">
</div>
<button type="submit">Save notification settings</button>
</form>
</div>
<!-- Browser cache --> <!-- Browser cache -->
<div class="section"> <div class="section">
<h2>Browser Cache</h2> <h2>Browser Cache</h2>
@@ -411,6 +460,12 @@
</div> </div>
<script> <script>
// Restore notification channel dropdown from injected value
(function() {
const sel = document.getElementById('notification_channel');
if (sel) sel.value = sel.dataset.value || '';
})();
// Password confirmation check // Password confirmation check
document.getElementById('password-form').addEventListener('submit', e => { document.getElementById('password-form').addEventListener('submit', e => {
const np = document.getElementById('new_password').value; const np = document.getElementById('new_password').value;