- http_post: POST to external URLs with per-user URL prefix allowlist
(home/{user}/http_allowlist.json); admin-only, confirm-required
- nc_talk_history: read recent NC Talk messages via Basic Auth (requires
nc_username + nc_app_password in channels.json under nextcloud)
- openai_orchestrator: _chat_with_retry() wraps both API calls with
exponential backoff (3 attempts, 1s/2s) on connection errors and
transient status codes (429, 500, 502, 503, 504)
- Docs updated: CLAUDE.md, HELP.md, TODO, MASTER, ROADMAP (50 tools)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
235 lines
9.2 KiB
Python
235 lines
9.2 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
|
|
|
|
import httpx
|
|
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_history(conversation_token: str = "", limit: int = 20) -> str:
|
|
"""Read recent messages from a Nextcloud Talk conversation.
|
|
|
|
Requires nc_username and nc_app_password in channels.json under 'nextcloud'.
|
|
conversation_token defaults to notification_room if not specified.
|
|
"""
|
|
from auth_utils import get_user_channels
|
|
username = get_user()
|
|
channels = get_user_channels(username)
|
|
nct = channels.get("nextcloud", {})
|
|
|
|
url = nct.get("url", "").rstrip("/")
|
|
nc_username = nct.get("nc_username", "").strip()
|
|
nc_app_password = nct.get("nc_app_password", "").strip()
|
|
token = conversation_token.strip() or nct.get("notification_room", "").strip()
|
|
|
|
if not url or not nc_username or not nc_app_password:
|
|
return (
|
|
"nc_talk_history requires nc_username and nc_app_password in channels.json "
|
|
f"(under 'nextcloud'). Add these to home/{username}/channels.json to enable message reading."
|
|
)
|
|
if not token:
|
|
return "No conversation token provided and no notification_room set in channels.json."
|
|
|
|
limit = min(max(int(limit), 1), 200)
|
|
return await asyncio.to_thread(_sync_nc_talk_history, url, nc_username, nc_app_password, token, limit)
|
|
|
|
|
|
def _sync_nc_talk_history(url: str, nc_user: str, nc_pass: str, token: str, limit: int) -> str:
|
|
from datetime import datetime, timezone
|
|
endpoint = f"{url}/ocs/v2.php/apps/spreed/api/v4/chat/{token}"
|
|
try:
|
|
resp = httpx.get(
|
|
endpoint,
|
|
params={"limit": limit, "lookIntoFuture": 0, "setReadMarker": 0, "noStatusUpdate": 1},
|
|
auth=(nc_user, nc_pass),
|
|
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
|
timeout=15,
|
|
)
|
|
except Exception as e:
|
|
return f"NC Talk API error: {e}"
|
|
|
|
if resp.status_code != 200:
|
|
return f"NC Talk API returned HTTP {resp.status_code}: {resp.text[:200]}"
|
|
|
|
try:
|
|
messages = resp.json().get("ocs", {}).get("data", [])
|
|
except Exception as e:
|
|
return f"Failed to parse NC Talk response: {e}"
|
|
|
|
if not messages:
|
|
return "No messages found in this conversation."
|
|
|
|
# NC Talk returns newest-first; reverse to chronological order
|
|
lines = [f"Last {len(messages)} messages from {token}:\n"]
|
|
for msg in reversed(messages):
|
|
sender = msg.get("actorDisplayName") or msg.get("actorId") or "Unknown"
|
|
ts = msg.get("timestamp", 0)
|
|
time_str = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
text = msg.get("message", "")
|
|
if msg.get("messageType") == "system":
|
|
lines.append(f"[system {time_str}] {text}")
|
|
else:
|
|
lines.append(f"{sender} ({time_str}): {text}")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
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"],
|
|
),
|
|
),
|
|
types.FunctionDeclaration(
|
|
name="nc_talk_history",
|
|
description=(
|
|
"Read recent messages from a Nextcloud Talk conversation. Useful for checking "
|
|
"what was said in a room before composing a reply, or reviewing recent context. "
|
|
"Requires nc_username and nc_app_password in channels.json under 'nextcloud'. "
|
|
"conversation_token defaults to notification_room if not provided."
|
|
),
|
|
parameters=types.Schema(
|
|
type=types.Type.OBJECT,
|
|
properties={
|
|
"conversation_token": types.Schema(type=types.Type.STRING, description="NC Talk room token (defaults to notification_room from channels.json)"),
|
|
"limit": types.Schema(type=types.Type.INTEGER, description="Number of messages to return (default 20, max 200)"),
|
|
},
|
|
required=[],
|
|
),
|
|
),
|
|
]
|