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:
@@ -1,14 +1,17 @@
|
||||
"""
|
||||
Web tools — search (DuckDuckGo), direct HTTP fetch, and clean content extraction.
|
||||
Web tools — search (DuckDuckGo), direct HTTP fetch, clean content extraction, and HTTP POST.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from google.genai import types
|
||||
|
||||
from config import settings
|
||||
from persona import get_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -115,6 +118,80 @@ def _sync_web_read(url: str, max_chars: int) -> str:
|
||||
return f"Content from {url}:\n\n{text}"
|
||||
|
||||
|
||||
def _load_http_allowlist(username: str) -> list[str]:
|
||||
"""Load per-user HTTP POST allowlist (URL prefixes). Empty list = all blocked."""
|
||||
path = settings.home_root() / username / "http_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 http_allowlist.json for %s: %s", username, e)
|
||||
return []
|
||||
|
||||
|
||||
def _http_post_allowed(url: str, allowlist: list[str]) -> bool:
|
||||
"""Return True if url starts with any allowlist entry (prefix match)."""
|
||||
for prefix in allowlist:
|
||||
if url.startswith(prefix):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def http_post(
|
||||
url: str,
|
||||
body: str = "",
|
||||
headers: dict | None = None,
|
||||
max_chars: int = 4096,
|
||||
) -> str:
|
||||
"""POST to an external URL. Requires the URL to match home/{user}/http_allowlist.json.
|
||||
|
||||
body may be a JSON string or plain text. If body is valid JSON, Content-Type is set
|
||||
to application/json; otherwise text/plain. Override via the headers param.
|
||||
Response is capped at max_chars (default 4096, max 131072).
|
||||
"""
|
||||
username = get_user()
|
||||
allowlist = _load_http_allowlist(username)
|
||||
if not allowlist:
|
||||
return (
|
||||
f"http_post blocked — no allowlist configured. "
|
||||
f"Add allowed URL prefixes to home/{username}/http_allowlist.json as a JSON array. "
|
||||
f"Example: [\"https://api.example.com\"]"
|
||||
)
|
||||
if not _http_post_allowed(url, allowlist):
|
||||
return (
|
||||
f"http_post blocked — {url} does not match any allowlist entry for {username}. "
|
||||
f"Add the URL prefix to home/{username}/http_allowlist.json."
|
||||
)
|
||||
|
||||
max_chars = min(max(int(max_chars), 100), 131072)
|
||||
|
||||
# Auto-detect content type from body
|
||||
body_str = body if isinstance(body, str) else json.dumps(body)
|
||||
try:
|
||||
json.loads(body_str)
|
||||
content_type = "application/json"
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
content_type = "text/plain"
|
||||
|
||||
req_headers = {"Content-Type": content_type}
|
||||
if headers:
|
||||
req_headers.update(headers)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||
resp = await client.post(url, content=body_str.encode(), headers=req_headers)
|
||||
body_text = resp.text[:max_chars]
|
||||
truncated = len(resp.text) > max_chars
|
||||
suffix = f"\n\n[… truncated at {max_chars} chars]" if truncated else ""
|
||||
return f"HTTP {resp.status_code} {resp.url}\n\n{body_text}{suffix}"
|
||||
except httpx.HTTPError as e:
|
||||
return f"HTTP error: {e}"
|
||||
except Exception as e:
|
||||
logger.warning("http_post error for %s: %s", url, e)
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
DECLARATIONS = [
|
||||
types.FunctionDeclaration(
|
||||
name="web_search",
|
||||
@@ -169,4 +246,22 @@ DECLARATIONS = [
|
||||
required=["url"],
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="http_post",
|
||||
description=(
|
||||
"POST to an external URL. Requires the URL to match the user's http_allowlist.json. "
|
||||
"Use for calling webhooks, triggering automations, posting to APIs, or any HTTP action. "
|
||||
"body is a string — JSON or plain text are both accepted (Content-Type auto-detected). "
|
||||
"Override headers as needed. Response capped at max_chars (default 4096, max 131072)."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"url": types.Schema(type=types.Type.STRING, description="Full URL to POST to"),
|
||||
"body": types.Schema(type=types.Type.STRING, description="Request body — JSON string or plain text"),
|
||||
"max_chars": types.Schema(type=types.Type.INTEGER, description="Max response chars (default 4096, max 131072)"),
|
||||
},
|
||||
required=["url"],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user