Compare commits
4 Commits
47d23a7b2f
...
c21f9a23ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c21f9a23ec | ||
|
|
19475610be | ||
|
|
3c7ecf4e4f | ||
|
|
64020ad982 |
@@ -114,6 +114,18 @@ async def _notify_google_chat(webhook_url: str, message: str, username: str) ->
|
||||
logger.error("notify Google Chat error for %s: %s", username, e)
|
||||
|
||||
|
||||
async def _notify_web_push(username: str, message: str) -> None:
|
||||
"""Send a browser push notification."""
|
||||
import push_utils
|
||||
result = await push_utils.send_push(username, "Cortex", message, "")
|
||||
if "error" in result:
|
||||
logger.warning("notify web_push error for %s: %s", username, result["error"])
|
||||
elif result.get("sent", 0) == 0:
|
||||
logger.debug("notify web_push: no subscriptions for %s", username)
|
||||
else:
|
||||
logger.info("notify web_push → %s (%d device(s))", username, result["sent"])
|
||||
|
||||
|
||||
async def notify(username: str, message: str, channel: str | None = None) -> None:
|
||||
"""Send a notification to the user's preferred outbound channel.
|
||||
|
||||
@@ -123,6 +135,7 @@ async def notify(username: str, message: str, channel: str | None = None) -> Non
|
||||
3. "nextcloud" if notification_room is configured
|
||||
4. Silent no-op
|
||||
|
||||
Supported channels: "web_push", "email", "nextcloud", "google_chat"
|
||||
Configure via home/{user}/channels.json — see module docstring.
|
||||
"""
|
||||
from auth_utils import get_user_channels
|
||||
@@ -137,7 +150,10 @@ async def notify(username: str, message: str, channel: str | None = None) -> Non
|
||||
else:
|
||||
return
|
||||
|
||||
if target == "email":
|
||||
if target == "web_push":
|
||||
await _notify_web_push(username, message)
|
||||
|
||||
elif target == "email":
|
||||
email_override = channels.get("notification_email", "").strip() or None
|
||||
await _notify_email(username, message, email_override=email_override)
|
||||
|
||||
|
||||
@@ -67,12 +67,14 @@ def _get_private_key_pem() -> str:
|
||||
def _send_one(sub: dict, payload: dict) -> bool:
|
||||
"""Send a push to a single subscription. Returns False if the endpoint is stale (410)."""
|
||||
from pywebpush import webpush, WebPushException
|
||||
from py_vapid import Vapid
|
||||
|
||||
try:
|
||||
vapid = Vapid.from_pem(_get_private_key_pem().encode())
|
||||
webpush(
|
||||
subscription_info=sub,
|
||||
data=json.dumps(payload),
|
||||
vapid_private_key=_get_private_key_pem(),
|
||||
vapid_private_key=vapid,
|
||||
vapid_claims={"sub": settings.vapid_contact},
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -58,3 +58,63 @@ async def unsubscribe(req: UnsubscribeRequest, request: Request) -> dict:
|
||||
username = _require_user(request)
|
||||
found = push_utils.remove_subscription(username, req.endpoint)
|
||||
return {"ok": True, "found": found}
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def notify_test(request: Request) -> dict:
|
||||
"""Send a test notification via the user's configured notification channel.
|
||||
|
||||
Useful for verifying channel setup (web push, NCT, email, etc.) without
|
||||
waiting for a cron job or reminder to fire naturally.
|
||||
"""
|
||||
username = _require_user(request)
|
||||
from notification import notify
|
||||
await notify(username, "Test notification from Cortex — your notification channel is working.")
|
||||
return {"ok": True, "user": username}
|
||||
|
||||
|
||||
@router.post("/reminders/check")
|
||||
async def reminder_check_now(request: Request) -> dict:
|
||||
"""Run the reminder check for the current user immediately.
|
||||
|
||||
Same logic as the daily 09:00 scheduler job, but scoped to one user
|
||||
and fired on demand. Returns how many reminders were found and whether
|
||||
a notification was sent.
|
||||
"""
|
||||
import re
|
||||
username = _require_user(request)
|
||||
|
||||
from persona import list_user_personas, set_context
|
||||
from notification import notify
|
||||
|
||||
total_sent = 0
|
||||
for persona_name in list_user_personas(username):
|
||||
set_context(username, persona_name)
|
||||
from tools.reminders import load_due_reminders
|
||||
content = load_due_reminders()
|
||||
if not content:
|
||||
continue
|
||||
|
||||
entries = []
|
||||
for line in content.splitlines():
|
||||
m = re.match(r"^\d+\.\s+(.+)", line.strip())
|
||||
if m:
|
||||
text = re.sub(r"\[(OVERDUE|due TODAY|due: \S+)\]", "", m.group(1)).strip()
|
||||
if text:
|
||||
entries.append(text)
|
||||
|
||||
if not entries:
|
||||
continue
|
||||
|
||||
count = len(entries)
|
||||
if count == 1:
|
||||
msg = f"Reminder: {entries[0]}"
|
||||
else:
|
||||
bullet_list = "\n".join(f"• {e}" for e in entries[:3])
|
||||
tail = f"\n…and {count - 3} more" if count > 3 else ""
|
||||
msg = f"{count} reminders due:\n{bullet_list}{tail}"
|
||||
|
||||
await notify(username, msg)
|
||||
total_sent += count
|
||||
|
||||
return {"ok": True, "user": username, "reminders_found": total_sent}
|
||||
|
||||
@@ -54,6 +54,26 @@ def _preferred_persona(request: Request, username: str) -> str:
|
||||
return names[0]
|
||||
|
||||
|
||||
def _notifications_page(username: str, back_persona: str = "", success: str = "", error: str = "") -> str:
|
||||
html = (_STATIC / "notifications.html").read_text()
|
||||
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)
|
||||
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
|
||||
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
|
||||
if success:
|
||||
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>')
|
||||
if error:
|
||||
html = html.replace("<!-- ERROR -->", f'<p class="error">{error}</p>')
|
||||
return html
|
||||
|
||||
|
||||
def _settings_page(username: str, personas: list[str], back_persona: str = "", success: str = "", error: str = "") -> str:
|
||||
html = (_STATIC / "settings.html").read_text()
|
||||
html = html.replace("{{ username }}", username)
|
||||
@@ -74,17 +94,6 @@ 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)
|
||||
|
||||
# Tool permission policy
|
||||
policy = get_tool_policy(username)
|
||||
tool_allow_text = _html.escape("\n".join(policy.get("allow", [])))
|
||||
@@ -261,6 +270,15 @@ async def rename_persona(
|
||||
return RedirectResponse("/settings", status_code=302)
|
||||
|
||||
|
||||
@router.get("/settings/notifications", include_in_schema=False)
|
||||
async def notifications_page(request: Request):
|
||||
username = _get_session_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
back_persona = _preferred_persona(request, username)
|
||||
return HTMLResponse(_notifications_page(username, back_persona))
|
||||
|
||||
|
||||
@router.post("/settings/notifications", include_in_schema=False)
|
||||
async def save_notifications(
|
||||
request: Request,
|
||||
@@ -273,7 +291,6 @@ async def save_notifications(
|
||||
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"
|
||||
@@ -284,7 +301,7 @@ async def save_notifications(
|
||||
|
||||
# Top-level notification preference
|
||||
notification_channel = notification_channel.strip()
|
||||
if notification_channel in ("email", "nextcloud", "google_chat"):
|
||||
if notification_channel in ("web_push", "email", "nextcloud", "google_chat"):
|
||||
channels["notification_channel"] = notification_channel
|
||||
else:
|
||||
channels.pop("notification_channel", None)
|
||||
@@ -308,8 +325,7 @@ async def save_notifications(
|
||||
|
||||
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."))
|
||||
return HTMLResponse(_notifications_page(username, back_persona, success="Notification settings saved."))
|
||||
|
||||
|
||||
@router.post("/settings/tool-policy", include_in_schema=False)
|
||||
|
||||
@@ -69,6 +69,47 @@ async def _run_long() -> None:
|
||||
logger.error("auto distill long [%s/%s] failed: %s", u, p, e)
|
||||
|
||||
|
||||
async def _run_reminder_check() -> None:
|
||||
"""Notify users of any due or overdue reminders (fires once daily at 09:00)."""
|
||||
import re
|
||||
from notification import notify
|
||||
from persona import set_context
|
||||
|
||||
for u, p in _all_personas():
|
||||
try:
|
||||
set_context(u, p)
|
||||
from tools.reminders import load_due_reminders
|
||||
content = load_due_reminders()
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Extract numbered entries (lines like "1. [label] text" or "1. text")
|
||||
entries = []
|
||||
for line in content.splitlines():
|
||||
m = re.match(r"^\d+\.\s+(.+)", line.strip())
|
||||
if m:
|
||||
# Strip status tags ([OVERDUE], [due TODAY], etc.) for display
|
||||
text = re.sub(r"\[(OVERDUE|due TODAY|due: \S+)\]", "", m.group(1)).strip()
|
||||
if text:
|
||||
entries.append(text)
|
||||
|
||||
if not entries:
|
||||
continue
|
||||
|
||||
count = len(entries)
|
||||
if count == 1:
|
||||
msg = f"Reminder: {entries[0]}"
|
||||
else:
|
||||
bullet_list = "\n".join(f"• {e}" for e in entries[:3])
|
||||
tail = f"\n…and {count - 3} more" if count > 3 else ""
|
||||
msg = f"{count} reminders due:\n{bullet_list}{tail}"
|
||||
|
||||
await notify(u, msg)
|
||||
logger.info("reminder check [%s/%s]: notified %d reminder(s)", u, p, count)
|
||||
except Exception as e:
|
||||
logger.error("reminder check [%s/%s] failed: %s", u, p, e)
|
||||
|
||||
|
||||
def get_scheduler() -> AsyncIOScheduler | None:
|
||||
"""Return the running scheduler instance (used by cron tools for live add/remove)."""
|
||||
return _scheduler
|
||||
@@ -93,6 +134,10 @@ def start() -> None:
|
||||
_scheduler.add_job(_run_long, "cron", day=1, hour=4, minute=0, id="distill_long")
|
||||
logger.info("scheduled: distill_long monthly on 1st at 04:00")
|
||||
|
||||
# Daily reminder notification check — 09:00
|
||||
_scheduler.add_job(_run_reminder_check, "cron", hour=9, minute=0, id="reminder_check")
|
||||
logger.info("scheduled: reminder_check daily at 09:00")
|
||||
|
||||
# Load user-defined cron jobs from CRONS.json
|
||||
_load_user_crons()
|
||||
|
||||
|
||||
296
cortex/static/notifications.html
Normal file
296
cortex/static/notifications.html
Normal file
@@ -0,0 +1,296 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cortex — Notifications</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
|
||||
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
|
||||
<style>
|
||||
:root {
|
||||
--pg-bg: #0f1117; --pg-surface: #1a1d27;
|
||||
--pg-border: #2d3148;
|
||||
--pg-text: #e2e8f0; --pg-muted: #94a3b8;
|
||||
--pg-dim: #64748b; --pg-dimmer: #475569;
|
||||
--pg-bright: #cbd5e1; --pg-nav-hover: rgba(255,255,255,0.05);
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--pg-bg: #f4f2fa; --pg-surface: #ffffff;
|
||||
--pg-border: #d0c8e8;
|
||||
--pg-text: #1a1228; --pg-muted: #5a5478;
|
||||
--pg-dim: #7a7290; --pg-dimmer: #9e98b0;
|
||||
--pg-bright: #1a1228; --pg-nav-hover: rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--pg-bg);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
font-weight: 450;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: var(--pg-text);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--pg-surface);
|
||||
border: 1px solid var(--pg-border);
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem 2rem;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.page-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--pg-dim);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-link:hover { color: var(--pg-bright); background: var(--pg-nav-hover); }
|
||||
.nav-link.active { color: #a78bfa; }
|
||||
.nav-spacer { flex: 1; min-width: 0.5rem; }
|
||||
.nav-link.nav-logout { color: var(--pg-dimmer); }
|
||||
.nav-link.nav-logout:hover { color: var(--pg-muted); background: none; }
|
||||
|
||||
.logo { margin-bottom: 1.75rem; }
|
||||
.logo h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; }
|
||||
.logo p { font-size: 0.8rem; color: var(--pg-muted); margin-top: 0.2rem; }
|
||||
|
||||
h2 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--pg-muted);
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid var(--pg-border);
|
||||
}
|
||||
|
||||
.section { margin-bottom: 2rem; }
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--pg-muted);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
input, select {
|
||||
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;
|
||||
}
|
||||
input:focus, select:focus { border-color: #7c3aed; }
|
||||
|
||||
.field { margin-bottom: 1rem; }
|
||||
|
||||
button[type="submit"] {
|
||||
width: 100%;
|
||||
padding: 0.7rem;
|
||||
margin-top: 0.25rem;
|
||||
background: #7c3aed;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button[type="submit"]:hover { background: #6d28d9; }
|
||||
|
||||
.error { color: #f87171; font-size: 0.85rem; text-align: center; margin-bottom: 1rem; }
|
||||
.success { color: #4ade80; font-size: 0.85rem; text-align: center; margin-bottom: 1rem; }
|
||||
|
||||
.btn-row { display: flex; gap: 0.6rem; margin-top: 0.5rem; }
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--pg-border);
|
||||
border-radius: 6px;
|
||||
background: var(--pg-bg);
|
||||
color: var(--pg-text);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
text-align: center;
|
||||
}
|
||||
.btn:hover { border-color: #7c3aed; color: #a78bfa; }
|
||||
.btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.test-result {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
display: none;
|
||||
}
|
||||
.test-result.ok { background: rgba(74, 222, 128, 0.1); color: #4ade80; border: 1px solid rgba(74, 222, 128, 0.25); }
|
||||
.test-result.err { background: rgba(248, 113, 113, 0.1); color: #f87171; border: 1px solid rgba(248, 113, 113, 0.25); }
|
||||
.hint { font-size: 0.78rem; color: var(--pg-dim); margin-top: 0.35rem; line-height: 1.5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<nav class="page-nav">
|
||||
<a href="{{ back_href }}" class="nav-link">← Chat</a>
|
||||
<a href="{{ help_href }}" class="nav-link">Help</a>
|
||||
<a href="/settings" class="nav-link">Settings</a>
|
||||
<a href="/settings/notifications" class="nav-link active">Notifications</a>
|
||||
<span class="nav-spacer"></span>
|
||||
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
||||
</nav>
|
||||
|
||||
<div class="logo">
|
||||
<h1>Notifications</h1>
|
||||
<p>How Inara reaches out proactively — reminders, cron jobs, and memory digests.</p>
|
||||
</div>
|
||||
|
||||
<!-- SUCCESS -->
|
||||
<!-- ERROR -->
|
||||
|
||||
<!-- Channel config -->
|
||||
<div class="section">
|
||||
<h2>Channel</h2>
|
||||
<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 }}">
|
||||
<option value="">None (disabled)</option>
|
||||
<option value="web_push">Browser Push Notification</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>
|
||||
|
||||
<!-- Test -->
|
||||
<div class="section">
|
||||
<h2>Test</h2>
|
||||
<p class="hint" style="margin-bottom:0.85rem">
|
||||
Fire a notification via your configured channel or run the reminder check
|
||||
immediately — no need to wait for the daily 09:00 scheduler job.
|
||||
</p>
|
||||
<div class="btn-row">
|
||||
<button class="btn" id="btn-test-notify">Send Test Notification</button>
|
||||
<button class="btn" id="btn-check-reminders">Check Reminders Now</button>
|
||||
</div>
|
||||
<div class="test-result" id="test-result"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set channel select to saved value
|
||||
const sel = document.getElementById('notification_channel');
|
||||
if (sel) {
|
||||
const saved = sel.dataset.value;
|
||||
if (saved) {
|
||||
for (const opt of sel.options) {
|
||||
if (opt.value === saved) { opt.selected = true; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test buttons
|
||||
const resultEl = document.getElementById('test-result');
|
||||
|
||||
function showResult(ok, msg) {
|
||||
resultEl.textContent = msg;
|
||||
resultEl.className = 'test-result ' + (ok ? 'ok' : 'err');
|
||||
resultEl.style.display = 'block';
|
||||
}
|
||||
|
||||
async function apiPost(url, btnEl, label) {
|
||||
btnEl.disabled = true;
|
||||
btnEl.textContent = label + '…';
|
||||
resultEl.style.display = 'none';
|
||||
try {
|
||||
const r = await fetch(url, { method: 'POST' });
|
||||
const data = await r.json();
|
||||
if (r.ok && data.ok) {
|
||||
if (url.includes('reminders')) {
|
||||
const n = data.reminders_found ?? 0;
|
||||
showResult(true, n > 0
|
||||
? `Found ${n} due reminder${n !== 1 ? 's' : ''} — notification sent.`
|
||||
: 'No due reminders found — nothing sent.');
|
||||
} else {
|
||||
showResult(true, 'Notification sent. Check your configured channel.');
|
||||
}
|
||||
} else {
|
||||
showResult(false, data.detail || 'Request failed.');
|
||||
}
|
||||
} catch (e) {
|
||||
showResult(false, 'Network error: ' + e.message);
|
||||
} finally {
|
||||
btnEl.disabled = false;
|
||||
btnEl.textContent = label;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('btn-test-notify').addEventListener('click', function() {
|
||||
apiPost('/api/push/test', this, 'Send Test Notification');
|
||||
});
|
||||
|
||||
document.getElementById('btn-check-reminders').addEventListener('click', function() {
|
||||
apiPost('/api/push/reminders/check', this, 'Check Reminders Now');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -264,6 +264,7 @@
|
||||
<a href="{{ back_href }}" class="nav-link">← Chat</a>
|
||||
<a href="{{ help_href }}" class="nav-link">Help</a>
|
||||
<a href="/settings" class="nav-link active">Settings</a>
|
||||
<a href="/settings/notifications" class="nav-link">Notifications</a>
|
||||
<span class="nav-spacer"></span>
|
||||
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
||||
</nav>
|
||||
@@ -348,49 +349,14 @@
|
||||
<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.
|
||||
Configure how Inara reaches out proactively — reminders, cron jobs, and memory digests.
|
||||
</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>
|
||||
<a href="/settings/notifications"
|
||||
style="display:inline-block; padding:0.55rem 1rem; background:#7c3aed; border-radius:6px;
|
||||
color:#fff; font-size:0.88rem; font-weight:600; text-decoration:none;
|
||||
transition:background 0.15s;">
|
||||
Notification settings →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tool Permissions -->
|
||||
@@ -536,12 +502,6 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
document.getElementById('password-form').addEventListener('submit', e => {
|
||||
const np = document.getElementById('new_password').value;
|
||||
|
||||
@@ -99,15 +99,15 @@ system prompt by `context_loader.py` at all tiers.
|
||||
- [ ] **`task_list` priority filter** — add `priority` param alongside existing `status`
|
||||
- [ ] **`http_fetch` max_chars** — optional param, default 8192, cap at 32768
|
||||
|
||||
### [Channel] Proactive notifications
|
||||
Inara reaches out on her own initiative via NC Talk or Google Chat when a reminder
|
||||
fires, a cron job completes, or something else warrants attention. The cron/reminder
|
||||
infrastructure already exists — this closes the loop so she can interrupt the user.
|
||||
- [ ] Add outbound message helper for NC Talk (`send_nextcloud_message(user, text)`)
|
||||
- [ ] Add outbound message helper for Google Chat (`send_google_chat_message(user, text)`)
|
||||
- [ ] Wire cron job completion and reminder triggers to call outbound helper
|
||||
- [ ] Store user preference: which channel to use for proactive notifications
|
||||
- [ ] `channels.json` already per-user — add `notify_channel: "nextcloud" | "google_chat" | null`
|
||||
### [Channel] Proactive notifications ✅ — 2026-05-08
|
||||
Inara reaches out on her own initiative via NC Talk, Google Chat, email, or browser push.
|
||||
- [x] `notification.py` — `notify(username, message, channel=None)` routes to NCT / email / Google Chat / web_push
|
||||
- [x] `web_push` added as a routable channel in `notification.py` (was tool-only before)
|
||||
- [x] `scheduler.py` — `_run_reminder_check()` daily at 09:00: reads due reminders per persona, fires `notify()` with a summary
|
||||
- [x] `cron_runner.py` — already calls `notify()` on job completion (was already wired)
|
||||
- [x] `scheduler.py` — distill_mid and distill_long already call `notify()` on completion
|
||||
- [x] Settings UI — "Browser Push Notification" option added to Notification Channel selector
|
||||
- [x] `notification_channel` accepts `"web_push"` in `routers/settings.py`
|
||||
|
||||
### [UI] File attachments in chat
|
||||
Upload an image or document inline and have it flow into context. Natural workflow
|
||||
|
||||
Reference in New Issue
Block a user