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.
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": "<override address — defaults to login email>",
"nextcloud": {
"url": "https://cloud.example.com",
"bot_secret": "...",
"notification_room": "<room-token>",
...
"url": "...", "bot_secret": "...", "notification_room": "<token>", ...
},
"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", "<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:
"""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)