feat: http_post tool, nc_talk_history tool, local orchestrator retry

- 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>
This commit is contained in:
Scott Idem
2026-05-09 13:38:38 -04:00
parent b9a78819ac
commit 7b443b40a4
9 changed files with 244 additions and 26 deletions

View File

@@ -10,6 +10,7 @@ import json
import logging
import re
import httpx
from google.genai import types
from config import settings
from persona import get_user
@@ -77,6 +78,74 @@ async def web_push(title: str, body: str, url: str = "") -> str:
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.
@@ -145,4 +214,21 @@ DECLARATIONS = [
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=[],
),
),
]