Settings: add dedicated DAV/Pushover tabs, fix CalDAV/CardDAV bugs
- Add admin DAV tab (rename from CalDAV/CardDAV) and Pushover tab
- Add per-user Pushover tab (User Key only; App Token stays admin-managed)
- Remove system-wide CalDAV/CardDAV fallback — per-user config only
- Rewrite contacts_tool.py using httpx directly (caldav 2.x dropped AddressBook)
- Fix CardDAV REPORT/PROPFIND using SOGo URL pattern
- Fix CalDAV/CardDAV test endpoints (POST method, URL scheme normalization)
- Fix Show Password button — API now returns actual credential values
- Convert Credentials tab to generic key-value store; dedicated keys
(CalDAV, Pushover, trusted_proxy) excluded via _DEDICATED_CRED_KEYS
This commit is contained in:
@@ -21,6 +21,9 @@ RUN apt-get update \
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Install Playwright browser (Chromium) and its system dependencies
|
||||
RUN playwright install --with-deps chromium
|
||||
|
||||
COPY server/ ./server/
|
||||
|
||||
# Data directory for encrypted DB (mounted as volume in production)
|
||||
|
||||
77
README.md
77
README.md
@@ -1,29 +1,30 @@
|
||||
# oAI-Web - Personal AI Agent
|
||||
|
||||
A secure, self-hosted personal AI agent powered by OpenRouter, Anthropic and OpenAI. Handles calendar, email, files, web research, and Telegram - controlled by you, running on your own hardware.
|
||||
A secure, self-hosted personal AI agent powered by Claude. Handles calendar, email, files, web research, and Telegram - controlled by you, running on your own hardware.
|
||||
|
||||
## Features
|
||||
|
||||
- **Chat interface** - conversational UI via browser, with model selector
|
||||
- **CalDAV** - read and write calendar events
|
||||
- **CalDAV** - read and write calendar events (per-user credentials, configured in Settings)
|
||||
- **CardDAV / Contacts** - search and manage contacts from your CardDAV server
|
||||
- **Email** - read inbox, send replies (whitelist-managed recipients)
|
||||
- **Filesystem** - read/write files in declared sandbox directories
|
||||
- **Filesystem** - read/write files in your personal data folder
|
||||
- **Web access** - tiered: whitelisted domains always allowed, others on request
|
||||
- **Push notifications** - Pushover for iOS/Android
|
||||
- **Push notifications** - Pushover for iOS/Android (set your own User Key in Settings)
|
||||
- **Telegram** - send and receive messages via your own bot
|
||||
- **Webhooks** - trigger agents from external services (iOS Shortcuts, GitHub, Home Assistant, etc.)
|
||||
- **Monitors** - page-change and RSS feed monitors that dispatch agents automatically
|
||||
- **Scheduled tasks** - cron-based autonomous tasks with declared permission scopes
|
||||
- **Agents** - goal-oriented runs with model selection and full run history
|
||||
- **Audit log** - every tool call logged, append-only
|
||||
- **Multi-user** - each user has their own credentials and settings
|
||||
|
||||
oAI-Web also has an extensive built in help function. This makes it easy for both admins and normal useres to learn and use oAI-Web in the best way possible.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker and Docker Compose
|
||||
- An API key from [Anthropic](https://console.anthropic.com) and/or [OpenRouter](https://openrouter.ai) and/or [OpenAI](https://openai.com)
|
||||
- An API key from [Anthropic](https://console.anthropic.com) and/or [OpenRouter](https://openrouter.ai)
|
||||
- A PostgreSQL-compatible host (included in the compose file)
|
||||
|
||||
---
|
||||
@@ -247,30 +248,51 @@ The file is mounted read-only into the container. Changes take effect on the nex
|
||||
|
||||
---
|
||||
|
||||
## First Run - Settings
|
||||
## Your Settings
|
||||
|
||||
After the setup wizard, go to **Settings** to configure your services.
|
||||
After logging in, go to **Settings** to configure your personal services. Each user has their own credentials — nothing is shared with other users.
|
||||
|
||||
### Credentials (admin only)
|
||||
### CalDAV / CardDAV
|
||||
|
||||
Add credentials for the services you use. Common keys:
|
||||
Set up your personal calendar and contacts server under **Settings → CalDAV / CardDAV**:
|
||||
|
||||
| Key | Example | Used by |
|
||||
|-----|---------|---------|
|
||||
| `anthropic_api_key` | `sk-ant-...` | Claude (Anthropic) |
|
||||
| `openrouter_api_key` | `sk-or-...` | OpenRouter models |
|
||||
| `mailcow_host` | `mail.yourdomain.com` | CalDAV, Email |
|
||||
| `mailcow_username` | `you@yourdomain.com` | CalDAV, Email |
|
||||
| `mailcow_password` | your IMAP password | CalDAV, Email |
|
||||
| `caldav_calendar_name` | `personal` | CalDAV |
|
||||
| `pushover_app_token` | from Pushover dashboard | Push notifications |
|
||||
| `telegram_bot_token` | from @BotFather | Telegram |
|
||||
- Enter your server URL (e.g. `mail.example.com`), username, and password
|
||||
- Optionally specify a calendar name (leave blank for the default calendar)
|
||||
- For CardDAV (contacts): tick *Same server as CalDAV* to reuse your credentials, or enter separate details
|
||||
- Use the **Test** buttons to verify your connection before saving
|
||||
- Enable **Allow contact writes** if you want agents to be able to create and update contacts
|
||||
|
||||
### Whitelists
|
||||
There is no system-wide fallback — if you don't configure it, calendar and contacts tools won't be available to your agents.
|
||||
|
||||
- **Email whitelist** - addresses the agent is allowed to send email to
|
||||
- **Web whitelist** - domains always accessible to the agent (Tier 1)
|
||||
- **Filesystem sandbox** - directories the agent is allowed to read/write
|
||||
### Pushover
|
||||
|
||||
To receive push notifications on your iOS or Android device:
|
||||
|
||||
1. Create a free account at [pushover.net](https://pushover.net)
|
||||
2. Copy your **User Key** from the dashboard
|
||||
3. Go to **Settings → Pushover** and save your User Key
|
||||
|
||||
The app is already registered by your admin — you only need your own User Key.
|
||||
|
||||
### Webhooks
|
||||
|
||||
Create inbound webhooks under **Settings → Webhooks** to trigger your agents from external services:
|
||||
|
||||
- Assign a name and target agent, then copy the secret token shown at creation (it's shown only once)
|
||||
- **POST trigger**: send `{"message": "your message"}` to `/webhook/{token}`
|
||||
- **GET trigger**: visit `/webhook/{token}?q=your+message` — ideal for iOS Shortcuts URL actions
|
||||
- Enable or disable webhooks without deleting them
|
||||
|
||||
### Telegram
|
||||
|
||||
Set your personal bot token under **Settings → Telegram** (or **Settings → Profile → Telegram Bot Token**) if you want your own Telegram bot. Your chat ID must be whitelisted by the admin before messages are processed.
|
||||
|
||||
### Email Accounts
|
||||
|
||||
Set up your own email accounts under **Settings → Email Accounts**:
|
||||
|
||||
- **Trigger account** — dispatches agents based on keyword rules in incoming emails
|
||||
- **Handling account** — a dedicated AI agent reads and handles each incoming email
|
||||
|
||||
---
|
||||
|
||||
@@ -290,9 +312,12 @@ docker compose up -d
|
||||
| `/` | Chat - send messages, select model, view tool activity |
|
||||
| `/tasks` | Scheduled tasks - cron-based autonomous tasks |
|
||||
| `/agents` | Agents - goal-oriented runs with model selection and run history |
|
||||
| `/monitors` | Monitors - page-change watchers and RSS feed monitors |
|
||||
| `/files` | Files - browse, download, and manage your personal data folder |
|
||||
| `/audit` | Audit log - filterable view of every tool call |
|
||||
| `/settings` | Credentials, whitelists, agent config, Telegram, and more |
|
||||
| `/settings` | Your personal settings: CalDAV, CardDAV, Pushover, Webhooks, Telegram, Email Accounts, and more |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ aioimaplib>=1.0
|
||||
# Web
|
||||
httpx==0.27.*
|
||||
beautifulsoup4==4.12.*
|
||||
feedparser==6.0.* # RSS/Atom feed parsing
|
||||
playwright>=1.40 # Headless browser for JS-heavy pages
|
||||
|
||||
# Scheduler
|
||||
apscheduler==3.10.*
|
||||
|
||||
@@ -21,17 +21,50 @@ from . import tasks as agent_store
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Priority levels for the run queue (lower number = higher priority)
|
||||
PRIORITY_HIGH = 0 # User-initiated chat runs
|
||||
PRIORITY_NORMAL = 1 # Webhook / inbox / Telegram triggers
|
||||
PRIORITY_LOW = 2 # Background monitors
|
||||
|
||||
_DEFAULT_MAX_CONCURRENT = 3
|
||||
|
||||
|
||||
class AgentRunner:
|
||||
def __init__(self) -> None:
|
||||
self._agent: Agent | None = None
|
||||
self._scheduler = AsyncIOScheduler(timezone=settings.timezone)
|
||||
self._running: dict[str, asyncio.Task] = {} # run_id → asyncio.Task
|
||||
# Concurrency semaphore — initialised in start() once event loop is running
|
||||
self._semaphore: asyncio.Semaphore | None = None
|
||||
self._max_concurrent: int = _DEFAULT_MAX_CONCURRENT
|
||||
|
||||
@property
|
||||
def scheduler(self) -> AsyncIOScheduler:
|
||||
return self._scheduler
|
||||
|
||||
@property
|
||||
def queue_status(self) -> dict:
|
||||
running = sum(1 for t in self._running.values() if not t.done())
|
||||
# Tasks waiting for the semaphore are counted as "queued"
|
||||
queued = max(0, running - self._max_concurrent)
|
||||
return {"running": min(running, self._max_concurrent), "queued": queued, "max_concurrent": self._max_concurrent}
|
||||
|
||||
def init(self, agent: Agent) -> None:
|
||||
self._agent = agent
|
||||
|
||||
async def _load_max_concurrent(self) -> int:
|
||||
val = await credential_store.get("system:max_concurrent_runs")
|
||||
try:
|
||||
return max(1, int(val)) if val else _DEFAULT_MAX_CONCURRENT
|
||||
except (ValueError, TypeError):
|
||||
return _DEFAULT_MAX_CONCURRENT
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Load all enabled agents with schedules into APScheduler and start it."""
|
||||
# Initialise concurrency semaphore (must happen inside a running event loop)
|
||||
self._max_concurrent = await self._load_max_concurrent()
|
||||
self._semaphore = asyncio.Semaphore(self._max_concurrent)
|
||||
|
||||
for agent in await agent_store.list_agents():
|
||||
if agent["enabled"] and agent["schedule"]:
|
||||
self._add_job(agent)
|
||||
@@ -44,7 +77,9 @@ class AgentRunner:
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
self._scheduler.start()
|
||||
logger.info("[agent-runner] Scheduler started, loaded scheduled agents")
|
||||
logger.info(
|
||||
"[agent-runner] Scheduler started, max_concurrent=%d", self._max_concurrent
|
||||
)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
if self._scheduler.running:
|
||||
@@ -261,7 +296,14 @@ class AgentRunner:
|
||||
finally:
|
||||
self._running.pop(run_id, None)
|
||||
|
||||
task = asyncio.create_task(_execute())
|
||||
async def _execute_with_semaphore():
|
||||
if self._semaphore:
|
||||
async with self._semaphore:
|
||||
await _execute()
|
||||
else:
|
||||
await _execute()
|
||||
|
||||
task = asyncio.create_task(_execute_with_semaphore())
|
||||
self._running[run_id] = task
|
||||
return await agent_store.get_run(run_id)
|
||||
|
||||
|
||||
@@ -393,6 +393,78 @@ _MIGRATIONS: list[list[str]] = [
|
||||
[
|
||||
"ALTER TABLE conversations ADD COLUMN IF NOT EXISTS model TEXT",
|
||||
],
|
||||
# v25 — Inbound webhook trigger endpoints + outbound webhook targets + monitors
|
||||
[
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS webhook_endpoints (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
description TEXT DEFAULT '',
|
||||
agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
allow_get BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_triggered_at TIMESTAMPTZ,
|
||||
trigger_count INTEGER DEFAULT 0
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS webhook_targets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
url TEXT NOT NULL,
|
||||
secret_header TEXT,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS watched_pages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
schedule TEXT NOT NULL DEFAULT '0 * * * *',
|
||||
css_selector TEXT,
|
||||
last_content_hash TEXT,
|
||||
last_changed_at TIMESTAMPTZ,
|
||||
last_checked_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
|
||||
notification_mode TEXT DEFAULT 'agent',
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
owner_user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS rss_feeds (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
schedule TEXT NOT NULL DEFAULT '0 */4 * * *',
|
||||
agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
|
||||
last_fetched_at TIMESTAMPTZ,
|
||||
last_etag TEXT,
|
||||
last_modified TEXT,
|
||||
seen_item_ids JSONB DEFAULT '[]',
|
||||
max_items_per_run INTEGER DEFAULT 5,
|
||||
notification_mode TEXT DEFAULT 'agent',
|
||||
last_error TEXT,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
owner_user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
""",
|
||||
],
|
||||
# v26 — Per-user webhook endpoint ownership
|
||||
[
|
||||
"ALTER TABLE webhook_endpoints ADD COLUMN IF NOT EXISTS owner_user_id TEXT REFERENCES users(id) ON DELETE CASCADE",
|
||||
],
|
||||
# v27 — Per-user webhook target ownership
|
||||
[
|
||||
"ALTER TABLE webhook_targets ADD COLUMN IF NOT EXISTS owner_user_id TEXT REFERENCES users(id) ON DELETE CASCADE",
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ from .context_vars import current_user as _current_user_var
|
||||
from .database import close_db, credential_store, init_db
|
||||
from .inbox.listener import inbox_listener
|
||||
from .mcp import create_mcp_app, _session_manager
|
||||
from .monitors.page_monitor import page_monitor
|
||||
from .monitors.rss_monitor import rss_monitor
|
||||
from .telegram.listener import telegram_listener
|
||||
from .tools import build_registry
|
||||
from .users import assign_existing_data_to_admin, create_user, get_user_by_username, user_count
|
||||
@@ -190,6 +192,11 @@ async def lifespan(app: FastAPI):
|
||||
print("[aide] Agent ready.")
|
||||
agent_runner.init(_agent)
|
||||
await agent_runner.start()
|
||||
# Wire monitors into the shared scheduler after it starts
|
||||
page_monitor.init(agent_runner.scheduler)
|
||||
rss_monitor.init(agent_runner.scheduler)
|
||||
await page_monitor.start_all()
|
||||
await rss_monitor.start_all()
|
||||
await _migrate_email_accounts()
|
||||
await inbox_listener.start_all()
|
||||
telegram_listener.start()
|
||||
@@ -260,7 +267,7 @@ import time as _time
|
||||
|
||||
_USER_COOKIE = "aide_user"
|
||||
_EXEMPT_PATHS = frozenset({"/login", "/login/mfa", "/logout", "/setup", "/health"})
|
||||
_EXEMPT_PREFIXES = ("/static/", "/brain-mcp/", "/docs", "/redoc", "/openapi.json")
|
||||
_EXEMPT_PREFIXES = ("/static/", "/brain-mcp/", "/docs", "/redoc", "/openapi.json", "/webhook/")
|
||||
_EXEMPT_API_PATHS = frozenset({"/api/settings/api-key"})
|
||||
|
||||
|
||||
@@ -380,6 +387,87 @@ app.include_router(api_router, prefix="/api")
|
||||
app.mount("/brain-mcp", create_mcp_app())
|
||||
|
||||
|
||||
# ── Public webhook trigger endpoints ─────────────────────────────────────────
|
||||
# These live outside /api and outside the auth middleware (token = auth).
|
||||
|
||||
_webhook_logger = logging.getLogger("server.webhook")
|
||||
|
||||
|
||||
async def _handle_webhook_trigger(token: str, message: str, wait: bool = False) -> JSONResponse:
|
||||
"""Shared logic for GET and POST webhook triggers."""
|
||||
from .webhooks.endpoints import get_by_token, record_trigger
|
||||
from .security import sanitize_external_content
|
||||
|
||||
ep = await get_by_token(token)
|
||||
if ep is None:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
|
||||
agent_id = ep.get("agent_id")
|
||||
if not agent_id:
|
||||
return JSONResponse({"error": "No agent configured for this webhook"}, status_code=422)
|
||||
|
||||
message = (message or "").strip()
|
||||
if not message:
|
||||
message = "Webhook triggered"
|
||||
|
||||
message = await sanitize_external_content(message, source="webhook")
|
||||
|
||||
from .audit import audit_log
|
||||
ep_id = str(ep["id"])
|
||||
session_id = f"webhook:{ep_id}"
|
||||
|
||||
await audit_log.record(
|
||||
tool_name="webhook",
|
||||
arguments={"endpoint": ep.get("name"), "message": message[:200]},
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
if wait:
|
||||
result = await agent_runner.run_agent_and_wait(
|
||||
agent_id=agent_id,
|
||||
override_message=message,
|
||||
session_id=session_id,
|
||||
)
|
||||
await record_trigger(ep_id)
|
||||
return JSONResponse({"ok": True, "result": result})
|
||||
else:
|
||||
run = await agent_runner.run_agent_now(agent_id=agent_id, override_message=message)
|
||||
await record_trigger(ep_id)
|
||||
run_id = run.get("id") or run.get("error", "error")
|
||||
return JSONResponse({"ok": True, "run_id": run_id})
|
||||
|
||||
|
||||
@app.get("/webhook/{token}")
|
||||
async def webhook_trigger_get(token: str, q: str = "", wait: bool = False):
|
||||
"""iOS Shortcuts / simple GET trigger. Message via ?q= query param."""
|
||||
ep = await _get_webhook_endpoint_for_get(token)
|
||||
if ep is None:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
return await _handle_webhook_trigger(token, q, wait=wait)
|
||||
|
||||
|
||||
@app.post("/webhook/{token}")
|
||||
async def webhook_trigger_post(token: str, request: Request):
|
||||
"""External service POST trigger. Message via JSON body {"message": "..."}."""
|
||||
try:
|
||||
body = await request.json()
|
||||
message = body.get("message", "")
|
||||
wait = bool(body.get("wait", False))
|
||||
except Exception:
|
||||
message = ""
|
||||
wait = False
|
||||
return await _handle_webhook_trigger(token, message, wait=wait)
|
||||
|
||||
|
||||
async def _get_webhook_endpoint_for_get(token: str) -> dict | None:
|
||||
"""Return endpoint only if allow_get is True."""
|
||||
from .webhooks.endpoints import get_by_token
|
||||
ep = await get_by_token(token)
|
||||
if ep and not ep.get("allow_get", True):
|
||||
return None
|
||||
return ep
|
||||
|
||||
|
||||
|
||||
# ── Auth helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -682,6 +770,11 @@ async def audit_page(request: Request):
|
||||
return templates.TemplateResponse("audit.html", await _ctx(request))
|
||||
|
||||
|
||||
@app.get("/monitors", response_class=HTMLResponse)
|
||||
async def monitors_page(request: Request):
|
||||
return templates.TemplateResponse("monitors.html", await _ctx(request))
|
||||
|
||||
|
||||
@app.get("/help", response_class=HTMLResponse)
|
||||
async def help_page(request: Request):
|
||||
return templates.TemplateResponse("help.html", await _ctx(request))
|
||||
|
||||
0
server/monitors/__init__.py
Normal file
0
server/monitors/__init__.py
Normal file
175
server/monitors/page_monitor.py
Normal file
175
server/monitors/page_monitor.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
monitors/page_monitor.py — Page change monitor.
|
||||
|
||||
Polls watched URLs on a cron schedule, hashes the content, and dispatches
|
||||
an agent (or Pushover notification) when the page content changes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from ..config import settings
|
||||
from . import store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (compatible; oAI-Web page-monitor/1.0)",
|
||||
}
|
||||
|
||||
|
||||
async def _fetch_page_content(url: str, css_selector: str | None = None) -> str:
|
||||
"""Fetch URL and return plain text (optionally filtered by CSS selector)."""
|
||||
async with httpx.AsyncClient(
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
headers=_DEFAULT_HEADERS,
|
||||
) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
html = resp.text
|
||||
|
||||
if css_selector:
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
elements = soup.select(css_selector)
|
||||
return "\n".join(el.get_text(separator=" ", strip=True) for el in elements)
|
||||
except Exception as e:
|
||||
logger.warning("[page-monitor] CSS selector '%s' failed: %s", css_selector, e)
|
||||
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for tag in soup(["script", "style", "nav", "footer", "header"]):
|
||||
tag.decompose()
|
||||
return soup.get_text(separator="\n", strip=True)
|
||||
except Exception:
|
||||
return html
|
||||
|
||||
|
||||
def _content_hash(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
|
||||
|
||||
|
||||
class PageMonitorManager:
|
||||
"""Manages APScheduler jobs for all watched_pages entries."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._scheduler: AsyncIOScheduler | None = None
|
||||
|
||||
def init(self, scheduler: AsyncIOScheduler) -> None:
|
||||
"""Share the AgentRunner's scheduler."""
|
||||
self._scheduler = scheduler
|
||||
|
||||
async def start_all(self) -> None:
|
||||
"""Load all enabled watched pages and register APScheduler jobs."""
|
||||
pages = await store.list_watched_pages()
|
||||
for page in pages:
|
||||
if page["enabled"]:
|
||||
self._add_job(page)
|
||||
logger.info("[page-monitor] Registered %d page monitor jobs", len([p for p in pages if p["enabled"]]))
|
||||
|
||||
def _add_job(self, page: dict) -> None:
|
||||
if not self._scheduler:
|
||||
return
|
||||
try:
|
||||
self._scheduler.add_job(
|
||||
self._check_page,
|
||||
trigger=CronTrigger.from_crontab(page["schedule"], timezone=settings.timezone),
|
||||
id=f"page:{page['id']}",
|
||||
args=[str(page["id"])],
|
||||
replace_existing=True,
|
||||
misfire_grace_time=300,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("[page-monitor] Failed to schedule page '%s': %s", page["name"], e)
|
||||
|
||||
def reschedule(self, page: dict) -> None:
|
||||
if not self._scheduler:
|
||||
return
|
||||
job_id = f"page:{page['id']}"
|
||||
try:
|
||||
self._scheduler.remove_job(job_id)
|
||||
except Exception:
|
||||
pass
|
||||
if page.get("enabled"):
|
||||
self._add_job(page)
|
||||
|
||||
def remove(self, page_id: str) -> None:
|
||||
if not self._scheduler:
|
||||
return
|
||||
try:
|
||||
self._scheduler.remove_job(f"page:{page_id}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def check_now(self, page_id: str) -> dict:
|
||||
"""Force-check a page immediately (UI-triggered). Returns status dict."""
|
||||
return await self._check_page(page_id)
|
||||
|
||||
async def _check_page(self, page_id: str) -> dict:
|
||||
page = await store.get_watched_page(page_id)
|
||||
if not page:
|
||||
return {"error": "Page not found"}
|
||||
|
||||
logger.info("[page-monitor] Checking '%s' (%s)", page["name"], page["url"])
|
||||
|
||||
try:
|
||||
content = await _fetch_page_content(page["url"], page.get("css_selector"))
|
||||
except Exception as e:
|
||||
error_msg = str(e)[:200]
|
||||
logger.warning("[page-monitor] Failed to fetch '%s': %s", page["url"], error_msg)
|
||||
await store.update_page_check_result(page_id, None, False, error=error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
new_hash = _content_hash(content)
|
||||
old_hash = page.get("last_content_hash")
|
||||
changed = old_hash is not None and new_hash != old_hash
|
||||
|
||||
await store.update_page_check_result(page_id, new_hash, changed)
|
||||
|
||||
if changed:
|
||||
logger.info("[page-monitor] Change detected on '%s'", page["name"])
|
||||
await self._dispatch_change(page, content)
|
||||
|
||||
return {"changed": changed, "hash": new_hash, "first_check": old_hash is None}
|
||||
|
||||
async def _dispatch_change(self, page: dict, content: str) -> None:
|
||||
mode = page.get("notification_mode", "agent")
|
||||
message = (
|
||||
f"Page change detected: {page['name']}\n"
|
||||
f"URL: {page['url']}\n\n"
|
||||
f"Current content (first 2000 chars):\n{content[:2000]}"
|
||||
)
|
||||
|
||||
if mode in ("pushover", "both"):
|
||||
try:
|
||||
from ..tools.pushover_tool import PushoverTool
|
||||
await PushoverTool().execute(
|
||||
title=f"Page changed: {page['name']}",
|
||||
message=f"{page['url']} has new content.",
|
||||
priority=0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[page-monitor] Pushover notify failed: %s", e)
|
||||
|
||||
if mode in ("agent", "both"):
|
||||
agent_id = page.get("agent_id")
|
||||
if agent_id:
|
||||
try:
|
||||
from ..agents.runner import agent_runner
|
||||
await agent_runner.run_agent_now(
|
||||
agent_id=agent_id,
|
||||
override_message=message,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[page-monitor] Agent dispatch failed: %s", e)
|
||||
|
||||
|
||||
page_monitor = PageMonitorManager()
|
||||
185
server/monitors/rss_monitor.py
Normal file
185
server/monitors/rss_monitor.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
monitors/rss_monitor.py — RSS / Atom feed monitor.
|
||||
|
||||
Polls feeds on a cron schedule, tracks seen item IDs, and dispatches
|
||||
an agent (or Pushover) when new items appear.
|
||||
Sends ETag / If-Modified-Since headers for bandwidth efficiency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from ..config import settings
|
||||
from . import store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _item_id(entry) -> str:
|
||||
"""Return a stable ID for a feed entry (id → link → title)."""
|
||||
return entry.get("id") or entry.get("link") or entry.get("title") or ""
|
||||
|
||||
|
||||
def _format_items(entries: list) -> str:
|
||||
"""Format new feed entries as a readable message for agents."""
|
||||
lines = []
|
||||
for e in entries:
|
||||
title = e.get("title", "(no title)")
|
||||
link = e.get("link", "")
|
||||
summary = e.get("summary", "")[:500]
|
||||
lines.append(f"- {title}\n {link}")
|
||||
if summary:
|
||||
lines.append(f" {summary}")
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
class RssFeedManager:
|
||||
"""Manages APScheduler jobs for all rss_feeds entries."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._scheduler: AsyncIOScheduler | None = None
|
||||
|
||||
def init(self, scheduler: AsyncIOScheduler) -> None:
|
||||
self._scheduler = scheduler
|
||||
|
||||
async def start_all(self) -> None:
|
||||
feeds = await store.list_rss_feeds()
|
||||
for feed in feeds:
|
||||
if feed["enabled"]:
|
||||
self._add_job(feed)
|
||||
logger.info("[rss-monitor] Registered %d RSS feed jobs", len([f for f in feeds if f["enabled"]]))
|
||||
|
||||
def _add_job(self, feed: dict) -> None:
|
||||
if not self._scheduler:
|
||||
return
|
||||
try:
|
||||
self._scheduler.add_job(
|
||||
self._fetch_feed,
|
||||
trigger=CronTrigger.from_crontab(feed["schedule"], timezone=settings.timezone),
|
||||
id=f"rss:{feed['id']}",
|
||||
args=[str(feed["id"])],
|
||||
replace_existing=True,
|
||||
misfire_grace_time=300,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("[rss-monitor] Failed to schedule feed '%s': %s", feed["name"], e)
|
||||
|
||||
def reschedule(self, feed: dict) -> None:
|
||||
if not self._scheduler:
|
||||
return
|
||||
job_id = f"rss:{feed['id']}"
|
||||
try:
|
||||
self._scheduler.remove_job(job_id)
|
||||
except Exception:
|
||||
pass
|
||||
if feed.get("enabled"):
|
||||
self._add_job(feed)
|
||||
|
||||
def remove(self, feed_id: str) -> None:
|
||||
if not self._scheduler:
|
||||
return
|
||||
try:
|
||||
self._scheduler.remove_job(f"rss:{feed_id}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def fetch_now(self, feed_id: str) -> dict:
|
||||
"""Force-fetch a feed immediately (UI-triggered). Returns status dict."""
|
||||
return await self._fetch_feed(feed_id)
|
||||
|
||||
async def _fetch_feed(self, feed_id: str) -> dict:
|
||||
import feedparser
|
||||
|
||||
feed_row = await store.get_rss_feed(feed_id)
|
||||
if not feed_row:
|
||||
return {"error": "Feed not found"}
|
||||
|
||||
logger.info("[rss-monitor] Fetching '%s' (%s)", feed_row["name"], feed_row["url"])
|
||||
|
||||
# Build request with conditional headers for bandwidth efficiency
|
||||
request_headers = {}
|
||||
if feed_row.get("last_etag"):
|
||||
request_headers["ETag"] = feed_row["last_etag"]
|
||||
if feed_row.get("last_modified"):
|
||||
request_headers["If-Modified-Since"] = feed_row["last_modified"]
|
||||
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
resp = await client.get(feed_row["url"], headers=request_headers)
|
||||
|
||||
if resp.status_code == 304:
|
||||
logger.info("[rss-monitor] '%s' unchanged (304)", feed_row["name"])
|
||||
await store.update_feed_fetch_result(feed_id, feed_row.get("seen_item_ids") or [])
|
||||
return {"new_items": 0, "status": "not_modified"}
|
||||
|
||||
resp.raise_for_status()
|
||||
parsed = feedparser.parse(resp.text)
|
||||
etag = resp.headers.get("etag")
|
||||
last_modified = resp.headers.get("last-modified")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)[:200]
|
||||
logger.warning("[rss-monitor] Failed to fetch '%s': %s", feed_row["url"], error_msg)
|
||||
await store.update_feed_fetch_result(feed_id, feed_row.get("seen_item_ids") or [], error=error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
seen = set(feed_row.get("seen_item_ids") or [])
|
||||
max_items = feed_row.get("max_items_per_run") or 5
|
||||
new_entries = [e for e in parsed.entries if _item_id(e) and _item_id(e) not in seen][:max_items]
|
||||
|
||||
# Update seen IDs (keep last 500 to prevent unbounded growth)
|
||||
all_ids = list(seen | {_item_id(e) for e in parsed.entries if _item_id(e)})
|
||||
all_ids = all_ids[-500:]
|
||||
|
||||
await store.update_feed_fetch_result(
|
||||
feed_id,
|
||||
seen_item_ids=all_ids,
|
||||
etag=etag,
|
||||
last_modified=last_modified,
|
||||
)
|
||||
|
||||
if new_entries:
|
||||
logger.info("[rss-monitor] %d new items in '%s'", len(new_entries), feed_row["name"])
|
||||
await self._dispatch_new_items(feed_row, new_entries)
|
||||
|
||||
return {"new_items": len(new_entries)}
|
||||
|
||||
async def _dispatch_new_items(self, feed_row: dict, entries: list) -> None:
|
||||
mode = feed_row.get("notification_mode", "agent")
|
||||
count = len(entries)
|
||||
items_text = _format_items(entries)
|
||||
message = (
|
||||
f"{count} new item{'s' if count != 1 else ''} in feed: {feed_row['name']}\n"
|
||||
f"URL: {feed_row['url']}\n\n"
|
||||
f"{items_text}"
|
||||
)
|
||||
|
||||
if mode in ("pushover", "both"):
|
||||
try:
|
||||
from ..tools.pushover_tool import PushoverTool
|
||||
await PushoverTool().execute(
|
||||
title=f"RSS: {count} new in {feed_row['name']}",
|
||||
message=items_text[:512],
|
||||
priority=0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[rss-monitor] Pushover notify failed: %s", e)
|
||||
|
||||
if mode in ("agent", "both"):
|
||||
agent_id = feed_row.get("agent_id")
|
||||
if agent_id:
|
||||
try:
|
||||
from ..agents.runner import agent_runner
|
||||
await agent_runner.run_agent_now(
|
||||
agent_id=agent_id,
|
||||
override_message=message,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[rss-monitor] Agent dispatch failed: %s", e)
|
||||
|
||||
|
||||
rss_monitor = RssFeedManager()
|
||||
203
server/monitors/store.py
Normal file
203
server/monitors/store.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
monitors/store.py — DB CRUD for watched_pages and rss_feeds tables.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..database import _rowcount, get_pool
|
||||
|
||||
|
||||
def _utcnow() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _page_row(row) -> dict:
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _feed_row(row) -> dict:
|
||||
d = dict(row)
|
||||
if isinstance(d.get("seen_item_ids"), str):
|
||||
try:
|
||||
d["seen_item_ids"] = json.loads(d["seen_item_ids"])
|
||||
except Exception:
|
||||
d["seen_item_ids"] = []
|
||||
return d
|
||||
|
||||
|
||||
# ─── Watched Pages ────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_watched_page(
|
||||
name: str,
|
||||
url: str,
|
||||
schedule: str = "0 * * * *",
|
||||
css_selector: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
notification_mode: str = "agent",
|
||||
owner_user_id: str | None = None,
|
||||
) -> dict:
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"""
|
||||
INSERT INTO watched_pages
|
||||
(name, url, schedule, css_selector, agent_id, notification_mode, owner_user_id, created_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *
|
||||
""",
|
||||
name, url, schedule, css_selector, agent_id or None, notification_mode,
|
||||
owner_user_id, _utcnow(),
|
||||
)
|
||||
return _page_row(row)
|
||||
|
||||
|
||||
async def list_watched_pages(owner_user_id: str | None = None) -> list[dict]:
|
||||
pool = await get_pool()
|
||||
if owner_user_id:
|
||||
rows = await pool.fetch(
|
||||
"SELECT * FROM watched_pages WHERE owner_user_id = $1 ORDER BY created_at DESC",
|
||||
owner_user_id,
|
||||
)
|
||||
else:
|
||||
rows = await pool.fetch("SELECT * FROM watched_pages ORDER BY created_at DESC")
|
||||
return [_page_row(r) for r in rows]
|
||||
|
||||
|
||||
async def get_watched_page(page_id: str) -> dict | None:
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow("SELECT * FROM watched_pages WHERE id = $1::uuid", page_id)
|
||||
return _page_row(row) if row else None
|
||||
|
||||
|
||||
async def update_watched_page(page_id: str, **fields) -> dict | None:
|
||||
allowed = {"name", "url", "schedule", "css_selector", "agent_id", "notification_mode", "enabled"}
|
||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not updates:
|
||||
return await get_watched_page(page_id)
|
||||
pool = await get_pool()
|
||||
set_clauses = ", ".join(f"{k} = ${i + 2}" for i, k in enumerate(updates))
|
||||
await pool.execute(
|
||||
f"UPDATE watched_pages SET {set_clauses} WHERE id = $1::uuid",
|
||||
page_id, *updates.values(),
|
||||
)
|
||||
return await get_watched_page(page_id)
|
||||
|
||||
|
||||
async def delete_watched_page(page_id: str) -> bool:
|
||||
pool = await get_pool()
|
||||
status = await pool.execute("DELETE FROM watched_pages WHERE id = $1::uuid", page_id)
|
||||
return _rowcount(status) > 0
|
||||
|
||||
|
||||
async def update_page_check_result(
|
||||
page_id: str,
|
||||
content_hash: str | None,
|
||||
changed: bool,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
pool = await get_pool()
|
||||
now = _utcnow()
|
||||
if error:
|
||||
await pool.execute(
|
||||
"UPDATE watched_pages SET last_checked_at=$1, last_error=$2 WHERE id=$3::uuid",
|
||||
now, error, page_id,
|
||||
)
|
||||
elif changed:
|
||||
await pool.execute(
|
||||
"""UPDATE watched_pages
|
||||
SET last_checked_at=$1, last_content_hash=$2, last_changed_at=$1, last_error=NULL
|
||||
WHERE id=$3::uuid""",
|
||||
now, content_hash, page_id,
|
||||
)
|
||||
else:
|
||||
await pool.execute(
|
||||
"""UPDATE watched_pages
|
||||
SET last_checked_at=$1, last_content_hash=$2, last_error=NULL
|
||||
WHERE id=$3::uuid""",
|
||||
now, content_hash, page_id,
|
||||
)
|
||||
|
||||
|
||||
# ─── RSS Feeds ────────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_rss_feed(
|
||||
name: str,
|
||||
url: str,
|
||||
schedule: str = "0 */4 * * *",
|
||||
agent_id: str | None = None,
|
||||
notification_mode: str = "agent",
|
||||
max_items_per_run: int = 5,
|
||||
owner_user_id: str | None = None,
|
||||
) -> dict:
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"""
|
||||
INSERT INTO rss_feeds
|
||||
(name, url, schedule, agent_id, notification_mode, max_items_per_run, owner_user_id, created_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *
|
||||
""",
|
||||
name, url, schedule, agent_id or None, notification_mode, max_items_per_run,
|
||||
owner_user_id, _utcnow(),
|
||||
)
|
||||
return _feed_row(row)
|
||||
|
||||
|
||||
async def list_rss_feeds(owner_user_id: str | None = None) -> list[dict]:
|
||||
pool = await get_pool()
|
||||
if owner_user_id:
|
||||
rows = await pool.fetch(
|
||||
"SELECT * FROM rss_feeds WHERE owner_user_id = $1 ORDER BY created_at DESC",
|
||||
owner_user_id,
|
||||
)
|
||||
else:
|
||||
rows = await pool.fetch("SELECT * FROM rss_feeds ORDER BY created_at DESC")
|
||||
return [_feed_row(r) for r in rows]
|
||||
|
||||
|
||||
async def get_rss_feed(feed_id: str) -> dict | None:
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow("SELECT * FROM rss_feeds WHERE id = $1::uuid", feed_id)
|
||||
return _feed_row(row) if row else None
|
||||
|
||||
|
||||
async def update_rss_feed(feed_id: str, **fields) -> dict | None:
|
||||
allowed = {"name", "url", "schedule", "agent_id", "notification_mode", "max_items_per_run", "enabled"}
|
||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not updates:
|
||||
return await get_rss_feed(feed_id)
|
||||
pool = await get_pool()
|
||||
set_clauses = ", ".join(f"{k} = ${i + 2}" for i, k in enumerate(updates))
|
||||
await pool.execute(
|
||||
f"UPDATE rss_feeds SET {set_clauses} WHERE id = $1::uuid",
|
||||
feed_id, *updates.values(),
|
||||
)
|
||||
return await get_rss_feed(feed_id)
|
||||
|
||||
|
||||
async def delete_rss_feed(feed_id: str) -> bool:
|
||||
pool = await get_pool()
|
||||
status = await pool.execute("DELETE FROM rss_feeds WHERE id = $1::uuid", feed_id)
|
||||
return _rowcount(status) > 0
|
||||
|
||||
|
||||
async def update_feed_fetch_result(
|
||||
feed_id: str,
|
||||
seen_item_ids: list[str],
|
||||
etag: str | None = None,
|
||||
last_modified: str | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
pool = await get_pool()
|
||||
now = _utcnow()
|
||||
if error:
|
||||
await pool.execute(
|
||||
"UPDATE rss_feeds SET last_fetched_at=$1, last_error=$2 WHERE id=$3::uuid",
|
||||
now, error, feed_id,
|
||||
)
|
||||
else:
|
||||
await pool.execute(
|
||||
"""UPDATE rss_feeds
|
||||
SET last_fetched_at=$1, seen_item_ids=$2, last_etag=$3, last_modified=$4, last_error=NULL
|
||||
WHERE id=$5::uuid""",
|
||||
now, seen_item_ids, etag, last_modified, feed_id,
|
||||
)
|
||||
@@ -20,24 +20,30 @@ def build_registry(include_mock: bool = False, is_admin: bool = True):
|
||||
# Production tools — each imported lazily to avoid errors if optional
|
||||
# dependencies are missing during development
|
||||
from .brain_tool import BrainTool
|
||||
from .browser_tool import BrowserTool
|
||||
from .caldav_tool import CalDAVTool
|
||||
from .contacts_tool import ContactsTool
|
||||
from .email_tool import EmailTool
|
||||
from .filesystem_tool import FilesystemTool
|
||||
from .image_gen_tool import ImageGenTool
|
||||
from .pushover_tool import PushoverTool
|
||||
from .telegram_tool import TelegramTool
|
||||
from .web_tool import WebTool
|
||||
from .webhook_tool import WebhookTool
|
||||
from .whitelist_tool import WhitelistTool
|
||||
|
||||
if is_admin:
|
||||
from .bash_tool import BashTool
|
||||
registry.register(BashTool())
|
||||
registry.register(BrainTool())
|
||||
registry.register(BrowserTool())
|
||||
registry.register(CalDAVTool())
|
||||
registry.register(ContactsTool())
|
||||
registry.register(EmailTool())
|
||||
registry.register(FilesystemTool())
|
||||
registry.register(ImageGenTool())
|
||||
registry.register(WebTool())
|
||||
registry.register(WebhookTool())
|
||||
registry.register(PushoverTool())
|
||||
registry.register(TelegramTool())
|
||||
registry.register(WhitelistTool())
|
||||
|
||||
149
server/tools/browser_tool.py
Normal file
149
server/tools/browser_tool.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
tools/browser_tool.py — Playwright headless browser tool.
|
||||
|
||||
For JS-heavy pages that httpx can't render. Enforces the same Tier 1/2
|
||||
web whitelist as WebTool. Browser instance is lazy-initialized and shared
|
||||
across calls.
|
||||
|
||||
Requires: playwright package + `playwright install chromium`
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import ClassVar
|
||||
|
||||
from ..context_vars import current_task_id, web_tier2_enabled
|
||||
from ..security import assert_domain_tier1, sanitize_external_content
|
||||
from .base import BaseTool, ToolResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_TEXT_CHARS = 25_000
|
||||
_TIMEOUT_MS = 30_000
|
||||
|
||||
|
||||
class BrowserTool(BaseTool):
|
||||
name = "browser"
|
||||
description = (
|
||||
"Fetch web pages using a real headless browser (Chromium). "
|
||||
"Use this for JS-heavy pages or single-page apps that the regular 'web' tool cannot read. "
|
||||
"Operations: fetch_page (extract text content), screenshot (base64 PNG). "
|
||||
"Follows the same domain whitelist rules as the web tool."
|
||||
)
|
||||
input_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["fetch_page", "screenshot"],
|
||||
"description": "fetch_page extracts text; screenshot returns a base64 PNG.",
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL to navigate to.",
|
||||
},
|
||||
"wait_for": {
|
||||
"type": "string",
|
||||
"description": "CSS selector to wait for before extracting content (optional).",
|
||||
},
|
||||
"extract_selector": {
|
||||
"type": "string",
|
||||
"description": "CSS selector to extract text from (optional; defaults to full page).",
|
||||
},
|
||||
},
|
||||
"required": ["operation", "url"],
|
||||
}
|
||||
requires_confirmation = False
|
||||
allowed_in_scheduled_tasks = False # Too resource-heavy for scheduled agents
|
||||
|
||||
# Module-level shared browser/playwright (lazy-init, reused)
|
||||
_playwright = None
|
||||
_browser = None
|
||||
_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
|
||||
|
||||
async def execute(self, operation: str, url: str = "", wait_for: str = "", extract_selector: str = "", **_) -> ToolResult:
|
||||
if not url:
|
||||
return ToolResult(success=False, error="'url' is required")
|
||||
|
||||
# Whitelist check (same Tier 1/2 rules as WebTool)
|
||||
denied = await self._check_tier(url)
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
except ImportError:
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error="Playwright is not installed. Run: pip install playwright && playwright install chromium",
|
||||
)
|
||||
|
||||
try:
|
||||
browser = await self._get_browser()
|
||||
context = await browser.new_context(
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
)
|
||||
page = await context.new_page()
|
||||
try:
|
||||
await page.goto(url, timeout=_TIMEOUT_MS, wait_until="domcontentloaded")
|
||||
|
||||
if wait_for:
|
||||
try:
|
||||
await page.wait_for_selector(wait_for, timeout=10_000)
|
||||
except Exception:
|
||||
pass # continue even if selector doesn't appear
|
||||
|
||||
if operation == "screenshot":
|
||||
data = await page.screenshot(type="png")
|
||||
import base64
|
||||
return ToolResult(success=True, data={"screenshot_base64": base64.b64encode(data).decode()})
|
||||
|
||||
# fetch_page
|
||||
if extract_selector:
|
||||
elements = await page.query_selector_all(extract_selector)
|
||||
text_parts = [await el.inner_text() for el in elements]
|
||||
text = "\n".join(text_parts)
|
||||
else:
|
||||
text = await page.inner_text("body")
|
||||
|
||||
text = text[:_MAX_TEXT_CHARS]
|
||||
text = await sanitize_external_content(text, source="browser")
|
||||
return ToolResult(success=True, data={"url": url, "text": text, "length": len(text)})
|
||||
finally:
|
||||
await context.close()
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, error=f"Browser error: {e}")
|
||||
|
||||
async def _get_browser(self):
|
||||
async with BrowserTool._lock:
|
||||
if BrowserTool._browser is None or not BrowserTool._browser.is_connected():
|
||||
from playwright.async_api import async_playwright
|
||||
BrowserTool._playwright = await async_playwright().start()
|
||||
BrowserTool._browser = await BrowserTool._playwright.chromium.launch(
|
||||
args=["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
)
|
||||
logger.info("[browser] Chromium launched")
|
||||
return BrowserTool._browser
|
||||
|
||||
async def _check_tier(self, url: str) -> ToolResult | None:
|
||||
"""Returns ToolResult(success=False) if denied, None if allowed."""
|
||||
from urllib.parse import urlparse
|
||||
if await assert_domain_tier1(url):
|
||||
return None
|
||||
task_id = current_task_id.get()
|
||||
if task_id is not None:
|
||||
return None
|
||||
if web_tier2_enabled.get():
|
||||
return None
|
||||
parsed = urlparse(url)
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error=(
|
||||
f"Domain '{parsed.hostname}' is not in the Tier 1 whitelist. "
|
||||
"Ask me to fetch a specific external page to enable Tier 2 access."
|
||||
),
|
||||
)
|
||||
@@ -37,32 +37,21 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
async def _get_caldav_config(user_id: str | None = None) -> dict:
|
||||
"""
|
||||
Two-layer CalDAV config lookup: user_settings → credential_store (global fallback).
|
||||
Per-user CalDAV config lookup — no system-wide fallback.
|
||||
|
||||
Keys in user_settings: caldav_url, caldav_username, caldav_password, caldav_calendar_name
|
||||
Keys in credential_store: mailcow_host, mailcow_username, mailcow_password, caldav_calendar_name
|
||||
|
||||
Returns a dict with url, username, password, calendar_name (any may be None).
|
||||
Each user (including admin) configures their own CalDAV credentials in Settings → CalDAV.
|
||||
Returns a dict with url, username, password, calendar_name (any may be None/empty).
|
||||
"""
|
||||
if user_id:
|
||||
from ..database import user_settings_store
|
||||
url = await user_settings_store.get(user_id, "caldav_url")
|
||||
if url:
|
||||
return {
|
||||
"url": url,
|
||||
"username": await user_settings_store.get(user_id, "caldav_username"),
|
||||
"password": await user_settings_store.get(user_id, "caldav_password"),
|
||||
"calendar_name": await user_settings_store.get(user_id, "caldav_calendar_name"),
|
||||
}
|
||||
return {
|
||||
"url": await user_settings_store.get(user_id, "caldav_url"),
|
||||
"username": await user_settings_store.get(user_id, "caldav_username"),
|
||||
"password": await user_settings_store.get(user_id, "caldav_password"),
|
||||
"calendar_name": await user_settings_store.get(user_id, "caldav_calendar_name"),
|
||||
}
|
||||
|
||||
# Fall back to global credential_store
|
||||
host = await credential_store.get("mailcow_host")
|
||||
return {
|
||||
"url": f"https://{host}/SOGo/dav/" if host else None,
|
||||
"username": await credential_store.get("mailcow_username"),
|
||||
"password": await credential_store.get("mailcow_password"),
|
||||
"calendar_name": await credential_store.get("caldav_calendar_name"),
|
||||
}
|
||||
return {"url": None, "username": None, "password": None, "calendar_name": None}
|
||||
|
||||
MAX_EVENTS = 100
|
||||
|
||||
@@ -135,11 +124,15 @@ class CalDAVTool(BaseTool):
|
||||
if not url or not username or not password:
|
||||
raise RuntimeError(
|
||||
"CalDAV credentials not configured. "
|
||||
"Set them in Settings → My Settings → CalDAV, or ask the admin to configure global CalDAV."
|
||||
"Set them in Settings → CalDAV / CardDAV."
|
||||
)
|
||||
|
||||
# Normalise scheme — users often enter just the hostname
|
||||
if not url.startswith(("http://", "https://")):
|
||||
url = "https://" + url
|
||||
|
||||
# Build principal URL: if the stored URL is already the full principal URL use it directly;
|
||||
# otherwise append the SOGo-style path (backward compat with old mailcow_host keys).
|
||||
# otherwise append the SOGo-style path.
|
||||
if "/SOGo/dav/" in url or url.rstrip("/").endswith(username):
|
||||
principal_url = url.rstrip("/") + "/"
|
||||
else:
|
||||
|
||||
423
server/tools/contacts_tool.py
Normal file
423
server/tools/contacts_tool.py
Normal file
@@ -0,0 +1,423 @@
|
||||
"""
|
||||
tools/contacts_tool.py — CardDAV contacts access (Mailcow / SOGo).
|
||||
|
||||
Uses httpx directly for CardDAV protocol (caldav 2.x dropped AddressBook support).
|
||||
Address book URL format: {base_url}/SOGo/dav/{username}/Contacts/personal/
|
||||
|
||||
Read operations: list_contacts, search_contacts, get_contact (always available)
|
||||
Write operations: create_contact, update_contact, delete_contact
|
||||
→ only available when credential_store key 'contacts:allow_write' == '1'
|
||||
→ all write ops require user confirmation
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid as _uuid
|
||||
from urllib.parse import urlparse
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import httpx
|
||||
import vobject
|
||||
|
||||
from ..context_vars import current_user
|
||||
from ..database import credential_store
|
||||
from .base import BaseTool, ToolResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_CONTACTS = 100
|
||||
|
||||
_CARDDAV_NS = "urn:ietf:params:xml:ns:carddav"
|
||||
_DAV_NS = "DAV:"
|
||||
|
||||
|
||||
# ── URL helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _sogo_carddav_url(base_url: str, username: str) -> str:
|
||||
"""Return the SOGo CardDAV personal address book URL for the given user."""
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
base_url = "https://" + base_url
|
||||
if "/SOGo/dav/" in base_url or base_url.rstrip("/").endswith("/Contacts/personal"):
|
||||
return base_url.rstrip("/") + "/"
|
||||
return f"{base_url.rstrip('/')}/SOGo/dav/{username}/Contacts/personal/"
|
||||
|
||||
|
||||
def _abs_href(href: str, base_url: str) -> str:
|
||||
"""Convert a relative href to an absolute URL using base_url's scheme+host."""
|
||||
if href.startswith("http://") or href.startswith("https://"):
|
||||
return href
|
||||
parsed = urlparse(base_url)
|
||||
return f"{parsed.scheme}://{parsed.netloc}{href}"
|
||||
|
||||
|
||||
# ── vCard helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _vcard_to_dict(vcard_text: str, contact_url: str = "") -> dict:
|
||||
"""Parse a vCard string into a structured dict."""
|
||||
try:
|
||||
vc = vobject.readOne(vcard_text)
|
||||
except Exception:
|
||||
return {"id": contact_url, "raw": vcard_text[:200]}
|
||||
|
||||
def _get(component: str) -> str:
|
||||
try:
|
||||
return str(getattr(vc, component).value)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _get_list(component: str) -> list[str]:
|
||||
try:
|
||||
items = vc.contents.get(component.lower(), [])
|
||||
return [str(item.value) for item in items]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
name = _get("fn")
|
||||
emails = _get_list("email")
|
||||
phones = _get_list("tel")
|
||||
org = _get("org")
|
||||
note = _get("note")
|
||||
uid = _get("uid") or contact_url
|
||||
|
||||
result: dict = {"id": contact_url or uid, "name": name}
|
||||
if emails:
|
||||
result["email"] = emails[0]
|
||||
if len(emails) > 1:
|
||||
result["emails"] = emails
|
||||
if phones:
|
||||
result["phone"] = phones[0]
|
||||
if len(phones) > 1:
|
||||
result["phones"] = phones
|
||||
if org:
|
||||
result["organization"] = org
|
||||
if note:
|
||||
result["note"] = note[:200]
|
||||
return result
|
||||
|
||||
|
||||
# ── Credential helpers ────────────────────────────────────────────────────────
|
||||
|
||||
async def _get_carddav_config(user_id: str | None = None) -> dict:
|
||||
"""
|
||||
Per-user CardDAV config lookup — no system-wide fallback.
|
||||
|
||||
If carddav_same_as_caldav is set, reuses the user's own CalDAV credentials.
|
||||
Otherwise uses the user's explicit CardDAV URL/username/password.
|
||||
Returns empty dict values when not configured.
|
||||
"""
|
||||
from .caldav_tool import _get_caldav_config
|
||||
|
||||
if not user_id:
|
||||
return {"url": None, "username": None, "password": None}
|
||||
|
||||
from ..database import user_settings_store
|
||||
same = await user_settings_store.get(user_id, "carddav_same_as_caldav")
|
||||
if same == "1":
|
||||
return await _get_caldav_config(user_id)
|
||||
|
||||
carddav_url = await user_settings_store.get(user_id, "carddav_url")
|
||||
carddav_user = await user_settings_store.get(user_id, "carddav_username")
|
||||
carddav_pass = await user_settings_store.get(user_id, "carddav_password")
|
||||
return {"url": carddav_url, "username": carddav_user, "password": carddav_pass}
|
||||
|
||||
|
||||
# ── Low-level CardDAV HTTP ────────────────────────────────────────────────────
|
||||
|
||||
async def _carddav_report(abook_url: str, auth: tuple[str, str]) -> list[dict]:
|
||||
"""
|
||||
Issue an addressbook-query REPORT to fetch all vCards.
|
||||
Returns list of {"url": str, "vcard_text": str}.
|
||||
"""
|
||||
body = (
|
||||
'<?xml version="1.0" encoding="utf-8"?>'
|
||||
'<C:addressbook-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">'
|
||||
"<D:prop><D:getetag/><C:address-data/></D:prop>"
|
||||
"<C:filter/>"
|
||||
"</C:addressbook-query>"
|
||||
)
|
||||
async with httpx.AsyncClient(auth=auth, timeout=30) as client:
|
||||
r = await client.request(
|
||||
"REPORT",
|
||||
abook_url,
|
||||
content=body,
|
||||
headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"},
|
||||
)
|
||||
if r.status_code != 207:
|
||||
raise RuntimeError(f"CardDAV REPORT returned HTTP {r.status_code}")
|
||||
|
||||
root = ET.fromstring(r.content)
|
||||
results = []
|
||||
for resp in root.findall(f"{{{_DAV_NS}}}response"):
|
||||
href_el = resp.find(f"{{{_DAV_NS}}}href")
|
||||
status_el = resp.find(f"{{{_DAV_NS}}}propstat/{{{_DAV_NS}}}status")
|
||||
data_el = resp.find(
|
||||
f"{{{_DAV_NS}}}propstat/{{{_DAV_NS}}}prop/{{{_CARDDAV_NS}}}address-data"
|
||||
)
|
||||
if href_el is None or data_el is None or not (data_el.text or "").strip():
|
||||
continue
|
||||
if status_el is not None and "200" not in (status_el.text or ""):
|
||||
continue
|
||||
abs_url = _abs_href(href_el.text, abook_url)
|
||||
results.append({"url": abs_url, "vcard_text": data_el.text})
|
||||
return results
|
||||
|
||||
|
||||
async def _carddav_put(url: str, vcard_text: str, auth: tuple[str, str], etag: str = "") -> None:
|
||||
headers: dict = {"Content-Type": "text/vcard; charset=utf-8"}
|
||||
if etag:
|
||||
headers["If-Match"] = etag
|
||||
async with httpx.AsyncClient(auth=auth, timeout=15) as client:
|
||||
r = await client.put(url, content=vcard_text.encode(), headers=headers)
|
||||
if r.status_code not in (200, 201, 204):
|
||||
raise RuntimeError(f"CardDAV PUT returned HTTP {r.status_code}")
|
||||
|
||||
|
||||
async def _carddav_delete(url: str, auth: tuple[str, str]) -> None:
|
||||
async with httpx.AsyncClient(auth=auth, timeout=15) as client:
|
||||
r = await client.delete(url)
|
||||
if r.status_code not in (200, 204):
|
||||
raise RuntimeError(f"CardDAV DELETE returned HTTP {r.status_code}")
|
||||
|
||||
|
||||
# ── Tool ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
_WRITE_OPS = {"create_contact", "update_contact", "delete_contact"}
|
||||
|
||||
|
||||
class ContactsTool(BaseTool):
|
||||
name = "contacts"
|
||||
description = (
|
||||
"Search, read, and (if write access is enabled) modify contacts in the CardDAV address book. "
|
||||
"Read operations: list_contacts, search_contacts, get_contact. "
|
||||
"Write operations (require contacts:allow_write=1): create_contact, update_contact, delete_contact. "
|
||||
"Uses the same CalDAV server credentials."
|
||||
)
|
||||
input_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"list_contacts", "search_contacts", "get_contact",
|
||||
"create_contact", "update_contact", "delete_contact",
|
||||
],
|
||||
"description": "Operation to perform.",
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query (name or email substring). Required for search_contacts.",
|
||||
},
|
||||
"contact_id": {
|
||||
"type": "string",
|
||||
"description": "Contact URL or UID. Required for get_contact, update_contact, delete_contact.",
|
||||
},
|
||||
"name": {"type": "string", "description": "Full display name. Required for create_contact."},
|
||||
"email": {"type": "string", "description": "Primary email address."},
|
||||
"phone": {"type": "string", "description": "Primary phone number."},
|
||||
"organization": {"type": "string", "description": "Organization / company name."},
|
||||
"note": {"type": "string", "description": "Free-text note."},
|
||||
},
|
||||
"required": ["operation"],
|
||||
}
|
||||
requires_confirmation = False
|
||||
allowed_in_scheduled_tasks = True
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
operation: str,
|
||||
query: str = "",
|
||||
contact_id: str = "",
|
||||
name: str = "",
|
||||
email: str = "",
|
||||
phone: str = "",
|
||||
organization: str = "",
|
||||
note: str = "",
|
||||
**_,
|
||||
) -> ToolResult:
|
||||
user_id = None
|
||||
try:
|
||||
u = current_user.get()
|
||||
if u:
|
||||
user_id = u.id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Gate write operations
|
||||
if operation in _WRITE_OPS:
|
||||
allow_write = False
|
||||
if user_id:
|
||||
from ..database import user_settings_store
|
||||
per_user = await user_settings_store.get(user_id, "contacts_allow_write")
|
||||
allow_write = (per_user == "1")
|
||||
if not allow_write:
|
||||
allow_write = (await credential_store.get("contacts:allow_write") == "1")
|
||||
if not allow_write:
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error=(
|
||||
"Contact write access is disabled. "
|
||||
"Enable it in Settings → CalDAV/CardDAV."
|
||||
),
|
||||
)
|
||||
|
||||
cfg = await _get_carddav_config(user_id)
|
||||
base_url = cfg.get("url")
|
||||
username = cfg.get("username")
|
||||
password = cfg.get("password")
|
||||
|
||||
if not base_url or not username or not password:
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error=(
|
||||
"CardDAV credentials not configured. "
|
||||
"Set them in Settings → CalDAV/CardDAV."
|
||||
),
|
||||
)
|
||||
|
||||
abook_url = _sogo_carddav_url(base_url, username)
|
||||
auth = (username, password)
|
||||
|
||||
if operation == "list_contacts":
|
||||
return await self._list_contacts(abook_url, auth)
|
||||
if operation == "search_contacts":
|
||||
if not query:
|
||||
return ToolResult(success=False, error="'query' is required for search_contacts")
|
||||
return await self._search_contacts(abook_url, auth, query)
|
||||
if operation == "get_contact":
|
||||
if not contact_id:
|
||||
return ToolResult(success=False, error="'contact_id' is required for get_contact")
|
||||
return await self._get_contact(abook_url, auth, contact_id)
|
||||
if operation == "create_contact":
|
||||
if not name:
|
||||
return ToolResult(success=False, error="'name' is required for create_contact")
|
||||
return await self._create_contact(abook_url, auth, name, email, phone, organization, note)
|
||||
if operation == "update_contact":
|
||||
if not contact_id:
|
||||
return ToolResult(success=False, error="'contact_id' is required for update_contact")
|
||||
return await self._update_contact(abook_url, auth, contact_id, name, email, phone, organization, note)
|
||||
if operation == "delete_contact":
|
||||
if not contact_id:
|
||||
return ToolResult(success=False, error="'contact_id' is required for delete_contact")
|
||||
return await self._delete_contact(abook_url, auth, contact_id)
|
||||
return ToolResult(success=False, error=f"Unknown operation: {operation}")
|
||||
|
||||
# ── Read operations ────────────────────────────────────────────────────────
|
||||
|
||||
async def _list_contacts(self, abook_url: str, auth: tuple) -> ToolResult:
|
||||
try:
|
||||
items = await _carddav_report(abook_url, auth)
|
||||
contacts = [_vcard_to_dict(it["vcard_text"], it["url"]) for it in items[:MAX_CONTACTS]]
|
||||
contacts.sort(key=lambda c: c.get("name", "").lower())
|
||||
return ToolResult(success=True, data={"contacts": contacts, "count": len(contacts)})
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, error=f"Failed to list contacts: {e}")
|
||||
|
||||
async def _search_contacts(self, abook_url: str, auth: tuple, query: str) -> ToolResult:
|
||||
try:
|
||||
q_lower = query.lower()
|
||||
items = await _carddav_report(abook_url, auth)
|
||||
matches = []
|
||||
for it in items:
|
||||
d = _vcard_to_dict(it["vcard_text"], it["url"])
|
||||
name_match = q_lower in d.get("name", "").lower()
|
||||
email_match = any(q_lower in e.lower() for e in ([d.get("email", "")] + d.get("emails", [])))
|
||||
if name_match or email_match:
|
||||
matches.append(d)
|
||||
if len(matches) >= MAX_CONTACTS:
|
||||
break
|
||||
matches.sort(key=lambda c: c.get("name", "").lower())
|
||||
return ToolResult(success=True, data={"contacts": matches, "count": len(matches), "query": query})
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, error=f"Failed to search contacts: {e}")
|
||||
|
||||
async def _get_contact(self, abook_url: str, auth: tuple, contact_id: str) -> ToolResult:
|
||||
try:
|
||||
items = await _carddav_report(abook_url, auth)
|
||||
for it in items:
|
||||
d = _vcard_to_dict(it["vcard_text"], it["url"])
|
||||
if d.get("id") == contact_id or contact_id in it["url"]:
|
||||
return ToolResult(success=True, data=d)
|
||||
return ToolResult(success=False, error=f"Contact '{contact_id}' not found")
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, error=f"Failed to get contact: {e}")
|
||||
|
||||
# ── Write operations ───────────────────────────────────────────────────────
|
||||
|
||||
def _build_vcard(
|
||||
self,
|
||||
name: str,
|
||||
email: str = "",
|
||||
phone: str = "",
|
||||
organization: str = "",
|
||||
note: str = "",
|
||||
uid: str | None = None,
|
||||
) -> str:
|
||||
vc = vobject.vCard()
|
||||
vc.add("fn").value = name
|
||||
parts = name.split(" ", 1)
|
||||
n = vobject.vcard.Name(family=parts[-1], given=parts[0] if len(parts) > 1 else "")
|
||||
vc.add("n").value = n
|
||||
vc.add("uid").value = uid or str(_uuid.uuid4())
|
||||
if email:
|
||||
email_obj = vc.add("email")
|
||||
email_obj.value = email
|
||||
email_obj.type_param = "INTERNET"
|
||||
if phone:
|
||||
tel_obj = vc.add("tel")
|
||||
tel_obj.value = phone
|
||||
tel_obj.type_param = "VOICE"
|
||||
if organization:
|
||||
vc.add("org").value = [organization]
|
||||
if note:
|
||||
vc.add("note").value = note
|
||||
return vc.serialize()
|
||||
|
||||
async def _create_contact(
|
||||
self, abook_url: str, auth: tuple, name: str, email: str, phone: str, organization: str, note: str
|
||||
) -> ToolResult:
|
||||
try:
|
||||
uid = str(_uuid.uuid4())
|
||||
vcard_text = self._build_vcard(name, email, phone, organization, note, uid=uid)
|
||||
url = abook_url.rstrip("/") + f"/{uid}.vcf"
|
||||
await _carddav_put(url, vcard_text, auth)
|
||||
return ToolResult(success=True, data={"created": name, "id": url})
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, error=f"Failed to create contact: {e}")
|
||||
|
||||
async def _update_contact(
|
||||
self, abook_url: str, auth: tuple, contact_id: str, name: str, email: str, phone: str, organization: str, note: str
|
||||
) -> ToolResult:
|
||||
try:
|
||||
items = await _carddav_report(abook_url, auth)
|
||||
for it in items:
|
||||
d = _vcard_to_dict(it["vcard_text"], it["url"])
|
||||
if d.get("id") == contact_id or contact_id in it["url"]:
|
||||
new_name = name or d.get("name", "")
|
||||
new_email = email or d.get("email", "")
|
||||
new_phone = phone or d.get("phone", "")
|
||||
new_org = organization or d.get("organization", "")
|
||||
new_note = note or d.get("note", "")
|
||||
# Extract UID from original vCard to reuse
|
||||
try:
|
||||
vc_orig = vobject.readOne(it["vcard_text"])
|
||||
uid = str(vc_orig.uid.value)
|
||||
except Exception:
|
||||
uid = None
|
||||
new_vcard = self._build_vcard(new_name, new_email, new_phone, new_org, new_note, uid=uid)
|
||||
await _carddav_put(it["url"], new_vcard, auth)
|
||||
return ToolResult(success=True, data={"updated": new_name})
|
||||
return ToolResult(success=False, error=f"Contact '{contact_id}' not found")
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, error=f"Failed to update contact: {e}")
|
||||
|
||||
async def _delete_contact(self, abook_url: str, auth: tuple, contact_id: str) -> ToolResult:
|
||||
try:
|
||||
items = await _carddav_report(abook_url, auth)
|
||||
for it in items:
|
||||
d = _vcard_to_dict(it["vcard_text"], it["url"])
|
||||
if d.get("id") == contact_id or contact_id in it["url"]:
|
||||
await _carddav_delete(it["url"], auth)
|
||||
return ToolResult(success=True, data={"deleted": d.get("name", contact_id)})
|
||||
return ToolResult(success=False, error=f"Contact '{contact_id}' not found")
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, error=f"Failed to delete contact: {e}")
|
||||
@@ -72,13 +72,26 @@ class PushoverTool(BaseTool):
|
||||
# If we got here, it was approved.
|
||||
pass
|
||||
|
||||
# Load credentials
|
||||
# Load credentials — per-user key first, then system fallback
|
||||
try:
|
||||
app_token = await credential_store.require("pushover_app_token")
|
||||
user_key = await credential_store.require("pushover_user_key")
|
||||
except RuntimeError as e:
|
||||
return ToolResult(success=False, error=str(e))
|
||||
|
||||
user_key: str | None = None
|
||||
try:
|
||||
from ..context_vars import current_user as _cu
|
||||
u = _cu.get()
|
||||
if u:
|
||||
from ..database import user_settings_store as _us
|
||||
user_key = await _us.get(u.id, "pushover_user_key")
|
||||
except Exception:
|
||||
pass
|
||||
if not user_key:
|
||||
user_key = await credential_store.get("pushover_user_key")
|
||||
if not user_key:
|
||||
return ToolResult(success=False, error="Pushover user key not configured. Set it in Settings → Pushover.")
|
||||
|
||||
payload: dict = {
|
||||
"token": app_token,
|
||||
"user": user_key,
|
||||
|
||||
133
server/tools/webhook_tool.py
Normal file
133
server/tools/webhook_tool.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
tools/webhook_tool.py — Outbound webhook tool.
|
||||
|
||||
Agents can POST a JSON payload to a pre-configured named target in the
|
||||
webhook_targets table. The target URL and optional auth secret are managed
|
||||
via Settings → Webhooks → Outbound Targets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from ..context_vars import current_user
|
||||
from .base import BaseTool, ToolResult
|
||||
|
||||
|
||||
class WebhookTool(BaseTool):
|
||||
name = "webhook"
|
||||
description = (
|
||||
"Send a JSON payload to a configured outbound webhook target. "
|
||||
"Use this to notify external services (e.g. Home Assistant, Zapier, custom APIs). "
|
||||
"List available targets with operation='list_targets', then send with operation='send'."
|
||||
)
|
||||
input_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["send", "list_targets"],
|
||||
"description": "Operation: 'send' to POST to a target, 'list_targets' to see available targets.",
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "Target name (as configured in Settings → Webhooks). Required for 'send'.",
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"description": "JSON payload to POST. Required for 'send'.",
|
||||
},
|
||||
},
|
||||
"required": ["operation"],
|
||||
}
|
||||
requires_confirmation = True
|
||||
allowed_in_scheduled_tasks = True
|
||||
|
||||
async def execute(self, operation: str, target: str = "", payload: dict | None = None, **_) -> ToolResult:
|
||||
if operation == "list_targets":
|
||||
return await self._list_targets()
|
||||
if operation == "send":
|
||||
if not target:
|
||||
return ToolResult(success=False, error="'target' is required for send operation")
|
||||
return await self._send(target, payload or {})
|
||||
return ToolResult(success=False, error=f"Unknown operation: {operation}")
|
||||
|
||||
def _current_user_id(self) -> str | None:
|
||||
try:
|
||||
u = current_user.get()
|
||||
return u.id if u else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def _list_targets(self) -> ToolResult:
|
||||
from ..database import get_pool
|
||||
pool = await get_pool()
|
||||
user_id = self._current_user_id()
|
||||
if user_id:
|
||||
rows = await pool.fetch(
|
||||
"""
|
||||
SELECT name, url, enabled FROM webhook_targets
|
||||
WHERE (owner_user_id = $1 OR owner_user_id IS NULL)
|
||||
ORDER BY owner_user_id NULLS LAST, name
|
||||
""",
|
||||
user_id,
|
||||
)
|
||||
else:
|
||||
rows = await pool.fetch(
|
||||
"SELECT name, url, enabled FROM webhook_targets WHERE owner_user_id IS NULL ORDER BY name"
|
||||
)
|
||||
targets = [{"name": r["name"], "url": r["url"], "enabled": r["enabled"]} for r in rows]
|
||||
return ToolResult(success=True, data={"targets": targets})
|
||||
|
||||
async def _send(self, target_name: str, payload: dict) -> ToolResult:
|
||||
from ..database import get_pool
|
||||
pool = await get_pool()
|
||||
user_id = self._current_user_id()
|
||||
# User-scoped target takes priority over global (NULL owner) target with same name
|
||||
row = None
|
||||
if user_id:
|
||||
row = await pool.fetchrow(
|
||||
"SELECT * FROM webhook_targets WHERE name = $1 AND owner_user_id = $2 AND enabled = TRUE",
|
||||
target_name, user_id,
|
||||
)
|
||||
if not row:
|
||||
row = await pool.fetchrow(
|
||||
"SELECT * FROM webhook_targets WHERE name = $1 AND owner_user_id IS NULL AND enabled = TRUE",
|
||||
target_name,
|
||||
)
|
||||
if not row:
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error=f"No enabled webhook target named '{target_name}'. Use list_targets to see available targets.",
|
||||
)
|
||||
|
||||
url: str = row["url"]
|
||||
secret: str | None = row.get("secret_header")
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if secret:
|
||||
headers["Authorization"] = f"Bearer {secret}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(url, json=payload, headers=headers)
|
||||
body = resp.text[:500] if resp.text else ""
|
||||
return ToolResult(
|
||||
success=True,
|
||||
data={
|
||||
"status_code": resp.status_code,
|
||||
"ok": resp.is_success,
|
||||
"response": body,
|
||||
},
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
return ToolResult(success=False, error=f"Request to '{target_name}' timed out after 15 seconds")
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, error=f"Request to '{target_name}' failed: {e}")
|
||||
|
||||
def confirmation_description(self, operation: str = "", target: str = "", payload: dict | None = None, **_) -> str:
|
||||
if operation == "send":
|
||||
snippet = json.dumps(payload or {})[:100]
|
||||
return f"POST to webhook target '{target}' with payload: {snippet}"
|
||||
return "List webhook targets"
|
||||
@@ -312,6 +312,7 @@ async def models_info(request: Request):
|
||||
class LimitsIn(BaseModel):
|
||||
max_tool_calls: Optional[int] = None
|
||||
max_autonomous_runs_per_hour: Optional[int] = None
|
||||
max_concurrent_runs: Optional[int] = None
|
||||
|
||||
|
||||
class ProxyTrustIn(BaseModel):
|
||||
@@ -328,16 +329,19 @@ async def get_limits(request: Request):
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
mtc, mar = await asyncio_gather(
|
||||
mtc, mar, mcr = await asyncio_gather(
|
||||
_get("system:max_tool_calls", settings.max_tool_calls),
|
||||
_get("system:max_autonomous_runs_per_hour", settings.max_autonomous_runs_per_hour),
|
||||
_get("system:max_concurrent_runs", 3),
|
||||
)
|
||||
return {
|
||||
"max_tool_calls": mtc,
|
||||
"max_autonomous_runs_per_hour": mar,
|
||||
"max_concurrent_runs": mcr,
|
||||
"defaults": {
|
||||
"max_tool_calls": settings.max_tool_calls,
|
||||
"max_autonomous_runs_per_hour": settings.max_autonomous_runs_per_hour,
|
||||
"max_concurrent_runs": 3,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -359,9 +363,23 @@ async def set_limits(request: Request, body: LimitsIn):
|
||||
"system:max_autonomous_runs_per_hour", str(body.max_autonomous_runs_per_hour),
|
||||
"Max autonomous scheduler runs per hour",
|
||||
)
|
||||
if body.max_concurrent_runs is not None:
|
||||
if body.max_concurrent_runs < 1:
|
||||
raise HTTPException(status_code=400, detail="max_concurrent_runs must be >= 1")
|
||||
await credential_store.set(
|
||||
"system:max_concurrent_runs", str(body.max_concurrent_runs),
|
||||
"Max concurrent agent runs",
|
||||
)
|
||||
return await get_limits()
|
||||
|
||||
|
||||
@router.get("/queue")
|
||||
async def get_queue_status(request: Request):
|
||||
"""Return current run queue status."""
|
||||
_require_auth(request)
|
||||
return agent_runner.queue_status
|
||||
|
||||
|
||||
@router.get("/settings/default-models")
|
||||
async def get_default_models(request: Request):
|
||||
_require_admin(request)
|
||||
@@ -447,6 +465,128 @@ async def set_users_base_folder(request: Request, body: UserBaseFolderIn):
|
||||
return {"path": path}
|
||||
|
||||
|
||||
# ── Admin CalDAV / CardDAV settings ──────────────────────────────────────────
|
||||
|
||||
@router.get("/settings/caldav")
|
||||
async def get_admin_caldav(request: Request):
|
||||
_require_admin(request)
|
||||
get = credential_store.get
|
||||
return {
|
||||
"host": await get("mailcow_host") or "",
|
||||
"username": await get("mailcow_username") or "",
|
||||
"password_set": bool(await get("mailcow_password")),
|
||||
"calendar_name": await get("caldav_calendar_name") or "",
|
||||
"contacts_allow_write": (await get("contacts:allow_write")) == "1",
|
||||
"carddav_same_as_caldav": (await get("carddav_same_as_caldav")) == "1",
|
||||
"carddav_url": await get("carddav_url") or "",
|
||||
"carddav_username": await get("carddav_username") or "",
|
||||
"carddav_password_set": bool(await get("carddav_password")),
|
||||
"imap_host": await get("mailcow_imap_host") or "",
|
||||
"smtp_host": await get("mailcow_smtp_host") or "",
|
||||
"smtp_port": await get("mailcow_smtp_port") or "",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/settings/caldav")
|
||||
async def set_admin_caldav(request: Request):
|
||||
_require_admin(request)
|
||||
body = await request.json()
|
||||
|
||||
async def _set(key, val, desc=""):
|
||||
val = (val or "").strip()
|
||||
if val:
|
||||
await credential_store.set(key, val, desc)
|
||||
else:
|
||||
await credential_store.delete(key)
|
||||
|
||||
async def _set_bool(key, val):
|
||||
if val:
|
||||
await credential_store.set(key, "1")
|
||||
else:
|
||||
await credential_store.delete(key)
|
||||
|
||||
await _set("mailcow_host", body.get("host"), "Mailcow hostname")
|
||||
await _set("mailcow_username", body.get("username"), "Mailcow username")
|
||||
# Only update password if a new value is provided
|
||||
pwd = (body.get("password") or "").strip()
|
||||
if pwd:
|
||||
await credential_store.set("mailcow_password", pwd, "Mailcow password")
|
||||
await _set("caldav_calendar_name", body.get("calendar_name"), "Default calendar name")
|
||||
await _set_bool("contacts:allow_write", body.get("contacts_allow_write"))
|
||||
|
||||
same = bool(body.get("carddav_same_as_caldav"))
|
||||
await _set_bool("carddav_same_as_caldav", same)
|
||||
if same:
|
||||
for k in ("carddav_url", "carddav_username", "carddav_password"):
|
||||
await credential_store.delete(k)
|
||||
else:
|
||||
await _set("carddav_url", body.get("carddav_url"), "CardDAV server URL")
|
||||
await _set("carddav_username", body.get("carddav_username"), "CardDAV username")
|
||||
cpwd = (body.get("carddav_password") or "").strip()
|
||||
if cpwd:
|
||||
await credential_store.set("carddav_password", cpwd, "CardDAV password")
|
||||
|
||||
# Email tool overrides (optional host overrides for IMAP/SMTP)
|
||||
await _set("mailcow_imap_host", body.get("imap_host"), "IMAP host override")
|
||||
await _set("mailcow_smtp_host", body.get("smtp_host"), "SMTP host override")
|
||||
await _set("mailcow_smtp_port", body.get("smtp_port"), "SMTP port override")
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Admin Pushover settings ───────────────────────────────────────────────────
|
||||
|
||||
@router.get("/settings/pushover")
|
||||
async def get_admin_pushover(request: Request):
|
||||
_require_admin(request)
|
||||
return {
|
||||
"app_token_set": bool(await credential_store.get("pushover_app_token")),
|
||||
"user_key_set": bool(await credential_store.get("pushover_user_key")),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/settings/pushover")
|
||||
async def set_admin_pushover(request: Request):
|
||||
_require_admin(request)
|
||||
body = await request.json()
|
||||
for field, key in [("app_token", "pushover_app_token"), ("user_key", "pushover_user_key")]:
|
||||
val = (body.get(field) or "").strip()
|
||||
if val:
|
||||
await credential_store.set(key, val)
|
||||
# Never clear on empty — must explicitly use delete endpoint
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── User Pushover settings ────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/my/pushover")
|
||||
async def get_my_pushover(request: Request):
|
||||
_require_auth(request)
|
||||
user = request.state.current_user
|
||||
app_ok = bool(await credential_store.get("pushover_app_token"))
|
||||
user_key = await _user_settings_store.get(user["id"], "pushover_user_key")
|
||||
return {"app_token_configured": app_ok, "user_key_set": bool(user_key)}
|
||||
|
||||
|
||||
@router.post("/my/pushover")
|
||||
async def set_my_pushover(request: Request):
|
||||
_require_auth(request)
|
||||
user = request.state.current_user
|
||||
body = await request.json()
|
||||
key = (body.get("user_key") or "").strip()
|
||||
if key:
|
||||
await _user_settings_store.set(user["id"], "pushover_user_key", key)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/my/pushover")
|
||||
async def delete_my_pushover(request: Request):
|
||||
_require_auth(request)
|
||||
user = request.state.current_user
|
||||
await _user_settings_store.delete(user["id"], "pushover_user_key")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Agents ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class AgentIn(BaseModel):
|
||||
@@ -1937,6 +2077,81 @@ async def delete_conversation(request: Request, conv_id: str):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/conversations/{conv_id}/export")
|
||||
async def export_conversation(request: Request, conv_id: str, format: str = "markdown"):
|
||||
"""Download a conversation as Markdown or JSON."""
|
||||
import json as _json
|
||||
from fastapi.responses import Response
|
||||
from datetime import datetime, timezone
|
||||
|
||||
user = _require_auth(request)
|
||||
from ..database import get_pool
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"SELECT * FROM conversations WHERE id = $1", conv_id
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
if not user.is_admin and row.get("user_id") != user.id:
|
||||
raise HTTPException(status_code=403, detail="Not your conversation")
|
||||
|
||||
messages = row.get("messages") or []
|
||||
if isinstance(messages, str):
|
||||
try:
|
||||
messages = _json.loads(messages)
|
||||
except Exception:
|
||||
messages = []
|
||||
|
||||
started_at = row.get("started_at") or ""
|
||||
title = row.get("title") or "Conversation"
|
||||
model = row.get("model") or ""
|
||||
|
||||
from ..config import settings as _settings
|
||||
agent_name = _settings.agent_name
|
||||
|
||||
if format == "json":
|
||||
content = _json.dumps({
|
||||
"id": str(row["id"]),
|
||||
"title": title,
|
||||
"model": model,
|
||||
"started_at": str(started_at),
|
||||
"messages": messages,
|
||||
}, indent=2, default=str)
|
||||
filename = f"conversation-{conv_id[:8]}.json"
|
||||
media_type = "application/json"
|
||||
else:
|
||||
# Markdown
|
||||
lines = [f"# {title}", ""]
|
||||
if model:
|
||||
lines.append(f"**Model:** {model} ")
|
||||
if started_at:
|
||||
lines.append(f"**Date:** {str(started_at)[:19]} ")
|
||||
lines += ["", "---", ""]
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
content_parts = msg.get("content", "")
|
||||
if isinstance(content_parts, list):
|
||||
text = " ".join(
|
||||
p.get("text", "") for p in content_parts
|
||||
if isinstance(p, dict) and p.get("type") == "text"
|
||||
)
|
||||
else:
|
||||
text = str(content_parts)
|
||||
if not text.strip():
|
||||
continue
|
||||
speaker = "You" if role == "user" else agent_name
|
||||
lines += [f"**{speaker}**", "", text, "", "---", ""]
|
||||
content = "\n".join(lines)
|
||||
filename = f"conversation-{conv_id[:8]}.md"
|
||||
media_type = "text/markdown"
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
# ── Agent templates ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/agent-templates")
|
||||
@@ -2532,15 +2747,26 @@ async def list_folders_for_account(request: Request, account_id: str):
|
||||
@router.get("/my/caldav/config")
|
||||
async def get_my_caldav_config(request: Request):
|
||||
user = _require_auth(request)
|
||||
url = await _user_settings_store.get(user.id, "caldav_url")
|
||||
username = await _user_settings_store.get(user.id, "caldav_username")
|
||||
password = await _user_settings_store.get(user.id, "caldav_password")
|
||||
calendar_name = await _user_settings_store.get(user.id, "caldav_calendar_name")
|
||||
get = lambda k: _user_settings_store.get(user.id, k)
|
||||
url = await get("caldav_url")
|
||||
username = await get("caldav_username")
|
||||
password = await get("caldav_password")
|
||||
calendar_name = await get("caldav_calendar_name")
|
||||
carddav_same = await get("carddav_same_as_caldav")
|
||||
carddav_url = await get("carddav_url")
|
||||
carddav_user = await get("carddav_username")
|
||||
carddav_pass = await get("carddav_password")
|
||||
allow_write = await get("contacts_allow_write")
|
||||
return {
|
||||
"url": url or "",
|
||||
"username": username or "",
|
||||
"password_set": bool(password),
|
||||
"password": password or "",
|
||||
"calendar_name": calendar_name or "",
|
||||
"carddav_same_as_caldav": carddav_same == "1",
|
||||
"carddav_url": carddav_url or "",
|
||||
"carddav_username": carddav_user or "",
|
||||
"carddav_password": carddav_pass or "",
|
||||
"contacts_allow_write": allow_write == "1",
|
||||
}
|
||||
|
||||
|
||||
@@ -2548,6 +2774,8 @@ async def get_my_caldav_config(request: Request):
|
||||
async def set_my_caldav_config(request: Request):
|
||||
user = _require_auth(request)
|
||||
body = await request.json()
|
||||
|
||||
# CalDAV fields
|
||||
for key, setting_key in [
|
||||
("url", "caldav_url"),
|
||||
("username", "caldav_username"),
|
||||
@@ -2557,8 +2785,36 @@ async def set_my_caldav_config(request: Request):
|
||||
val = (body.get(key) or "").strip()
|
||||
if val:
|
||||
await _user_settings_store.set(user.id, setting_key, val)
|
||||
elif key != "password": # never clear password on empty
|
||||
elif key != "password":
|
||||
await _user_settings_store.delete(user.id, setting_key)
|
||||
|
||||
# CardDAV fields
|
||||
same = bool(body.get("carddav_same_as_caldav"))
|
||||
if same:
|
||||
await _user_settings_store.set(user.id, "carddav_same_as_caldav", "1")
|
||||
# Clear separate CardDAV creds — not needed when using same server
|
||||
for k in ("carddav_url", "carddav_username", "carddav_password"):
|
||||
await _user_settings_store.delete(user.id, k)
|
||||
else:
|
||||
await _user_settings_store.delete(user.id, "carddav_same_as_caldav")
|
||||
for key, setting_key in [
|
||||
("carddav_url", "carddav_url"),
|
||||
("carddav_username", "carddav_username"),
|
||||
("carddav_password", "carddav_password"),
|
||||
]:
|
||||
val = (body.get(key) or "").strip()
|
||||
if val:
|
||||
await _user_settings_store.set(user.id, setting_key, val)
|
||||
elif key != "carddav_password":
|
||||
await _user_settings_store.delete(user.id, setting_key)
|
||||
|
||||
# Per-user contacts write permission
|
||||
allow_write = bool(body.get("contacts_allow_write"))
|
||||
if allow_write:
|
||||
await _user_settings_store.set(user.id, "contacts_allow_write", "1")
|
||||
else:
|
||||
await _user_settings_store.delete(user.id, "contacts_allow_write")
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -2572,6 +2828,8 @@ async def test_my_caldav_config(request: Request):
|
||||
try:
|
||||
import caldav
|
||||
url = cfg["url"]
|
||||
if not url.startswith(("http://", "https://")):
|
||||
url = "https://" + url
|
||||
if "/SOGo/dav/" not in url:
|
||||
url = f"{url.rstrip('/')}/SOGo/dav/{cfg['username']}/"
|
||||
client = caldav.DAVClient(url=url, username=cfg["username"], password=cfg["password"])
|
||||
@@ -2582,10 +2840,43 @@ async def test_my_caldav_config(request: Request):
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
|
||||
@router.post("/my/caldav/test-carddav")
|
||||
async def test_my_carddav_config(request: Request):
|
||||
user = _require_auth(request)
|
||||
from ..tools.contacts_tool import _get_carddav_config, _sogo_carddav_url
|
||||
cfg = await _get_carddav_config(user_id=user.id)
|
||||
if not cfg.get("url") or not cfg.get("username") or not cfg.get("password"):
|
||||
return {"success": False, "message": "CardDAV credentials not configured"}
|
||||
try:
|
||||
import httpx
|
||||
abook_url = _sogo_carddav_url(cfg["url"], cfg["username"])
|
||||
body = (
|
||||
'<?xml version="1.0" encoding="utf-8"?>'
|
||||
'<D:propfind xmlns:D="DAV:"><D:prop><D:resourcetype/></D:prop></D:propfind>'
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
auth=(cfg["username"], cfg["password"]), timeout=10
|
||||
) as client:
|
||||
r = await client.request(
|
||||
"PROPFIND", abook_url,
|
||||
content=body,
|
||||
headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"},
|
||||
)
|
||||
if r.status_code in (200, 207):
|
||||
return {"success": True, "message": f"Connected to {abook_url}"}
|
||||
return {"success": False, "message": f"Server returned HTTP {r.status_code}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
|
||||
@router.delete("/my/caldav/config")
|
||||
async def delete_my_caldav_config(request: Request):
|
||||
user = _require_auth(request)
|
||||
for key in ("caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name"):
|
||||
for key in (
|
||||
"caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name",
|
||||
"carddav_same_as_caldav", "carddav_url", "carddav_username", "carddav_password",
|
||||
"contacts_allow_write",
|
||||
):
|
||||
await _user_settings_store.delete(user.id, key)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -2742,3 +3033,489 @@ async def update_my_profile(request: Request, body: ProfileUpdateIn):
|
||||
from ..users import update_user
|
||||
await update_user(user.id, display_name=body.display_name.strip() or None)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Webhook endpoints (inbound triggers) ──────────────────────────────────────
|
||||
|
||||
class WebhookEndpointIn(BaseModel):
|
||||
name: str
|
||||
agent_id: str = ""
|
||||
description: str = ""
|
||||
allow_get: bool = True
|
||||
|
||||
|
||||
class WebhookEndpointUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
agent_id: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
allow_get: Optional[bool] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("/webhooks")
|
||||
async def list_webhooks(request: Request):
|
||||
_require_admin(request)
|
||||
from ..webhooks.endpoints import list_endpoints
|
||||
return await list_endpoints()
|
||||
|
||||
|
||||
@router.post("/webhooks", status_code=201)
|
||||
async def create_webhook(request: Request, body: WebhookEndpointIn):
|
||||
_require_admin(request)
|
||||
from ..webhooks.endpoints import create_endpoint
|
||||
if not body.name.strip():
|
||||
raise HTTPException(status_code=400, detail="Name is required")
|
||||
ep = await create_endpoint(
|
||||
name=body.name.strip(),
|
||||
agent_id=body.agent_id or "",
|
||||
description=body.description,
|
||||
allow_get=body.allow_get,
|
||||
)
|
||||
return ep # includes token — only time it's returned
|
||||
|
||||
|
||||
@router.put("/webhooks/{endpoint_id}")
|
||||
async def update_webhook(request: Request, endpoint_id: str, body: WebhookEndpointUpdate):
|
||||
_require_admin(request)
|
||||
from ..webhooks.endpoints import update_endpoint
|
||||
fields = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
ep = await update_endpoint(endpoint_id, **fields)
|
||||
if ep is None:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
return ep
|
||||
|
||||
|
||||
@router.delete("/webhooks/{endpoint_id}")
|
||||
async def delete_webhook(request: Request, endpoint_id: str):
|
||||
_require_admin(request)
|
||||
from ..webhooks.endpoints import delete_endpoint
|
||||
deleted = await delete_endpoint(endpoint_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/webhooks/{endpoint_id}/rotate")
|
||||
async def rotate_webhook_token(request: Request, endpoint_id: str):
|
||||
_require_admin(request)
|
||||
from ..webhooks.endpoints import get_endpoint, rotate_token
|
||||
ep = await get_endpoint(endpoint_id)
|
||||
if ep is None:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
new_token = await rotate_token(endpoint_id)
|
||||
return {"ok": True, "token": new_token}
|
||||
|
||||
|
||||
# ── User-scoped webhook endpoints (non-admin) ─────────────────────────────────
|
||||
|
||||
@router.get("/my/webhooks")
|
||||
async def list_my_webhooks(request: Request):
|
||||
_require_auth(request)
|
||||
user_id = request.state.current_user["id"]
|
||||
from ..webhooks.endpoints import list_endpoints
|
||||
return await list_endpoints(owner_user_id=user_id)
|
||||
|
||||
|
||||
@router.post("/my/webhooks", status_code=201)
|
||||
async def create_my_webhook(request: Request, body: WebhookEndpointIn):
|
||||
_require_auth(request)
|
||||
user_id = request.state.current_user["id"]
|
||||
from ..webhooks.endpoints import create_endpoint
|
||||
if not body.name.strip():
|
||||
raise HTTPException(status_code=400, detail="Name is required")
|
||||
ep = await create_endpoint(
|
||||
name=body.name.strip(),
|
||||
agent_id=body.agent_id or "",
|
||||
description=body.description,
|
||||
allow_get=body.allow_get,
|
||||
owner_user_id=user_id,
|
||||
)
|
||||
return ep # includes token — only time it's returned
|
||||
|
||||
|
||||
@router.put("/my/webhooks/{endpoint_id}")
|
||||
async def update_my_webhook(request: Request, endpoint_id: str, body: WebhookEndpointUpdate):
|
||||
_require_auth(request)
|
||||
user_id = request.state.current_user["id"]
|
||||
from ..webhooks.endpoints import update_endpoint, get_endpoint
|
||||
ep = await get_endpoint(endpoint_id, owner_user_id=user_id)
|
||||
if ep is None:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
fields = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
updated = await update_endpoint(endpoint_id, **fields)
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/my/webhooks/{endpoint_id}")
|
||||
async def delete_my_webhook(request: Request, endpoint_id: str):
|
||||
_require_auth(request)
|
||||
user_id = request.state.current_user["id"]
|
||||
from ..webhooks.endpoints import delete_endpoint
|
||||
deleted = await delete_endpoint(endpoint_id, owner_user_id=user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/my/webhooks/{endpoint_id}/rotate")
|
||||
async def rotate_my_webhook_token(request: Request, endpoint_id: str):
|
||||
_require_auth(request)
|
||||
user_id = request.state.current_user["id"]
|
||||
from ..webhooks.endpoints import get_endpoint, rotate_token
|
||||
ep = await get_endpoint(endpoint_id, owner_user_id=user_id)
|
||||
if ep is None:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
new_token = await rotate_token(endpoint_id)
|
||||
return {"ok": True, "token": new_token}
|
||||
|
||||
|
||||
# ── Webhook targets (outbound) ────────────────────────────────────────────────
|
||||
|
||||
class WebhookTargetIn(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
secret_header: str = ""
|
||||
|
||||
|
||||
class WebhookTargetUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
secret_header: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("/webhook-targets")
|
||||
async def list_webhook_targets(request: Request):
|
||||
_require_admin(request)
|
||||
from ..database import get_pool
|
||||
pool = await get_pool()
|
||||
rows = await pool.fetch("SELECT * FROM webhook_targets ORDER BY name")
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/webhook-targets", status_code=201)
|
||||
async def create_webhook_target(request: Request, body: WebhookTargetIn):
|
||||
_require_admin(request)
|
||||
if not body.name.strip():
|
||||
raise HTTPException(status_code=400, detail="Name is required")
|
||||
if not body.url.strip():
|
||||
raise HTTPException(status_code=400, detail="URL is required")
|
||||
from ..database import get_pool
|
||||
from datetime import datetime, timezone
|
||||
pool = await get_pool()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
row = await pool.fetchrow(
|
||||
"""
|
||||
INSERT INTO webhook_targets (name, url, secret_header, created_at)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *
|
||||
""",
|
||||
body.name.strip(), body.url.strip(), body.secret_header or None,
|
||||
now,
|
||||
)
|
||||
except Exception as e:
|
||||
if "unique" in str(e).lower():
|
||||
raise HTTPException(status_code=409, detail="A target with that name already exists")
|
||||
raise
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.put("/webhook-targets/{target_id}")
|
||||
async def update_webhook_target(request: Request, target_id: str, body: WebhookTargetUpdate):
|
||||
_require_admin(request)
|
||||
fields = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
from ..database import get_pool
|
||||
pool = await get_pool()
|
||||
set_clauses = ", ".join(f"{k} = ${i + 2}" for i, k in enumerate(fields))
|
||||
await pool.execute(
|
||||
f"UPDATE webhook_targets SET {set_clauses} WHERE id = $1::uuid",
|
||||
target_id, *fields.values(),
|
||||
)
|
||||
row = await pool.fetchrow("SELECT * FROM webhook_targets WHERE id = $1::uuid", target_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.delete("/webhook-targets/{target_id}")
|
||||
async def delete_webhook_target(request: Request, target_id: str):
|
||||
_require_admin(request)
|
||||
from ..database import get_pool, _rowcount
|
||||
pool = await get_pool()
|
||||
status = await pool.execute(
|
||||
"DELETE FROM webhook_targets WHERE id = $1::uuid", target_id
|
||||
)
|
||||
if _rowcount(status) == 0:
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── User-scoped webhook targets (non-admin) ───────────────────────────────────
|
||||
|
||||
@router.get("/my/webhook-targets")
|
||||
async def list_my_webhook_targets(request: Request):
|
||||
_require_auth(request)
|
||||
user_id = request.state.current_user["id"]
|
||||
from ..database import get_pool
|
||||
pool = await get_pool()
|
||||
rows = await pool.fetch(
|
||||
"SELECT * FROM webhook_targets WHERE owner_user_id = $1 ORDER BY name", user_id
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/my/webhook-targets", status_code=201)
|
||||
async def create_my_webhook_target(request: Request, body: WebhookTargetIn):
|
||||
_require_auth(request)
|
||||
user_id = request.state.current_user["id"]
|
||||
if not body.name.strip():
|
||||
raise HTTPException(status_code=400, detail="Name is required")
|
||||
if not body.url.strip():
|
||||
raise HTTPException(status_code=400, detail="URL is required")
|
||||
from ..database import get_pool
|
||||
from datetime import datetime, timezone
|
||||
pool = await get_pool()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
row = await pool.fetchrow(
|
||||
"""
|
||||
INSERT INTO webhook_targets (name, url, secret_header, owner_user_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *
|
||||
""",
|
||||
body.name.strip(), body.url.strip(), body.secret_header or None, user_id, now,
|
||||
)
|
||||
except Exception as e:
|
||||
if "unique" in str(e).lower():
|
||||
raise HTTPException(status_code=409, detail="A target with that name already exists")
|
||||
raise
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.put("/my/webhook-targets/{target_id}")
|
||||
async def update_my_webhook_target(request: Request, target_id: str, body: WebhookTargetUpdate):
|
||||
_require_auth(request)
|
||||
user_id = request.state.current_user["id"]
|
||||
fields = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
from ..database import get_pool
|
||||
pool = await get_pool()
|
||||
# Verify ownership first
|
||||
existing = await pool.fetchrow(
|
||||
"SELECT id FROM webhook_targets WHERE id = $1::uuid AND owner_user_id = $2",
|
||||
target_id, user_id,
|
||||
)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
set_clauses = ", ".join(f"{k} = ${i + 2}" for i, k in enumerate(fields))
|
||||
await pool.execute(
|
||||
f"UPDATE webhook_targets SET {set_clauses} WHERE id = $1::uuid",
|
||||
target_id, *fields.values(),
|
||||
)
|
||||
row = await pool.fetchrow("SELECT * FROM webhook_targets WHERE id = $1::uuid", target_id)
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.delete("/my/webhook-targets/{target_id}")
|
||||
async def delete_my_webhook_target(request: Request, target_id: str):
|
||||
_require_auth(request)
|
||||
user_id = request.state.current_user["id"]
|
||||
from ..database import get_pool, _rowcount
|
||||
pool = await get_pool()
|
||||
status = await pool.execute(
|
||||
"DELETE FROM webhook_targets WHERE id = $1::uuid AND owner_user_id = $2",
|
||||
target_id, user_id,
|
||||
)
|
||||
if _rowcount(status) == 0:
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Page Change Monitors ───────────────────────────────────────────────────────
|
||||
|
||||
class WatchedPageIn(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
schedule: str = "0 * * * *"
|
||||
css_selector: Optional[str] = None
|
||||
agent_id: Optional[str] = None
|
||||
notification_mode: str = "agent"
|
||||
|
||||
|
||||
class WatchedPageUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
schedule: Optional[str] = None
|
||||
css_selector: Optional[str] = None
|
||||
agent_id: Optional[str] = None
|
||||
notification_mode: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("/watched-pages")
|
||||
async def list_watched_pages(request: Request):
|
||||
user = _require_auth(request)
|
||||
from ..monitors.store import list_watched_pages as _list
|
||||
owner = None if user.is_admin else user.id
|
||||
return await _list(owner_user_id=owner)
|
||||
|
||||
|
||||
@router.post("/watched-pages", status_code=201)
|
||||
async def create_watched_page(request: Request, body: WatchedPageIn):
|
||||
user = _require_auth(request)
|
||||
if not body.name.strip() or not body.url.strip():
|
||||
raise HTTPException(status_code=400, detail="Name and URL are required")
|
||||
from ..monitors.store import create_watched_page as _create
|
||||
from ..monitors.page_monitor import page_monitor
|
||||
page = await _create(
|
||||
name=body.name.strip(),
|
||||
url=body.url.strip(),
|
||||
schedule=body.schedule,
|
||||
css_selector=body.css_selector or None,
|
||||
agent_id=body.agent_id or None,
|
||||
notification_mode=body.notification_mode,
|
||||
owner_user_id=user.id,
|
||||
)
|
||||
page_monitor.reschedule(page)
|
||||
return page
|
||||
|
||||
|
||||
@router.put("/watched-pages/{page_id}")
|
||||
async def update_watched_page(request: Request, page_id: str, body: WatchedPageUpdate):
|
||||
user = _require_auth(request)
|
||||
from ..monitors.store import get_watched_page, update_watched_page as _update
|
||||
page = await get_watched_page(page_id)
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
if not user.is_admin and str(page.get("owner_user_id")) != user.id:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
fields = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
updated = await _update(page_id, **fields)
|
||||
from ..monitors.page_monitor import page_monitor
|
||||
page_monitor.reschedule(updated)
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/watched-pages/{page_id}")
|
||||
async def delete_watched_page(request: Request, page_id: str):
|
||||
user = _require_auth(request)
|
||||
from ..monitors.store import get_watched_page, delete_watched_page as _delete
|
||||
page = await get_watched_page(page_id)
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
if not user.is_admin and str(page.get("owner_user_id")) != user.id:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
await _delete(page_id)
|
||||
from ..monitors.page_monitor import page_monitor
|
||||
page_monitor.remove(page_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/watched-pages/{page_id}/check-now")
|
||||
async def check_page_now(request: Request, page_id: str):
|
||||
user = _require_auth(request)
|
||||
from ..monitors.store import get_watched_page
|
||||
page = await get_watched_page(page_id)
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
if not user.is_admin and str(page.get("owner_user_id")) != user.id:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
from ..monitors.page_monitor import page_monitor
|
||||
result = await page_monitor.check_now(page_id)
|
||||
return result
|
||||
|
||||
|
||||
# ── RSS Feed Monitors ──────────────────────────────────────────────────────────
|
||||
|
||||
class RssFeedIn(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
schedule: str = "0 */4 * * *"
|
||||
agent_id: Optional[str] = None
|
||||
notification_mode: str = "agent"
|
||||
max_items_per_run: int = 5
|
||||
|
||||
|
||||
class RssFeedUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
schedule: Optional[str] = None
|
||||
agent_id: Optional[str] = None
|
||||
notification_mode: Optional[str] = None
|
||||
max_items_per_run: Optional[int] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("/rss-feeds")
|
||||
async def list_rss_feeds(request: Request):
|
||||
user = _require_auth(request)
|
||||
from ..monitors.store import list_rss_feeds as _list
|
||||
owner = None if user.is_admin else user.id
|
||||
return await _list(owner_user_id=owner)
|
||||
|
||||
|
||||
@router.post("/rss-feeds", status_code=201)
|
||||
async def create_rss_feed(request: Request, body: RssFeedIn):
|
||||
user = _require_auth(request)
|
||||
if not body.name.strip() or not body.url.strip():
|
||||
raise HTTPException(status_code=400, detail="Name and URL are required")
|
||||
from ..monitors.store import create_rss_feed as _create
|
||||
from ..monitors.rss_monitor import rss_monitor
|
||||
feed = await _create(
|
||||
name=body.name.strip(),
|
||||
url=body.url.strip(),
|
||||
schedule=body.schedule,
|
||||
agent_id=body.agent_id or None,
|
||||
notification_mode=body.notification_mode,
|
||||
max_items_per_run=body.max_items_per_run,
|
||||
owner_user_id=user.id,
|
||||
)
|
||||
rss_monitor.reschedule(feed)
|
||||
return feed
|
||||
|
||||
|
||||
@router.put("/rss-feeds/{feed_id}")
|
||||
async def update_rss_feed(request: Request, feed_id: str, body: RssFeedUpdate):
|
||||
user = _require_auth(request)
|
||||
from ..monitors.store import get_rss_feed, update_rss_feed as _update
|
||||
feed = await get_rss_feed(feed_id)
|
||||
if not feed:
|
||||
raise HTTPException(status_code=404, detail="Feed not found")
|
||||
if not user.is_admin and str(feed.get("owner_user_id")) != user.id:
|
||||
raise HTTPException(status_code=404, detail="Feed not found")
|
||||
fields = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
updated = await _update(feed_id, **fields)
|
||||
from ..monitors.rss_monitor import rss_monitor
|
||||
rss_monitor.reschedule(updated)
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/rss-feeds/{feed_id}")
|
||||
async def delete_rss_feed(request: Request, feed_id: str):
|
||||
user = _require_auth(request)
|
||||
from ..monitors.store import get_rss_feed, delete_rss_feed as _delete
|
||||
feed = await get_rss_feed(feed_id)
|
||||
if not feed:
|
||||
raise HTTPException(status_code=404, detail="Feed not found")
|
||||
if not user.is_admin and str(feed.get("owner_user_id")) != user.id:
|
||||
raise HTTPException(status_code=404, detail="Feed not found")
|
||||
await _delete(feed_id)
|
||||
from ..monitors.rss_monitor import rss_monitor
|
||||
rss_monitor.remove(feed_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/rss-feeds/{feed_id}/fetch-now")
|
||||
async def fetch_feed_now(request: Request, feed_id: str):
|
||||
user = _require_auth(request)
|
||||
from ..monitors.store import get_rss_feed
|
||||
feed = await get_rss_feed(feed_id)
|
||||
if not feed:
|
||||
raise HTTPException(status_code=404, detail="Feed not found")
|
||||
if not user.is_admin and str(feed.get("owner_user_id")) != user.id:
|
||||
raise HTTPException(status_code=404, detail="Feed not found")
|
||||
from ..monitors.rss_monitor import rss_monitor
|
||||
result = await rss_monitor.fetch_now(feed_id)
|
||||
return result
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@
|
||||
<img src="{{ logo_url }}" alt="logo" class="sidebar-logo-img">
|
||||
<div class="sidebar-logo-text">
|
||||
<div class="sidebar-logo-name">{{ brand_name }}</div>
|
||||
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.0</span></div>
|
||||
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
Files
|
||||
</a>
|
||||
<a class="nav-item" data-page="/monitors" href="/monitors">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M20.188 10.934a8.002 8.002 0 0 1 0 2.132M3.812 13.066a8.002 8.002 0 0 1 0-2.132M15.536 17.121a8 8 0 0 1-1.506.643M9.97 6.236A8 8 0 0 1 11.5 6M17.657 7.757a8 8 0 0 1 .879 1.506M6.343 16.243a8 8 0 0 1-.879-1.506M17.121 15.536a8 8 0 0 1-1.506.879M8.464 6.879A8 8 0 0 1 9.97 6.236"/></svg>
|
||||
Monitors
|
||||
</a>
|
||||
<a class="nav-item" data-page="/audit" href="/audit">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
Audit Log
|
||||
|
||||
@@ -128,6 +128,7 @@ function renderChats(data) {
|
||||
data-id="${c.id}" onclick="localStorage.setItem('current_session_id',this.dataset.id)">Open</a>
|
||||
<button class="btn btn-ghost btn-small"
|
||||
data-id="${c.id}" onclick="openRenameModal(this.dataset.id)">Rename</button>
|
||||
<a class="btn btn-ghost btn-small" href="/api/conversations/${c.id}/export" download>Export</a>
|
||||
<button class="btn btn-ghost btn-small" style="color:var(--danger,#dc3c3c)"
|
||||
data-id="${c.id}" onclick="deleteSingle(this.dataset.id)">Delete</button>
|
||||
</div>
|
||||
|
||||
@@ -42,14 +42,18 @@
|
||||
<li><a href="#settings-general">General</a></li>
|
||||
<li><a href="#settings-whitelists">Whitelists</a></li>
|
||||
<li><a href="#settings-credentials">Credentials</a></li>
|
||||
<li><a href="#settings-dav">DAV</a></li>
|
||||
<li><a href="#settings-pushover">Pushover</a></li>
|
||||
{% endif %}
|
||||
<li><a href="#settings-inbox">Inbox</a></li>
|
||||
<li><a href="#settings-emailaccounts">Email Accounts</a></li>
|
||||
<li><a href="#settings-telegram">Telegram</a></li>
|
||||
<li><a href="#settings-profile">Profile</a></li>
|
||||
{% if not (current_user and current_user.is_admin) %}
|
||||
<li><a href="#settings-caldav">CalDAV</a></li>
|
||||
<li><a href="#settings-caldav">CalDAV / CardDAV</a></li>
|
||||
<li><a href="#settings-pushover">Pushover</a></li>
|
||||
{% endif %}
|
||||
<li><a href="#settings-webhooks">Webhooks</a></li>
|
||||
<li><a href="#settings-profile">Profile</a></li>
|
||||
<li><a href="#settings-personality">Personality</a></li>
|
||||
<li><a href="#settings-brain">2nd Brain</a></li>
|
||||
<li><a href="#settings-mcp">MCP Servers</a></li>
|
||||
@@ -372,8 +376,28 @@ mcp = FastMCP(
|
||||
|
||||
<h2 id="settings-credentials">Credentials <small style="font-weight:400;font-size:12px;color:var(--text-dim)">(Admin)</small></h2>
|
||||
<p>
|
||||
All secrets (API keys, passwords, app settings) are stored in an AES-256-GCM encrypted PostgreSQL table. Keys use a <code>namespace:key</code> convention. See the <a href="#credentials">Credential Key Reference</a> for a full list.
|
||||
A generic AES-256-GCM encrypted key-value store for API keys and other secrets. Keys use a <code>namespace:key</code> convention. Service-specific credentials (CalDAV, CardDAV, Pushover) are managed in their own dedicated tabs — they do not appear here. See the <a href="#credentials">Credential Key Reference</a> for a full list of system keys.
|
||||
</p>
|
||||
|
||||
<h2 id="settings-dav">DAV (CalDAV & CardDAV) <small style="font-weight:400;font-size:12px;color:var(--text-dim)">(Admin)</small></h2>
|
||||
<p>
|
||||
Configure CalDAV and CardDAV for the admin user. There is no system-wide fallback — every user configures their own credentials independently via this tab (admin) or the <strong>CalDAV / CardDAV</strong> tab (regular users).
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>CalDAV</strong>: server URL, username, password, and calendar name. Bare hostnames (e.g. <code>mail.example.com</code>) are accepted — <code>https://</code> is prepended automatically.</li>
|
||||
<li><strong>CardDAV</strong>: tick <em>Same server as CalDAV</em> to reuse the same credentials, or enter a separate URL, username, and password. The SOGo URL pattern (<code>/SOGo/dav/{user}/Contacts/personal/</code>) is built automatically.</li>
|
||||
<li><strong>Allow contact writes</strong>: when enabled, agents can create, update, and delete contacts (not just read them). This is per-user — enabling it for your account does not affect other users.</li>
|
||||
<li><strong>Test buttons</strong>: verify CalDAV and CardDAV connectivity without saving.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="settings-pushover">Pushover <small style="font-weight:400;font-size:12px;color:var(--text-dim)">(Admin)</small></h2>
|
||||
<p>
|
||||
Pushover sends push notifications to iOS and Android devices.
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>App Token</strong>: registered once at <a href="https://pushover.net" target="_blank">pushover.net</a> for this oAI-Web installation. Shared by all users — they cannot see or change it.</li>
|
||||
<li><strong>User Key</strong>: the admin's personal Pushover user key, shown on your pushover.net dashboard. Each user sets their own User Key in <strong>Settings → Pushover</strong>.</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<h2 id="settings-inbox">Inbox</h2>
|
||||
@@ -413,6 +437,36 @@ mcp = FastMCP(
|
||||
<li><strong>Trigger Rules</strong>: same keyword-matching logic as email inbox</li>
|
||||
</ul>
|
||||
|
||||
{% if not (current_user and current_user.is_admin) %}
|
||||
<h2 id="settings-caldav">CalDAV / CardDAV</h2>
|
||||
<p>
|
||||
Configure your personal CalDAV and CardDAV connection. There is no system-wide fallback — if you don't configure it, the tools are unavailable to you.
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>CalDAV</strong>: server URL, username, password, and calendar name. Bare hostnames are accepted — <code>https://</code> is added automatically.</li>
|
||||
<li><strong>CardDAV</strong>: tick <em>Same server as CalDAV</em> to reuse credentials, or enter separate details.</li>
|
||||
<li><strong>Allow contact writes</strong>: when enabled, agents can create, update, and delete contacts.</li>
|
||||
<li><strong>Test buttons</strong>: verify connectivity before saving.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="settings-pushover">Pushover</h2>
|
||||
<p>
|
||||
Set your personal <strong>User Key</strong> to receive push notifications on your Pushover-connected devices. Your User Key is shown on your <a href="https://pushover.net" target="_blank">pushover.net</a> dashboard. The App Token (the shared application credential) is managed by the admin — you only need your own User Key.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<h2 id="settings-webhooks">Webhooks</h2>
|
||||
<p>
|
||||
Inbound webhooks let external services trigger agents via HTTP — useful for iOS Shortcuts, GitHub actions, Home Assistant automations, or any tool that can send an HTTP request.
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Create a webhook</strong>: assign a name, description, and target agent. The secret token is shown <strong>once</strong> at creation — copy it immediately. Use <em>Rotate Token</em> to generate a new one if it is ever compromised.</li>
|
||||
<li><strong>Trigger via POST</strong>: <code>POST /webhook/{token}</code> with body <code>{"message": "..."}</code></li>
|
||||
<li><strong>Trigger via GET</strong>: <code>GET /webhook/{token}?q=your+message</code> — useful for iOS Shortcuts URL actions</li>
|
||||
<li><strong>Enable/disable</strong>: toggle a webhook on/off without deleting it</li>
|
||||
</ul>
|
||||
<p>The <strong>Outbound Targets</strong> section (same tab) manages named URLs that agents can send JSON payloads to via the <code>webhook</code> tool.</p>
|
||||
|
||||
<h2 id="settings-profile">Profile</h2>
|
||||
<p>Available to all users. Contains:</p>
|
||||
<ul>
|
||||
@@ -422,16 +476,8 @@ mcp = FastMCP(
|
||||
<li><strong>Two-Factor Authentication (TOTP)</strong>: enable/disable TOTP-based MFA. On setup, a QR code is shown to scan with any authenticator app (e.g. Aegis, Google Authenticator). Once enabled, every login requires a 6-digit code.</li>
|
||||
<li><strong>Data Folder</strong>: shows the path of your auto-provisioned personal folder (set by admin via <code>system:users_base_folder</code>). This folder is where the Files page browses and where agent memory files are stored.</li>
|
||||
<li><strong>Telegram Bot Token</strong>: per-user Telegram bot token (optional). Overrides the global token for your sessions.</li>
|
||||
<li><strong>CalDAV</strong>: per-user CalDAV server, credentials, and calendar name. Overrides the global CalDAV config.</li>
|
||||
</ul>
|
||||
|
||||
{% if not (current_user and current_user.is_admin) %}
|
||||
<h2 id="settings-caldav">CalDAV</h2>
|
||||
<p>
|
||||
Configure your personal CalDAV connection under <strong>Settings → Profile → CalDAV</strong>. This overrides the global CalDAV config set by the admin. Fields: server URL, username, password, calendar name. Leave blank to inherit the global config.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<h2 id="settings-personality">Personality</h2>
|
||||
<p>
|
||||
Edit <strong>SOUL.md</strong> (agent identity, values, communication style) and <strong>USER.md</strong> (owner context: name, location, preferences) directly in the browser. Changes take effect immediately — no restart required.
|
||||
@@ -553,9 +599,7 @@ mcp = FastMCP(
|
||||
<tr><td><code>system:security_max_subject_chars</code></td><td>Max chars of email subject (default: 200)</td></tr>
|
||||
<tr><td><code>telegram:bot_token</code></td><td>Global Telegram bot API token</td></tr>
|
||||
<tr><td><code>telegram:default_agent_id</code></td><td>UUID of agent for unmatched Telegram messages</td></tr>
|
||||
<tr><td><code>pushover_user_key</code></td><td>Pushover user key</td></tr>
|
||||
<tr><td><code>pushover_app_token</code></td><td>Pushover application token</td></tr>
|
||||
<tr><td><code>caldav_calendar_name</code></td><td>Optional CalDAV calendar name override (global)</td></tr>
|
||||
<tr><td><code>pushover_app_token</code></td><td>Pushover App Token — managed via <strong>Settings → Pushover</strong>, not this tab</td></tr>
|
||||
<tr><td><code>brain:mcp_key</code></td><td>2nd Brain MCP authentication key</td></tr>
|
||||
<tr><td><code>system:api_key_hash</code></td><td>SHA-256 hash of the external API key (raw key never stored)</td></tr>
|
||||
<tr><td><code>system:api_key_created_at</code></td><td>Timestamp of last API key generation</td></tr>
|
||||
@@ -632,6 +676,10 @@ mcp = FastMCP(
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/settings/branding/logo</code></td><td>Reset logo to default</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/settings/audit-retention</code></td><td>Current audit retention setting</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/settings/audit-retention</code></td><td>Update <code>{days}</code></td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/settings/caldav</code></td><td>Admin CalDAV & CardDAV config (same as <code>/api/my/caldav/config</code>)</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/settings/caldav</code></td><td>Save admin CalDAV & CardDAV config</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/settings/pushover</code></td><td>Current Pushover App Token and admin User Key</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/settings/pushover</code></td><td>Save App Token and admin User Key</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -768,14 +816,62 @@ mcp = FastMCP(
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/mfa/setup/begin</code></td><td>Start MFA setup — returns QR code PNG (base64) and provisioning URI</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/mfa/setup/confirm</code></td><td>Confirm setup with a valid TOTP code <code>{code}</code></td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/my/mfa/disable</code></td><td>Disable MFA for the current user</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/caldav/config</code></td><td>Get per-user CalDAV config</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/caldav/config</code></td><td>Save per-user CalDAV credentials</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/my/caldav/config</code></td><td>Remove per-user CalDAV config (fall back to global)</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/caldav/config</code></td><td>Get per-user CalDAV & CardDAV config</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/caldav/config</code></td><td>Save per-user CalDAV & CardDAV credentials</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/my/caldav/config</code></td><td>Remove per-user CalDAV & CardDAV config</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/caldav/test</code></td><td>Test CalDAV connectivity with current saved config</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/caldav/test-carddav</code></td><td>Test CardDAV connectivity with current saved config</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/pushover</code></td><td>Get current user's Pushover User Key (masked)</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/pushover</code></td><td>Save personal User Key <code>{user_key}</code></td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/my/pushover</code></td><td>Remove personal User Key</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/telegram/whitelisted-chats</code></td><td>List Telegram chat IDs whitelisted for the current user</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Webhooks</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/webhooks</code></td><td>List inbound webhook endpoints (admin)</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/webhooks</code></td><td>Create endpoint — returns token once (admin)</td></tr>
|
||||
<tr><td><span class="http-put">PUT</span></td><td><code>/api/webhooks/{id}</code></td><td>Update name/description/agent/enabled (admin)</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/webhooks/{id}</code></td><td>Delete endpoint (admin)</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/webhooks/{id}/rotate</code></td><td>Regenerate token — returns new token once (admin)</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/webhooks</code></td><td>List current user's webhook endpoints</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/webhooks</code></td><td>Create personal webhook endpoint</td></tr>
|
||||
<tr><td><span class="http-put">PUT</span></td><td><code>/api/my/webhooks/{id}</code></td><td>Update personal webhook endpoint</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/my/webhooks/{id}</code></td><td>Delete personal webhook endpoint</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/webhook/{token}</code></td><td>Trigger via GET — param: <code>?q=message</code> (no auth)</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/webhook/{token}</code></td><td>Trigger via POST — body: <code>{"message": "...", "async": true}</code> (no auth)</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/webhook-targets</code></td><td>List outbound webhook targets (admin)</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/webhook-targets</code></td><td>Create outbound target (admin)</td></tr>
|
||||
<tr><td><span class="http-put">PUT</span></td><td><code>/api/webhook-targets/{id}</code></td><td>Update outbound target (admin)</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/webhook-targets/{id}</code></td><td>Delete outbound target (admin)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Monitors</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/watched-pages</code></td><td>List page-change monitors</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/watched-pages</code></td><td>Create page monitor</td></tr>
|
||||
<tr><td><span class="http-put">PUT</span></td><td><code>/api/watched-pages/{id}</code></td><td>Update page monitor</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/watched-pages/{id}</code></td><td>Delete page monitor</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/watched-pages/{id}/check-now</code></td><td>Force an immediate check</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/rss-feeds</code></td><td>List RSS feed monitors</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/rss-feeds</code></td><td>Create RSS feed monitor</td></tr>
|
||||
<tr><td><span class="http-put">PUT</span></td><td><code>/api/rss-feeds/{id}</code></td><td>Update RSS feed monitor</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/rss-feeds/{id}</code></td><td>Delete RSS feed monitor</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/rss-feeds/{id}/fetch-now</code></td><td>Force an immediate fetch</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>User Management <small style="color:var(--text-dim)">(Admin)</small></h3>
|
||||
<div class="table-wrap">
|
||||
<table class="help-api-table">
|
||||
|
||||
138
server/web/templates/monitors.html
Normal file
138
server/web/templates/monitors.html
Normal file
@@ -0,0 +1,138 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ agent_name }} — Monitors{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page" id="monitors-container">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px">
|
||||
<h1>Monitors</h1>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div style="display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:24px">
|
||||
<button class="tab-btn active" id="mtab-pages" type="button" onclick="switchMonitorTab('pages')">Page Watchers</button>
|
||||
<button class="tab-btn" id="mtab-rss" type="button" onclick="switchMonitorTab('rss')">RSS Feeds</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Page Watchers tab ── -->
|
||||
<div id="mpane-pages">
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
||||
<button class="btn btn-primary" onclick="openPageModal(null)">+ Add Page Watcher</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="pages-table">
|
||||
<thead><tr>
|
||||
<th>Name</th>
|
||||
<th>URL</th>
|
||||
<th>Schedule</th>
|
||||
<th>Status</th>
|
||||
<th>Last checked</th>
|
||||
<th>Last changed</th>
|
||||
<th style="text-align:right">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody id="pages-tbody"><tr><td colspan="7" style="color:var(--text-dim)">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── RSS Feeds tab ── -->
|
||||
<div id="mpane-rss" style="display:none">
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
||||
<button class="btn btn-primary" onclick="openFeedModal(null)">+ Add RSS Feed</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="feeds-table">
|
||||
<thead><tr>
|
||||
<th>Name</th>
|
||||
<th>URL</th>
|
||||
<th>Schedule</th>
|
||||
<th>Status</th>
|
||||
<th>Last fetched</th>
|
||||
<th style="text-align:right">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody id="feeds-tbody"><tr><td colspan="6" style="color:var(--text-dim)">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page watcher modal -->
|
||||
<div class="modal-overlay" id="page-modal" style="display:none">
|
||||
<div class="modal" style="max-width:540px;width:100%">
|
||||
<h3 id="page-modal-title" style="margin-bottom:20px">Add Page Watcher</h3>
|
||||
<input type="hidden" id="page-modal-id">
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" id="page-modal-name" class="form-input" placeholder="e.g. Company homepage" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>URL</label>
|
||||
<input type="text" id="page-modal-url" class="form-input" placeholder="https://example.com/page">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>CSS selector <span style="color:var(--text-dim)">(optional — extract specific element)</span></label>
|
||||
<input type="text" id="page-modal-selector" class="form-input" placeholder="e.g. #main-content or .price">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Check schedule (cron)</label>
|
||||
<input type="text" id="page-modal-schedule" class="form-input" value="0 * * * *" placeholder="0 * * * *">
|
||||
<div style="font-size:11px;color:var(--text-dim);margin-top:4px">Default: every hour. Use <a href="https://crontab.guru" target="_blank" style="color:var(--accent)">crontab.guru</a> to build expressions.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>On change: notify via</label>
|
||||
<select id="page-modal-mode" class="form-input">
|
||||
<option value="agent">Agent</option>
|
||||
<option value="pushover">Pushover</option>
|
||||
<option value="both">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="page-modal-agent-group">
|
||||
<label>Agent to trigger</label>
|
||||
<select id="page-modal-agent" class="form-input"></select>
|
||||
</div>
|
||||
<div class="modal-buttons" style="margin-top:20px">
|
||||
<button type="button" class="btn btn-ghost" onclick="closePageModal()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="savePage()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSS feed modal -->
|
||||
<div class="modal-overlay" id="feed-modal" style="display:none">
|
||||
<div class="modal" style="max-width:540px;width:100%">
|
||||
<h3 id="feed-modal-title" style="margin-bottom:20px">Add RSS Feed</h3>
|
||||
<input type="hidden" id="feed-modal-id">
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" id="feed-modal-name" class="form-input" placeholder="e.g. Hacker News" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Feed URL (RSS or Atom)</label>
|
||||
<input type="text" id="feed-modal-url" class="form-input" placeholder="https://example.com/feed.xml">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Fetch schedule (cron)</label>
|
||||
<input type="text" id="feed-modal-schedule" class="form-input" value="0 */4 * * *" placeholder="0 */4 * * *">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max new items per run</label>
|
||||
<input type="number" id="feed-modal-max-items" class="form-input" value="5" min="1" max="50">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>On new items: notify via</label>
|
||||
<select id="feed-modal-mode" class="form-input">
|
||||
<option value="agent">Agent</option>
|
||||
<option value="pushover">Pushover</option>
|
||||
<option value="both">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="feed-modal-agent-group">
|
||||
<label>Agent to trigger</label>
|
||||
<select id="feed-modal-agent" class="form-input"></select>
|
||||
</div>
|
||||
<div class="modal-buttons" style="margin-top:20px">
|
||||
<button type="button" class="btn btn-ghost" onclick="closeFeedModal()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveFeed()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -17,6 +17,8 @@
|
||||
<button type="button" class="tab-btn active" id="stab-general" onclick="switchSettingsTab('general')">General</button>
|
||||
<button type="button" class="tab-btn" id="stab-whitelists" onclick="switchSettingsTab('whitelists')">Whitelists</button>
|
||||
<button type="button" class="tab-btn" id="stab-credentials" onclick="switchSettingsTab('credentials')">Credentials</button>
|
||||
<button type="button" class="tab-btn" id="stab-caldav" onclick="switchSettingsTab('caldav')">DAV</button>
|
||||
<button type="button" class="tab-btn" id="stab-pushover" onclick="switchSettingsTab('pushover')">Pushover</button>
|
||||
<button type="button" class="tab-btn" id="stab-inbox" onclick="switchSettingsTab('inbox')">Inbox</button>
|
||||
<button type="button" class="tab-btn" id="stab-emailaccounts" onclick="switchSettingsTab('emailaccounts')">Email Accounts</button>
|
||||
<button type="button" class="tab-btn" id="stab-telegram" onclick="switchSettingsTab('telegram')">Telegram</button>
|
||||
@@ -25,6 +27,7 @@
|
||||
<button type="button" class="tab-btn" id="stab-mcp" onclick="switchSettingsTab('mcp')">MCP Servers</button>
|
||||
<button type="button" class="tab-btn" id="stab-security" onclick="switchSettingsTab('security')">Security</button>
|
||||
<button type="button" class="tab-btn" id="stab-branding" onclick="switchSettingsTab('branding')">Branding</button>
|
||||
<button type="button" class="tab-btn" id="stab-webhooks" onclick="switchSettingsTab('webhooks')">Webhooks</button>
|
||||
<button type="button" class="tab-btn" id="stab-mfa" onclick="switchSettingsTab('mfa')">Profile</button>
|
||||
</div>
|
||||
{% else %}
|
||||
@@ -34,10 +37,12 @@
|
||||
<button type="button" class="tab-btn" id="ustab-personality" onclick="switchUserTab('personality')">Personality</button>
|
||||
<button type="button" class="tab-btn" id="ustab-inbox" onclick="switchUserTab('inbox')">Inbox</button>
|
||||
<button type="button" class="tab-btn" id="ustab-emailaccounts" onclick="switchUserTab('emailaccounts')">Email Accounts</button>
|
||||
<button type="button" class="tab-btn" id="ustab-caldav" onclick="switchUserTab('caldav')">CalDAV</button>
|
||||
<button type="button" class="tab-btn" id="ustab-caldav" onclick="switchUserTab('caldav')">CalDAV / CardDAV</button>
|
||||
<button type="button" class="tab-btn" id="ustab-telegram" onclick="switchUserTab('telegram')">Telegram</button>
|
||||
<button type="button" class="tab-btn" id="ustab-mcp" onclick="switchUserTab('mcp')">MCP Servers</button>
|
||||
<button type="button" class="tab-btn" id="ustab-brain" onclick="switchUserTab('brain')">2nd Brain</button>
|
||||
<button type="button" class="tab-btn" id="ustab-pushover" onclick="switchUserTab('pushover')">Pushover</button>
|
||||
<button type="button" class="tab-btn" id="ustab-webhooks" onclick="switchUserTab('webhooks')">Webhooks</button>
|
||||
<button type="button" class="tab-btn" id="ustab-mfa" onclick="switchUserTab('mfa')">Profile</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -107,7 +112,7 @@
|
||||
Override the defaults from <code>.env</code>. Changes take effect immediately — no restart needed.
|
||||
Individual agents can further override <em>Max tool calls</em> on their own settings page.
|
||||
</p>
|
||||
<form id="limits-form" style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end;max-width:520px">
|
||||
<form id="limits-form" style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end;max-width:680px">
|
||||
<div class="form-group" style="flex:1;min-width:160px;margin-bottom:0">
|
||||
<label>Max tool calls per run</label>
|
||||
<input type="number" id="lim-tool-calls" class="form-input" min="1" max="200">
|
||||
@@ -116,6 +121,10 @@
|
||||
<label>Max autonomous runs / hour</label>
|
||||
<input type="number" id="lim-runs-per-hour" class="form-input" min="1" max="1000">
|
||||
</div>
|
||||
<div class="form-group" style="flex:1;min-width:160px;margin-bottom:0">
|
||||
<label>Max concurrent runs</label>
|
||||
<input type="number" id="lim-concurrent-runs" class="form-input" min="1" max="20">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="margin-bottom:0">Save</button>
|
||||
</form>
|
||||
<div id="limits-defaults" style="font-size:11px;color:var(--text-dim);margin-top:10px"></div>
|
||||
@@ -404,17 +413,18 @@
|
||||
|
||||
<!-- Stored credentials -->
|
||||
<section style="margin-bottom:32px">
|
||||
<h2 class="settings-section-title">Encrypted Credentials</h2>
|
||||
<h2 class="settings-section-title">Encrypted Credential Store</h2>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px">
|
||||
Credentials for CalDAV, Email, and Pushover. All values are stored AES-256-GCM encrypted.
|
||||
Inbox, Telegram, and other integration credentials are managed in their respective Settings tabs.
|
||||
Generic key-value store for any credentials not managed in a dedicated settings tab.
|
||||
All values are stored AES-256-GCM encrypted.
|
||||
CalDAV, CardDAV, Pushover, Inbox, and Telegram credentials are managed in their own tabs.
|
||||
</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Credential</th>
|
||||
<th>Used By</th>
|
||||
<th>Key</th>
|
||||
<th>Description</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
@@ -428,6 +438,102 @@
|
||||
|
||||
</div><!-- /spane-credentials -->
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
TAB: CalDAV / CardDAV (admin system credentials)
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
<div id="spane-caldav" style="display:none">
|
||||
|
||||
<!-- CalDAV (Calendar) -->
|
||||
<section style="max-width:560px;margin-bottom:36px;padding-bottom:32px;border-bottom:1px solid var(--border)">
|
||||
<h2 class="settings-section-title">My CalDAV <span style="font-weight:400;font-size:13px;color:var(--text-dim)">(Calendar)</span></h2>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px">
|
||||
Your personal CalDAV credentials. Each user configures their own — there is no shared fallback.
|
||||
</p>
|
||||
<div class="form-group"><label>Mailcow Host</label>
|
||||
<input type="text" id="adm-caldav-host" class="form-input" placeholder="mail.example.com"></div>
|
||||
<div class="form-group"><label>Username</label>
|
||||
<input type="text" id="adm-caldav-username" class="form-input" placeholder="jarvis@example.com"></div>
|
||||
<div class="form-group"><label>Password</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input type="password" id="adm-caldav-password" class="form-input" placeholder="Leave blank to keep existing">
|
||||
<button type="button" class="btn btn-ghost btn-small" onclick="togglePasswordVisibility('adm-caldav-password', this)">Show</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Calendar name <span style="color:var(--text-dim);font-size:11px">(optional)</span></label>
|
||||
<input type="text" id="adm-caldav-calendar-name" class="form-input" placeholder="Leave blank to use first found"></div>
|
||||
<div style="margin-top:4px">
|
||||
<button class="btn btn-ghost btn-small" onclick="testAdminCaldav()" id="adm-caldav-test-btn">Test CalDAV</button>
|
||||
<div id="adm-caldav-test-result" style="margin-top:8px;font-size:13px"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CardDAV (Contacts) -->
|
||||
<section style="max-width:560px;margin-bottom:36px;padding-bottom:32px;border-bottom:1px solid var(--border)">
|
||||
<h2 class="settings-section-title">System CardDAV <span style="font-weight:400;font-size:13px;color:var(--text-dim)">(Contacts)</span></h2>
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:10px;margin-bottom:16px">
|
||||
<input type="checkbox" id="adm-carddav-same" style="width:auto" onchange="onAdmCarddavSameChange()">
|
||||
<label for="adm-carddav-same" style="margin:0;cursor:pointer;font-size:13px">Same server as CalDAV (use CalDAV credentials)</label>
|
||||
</div>
|
||||
<div id="adm-carddav-custom-fields">
|
||||
<div class="form-group"><label>CardDAV Server URL</label>
|
||||
<input type="text" id="adm-carddav-url" class="form-input" placeholder="https://contacts.example.com"></div>
|
||||
<div class="form-group"><label>Username</label>
|
||||
<input type="text" id="adm-carddav-username" class="form-input" placeholder="user@example.com"></div>
|
||||
<div class="form-group"><label>Password</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input type="password" id="adm-carddav-password" class="form-input" placeholder="Leave blank to keep existing">
|
||||
<button type="button" class="btn btn-ghost btn-small" onclick="togglePasswordVisibility('adm-carddav-password', this)">Show</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:10px;padding-top:12px;border-top:1px solid var(--border)">
|
||||
<input type="checkbox" id="adm-contacts-allow-write" style="width:auto">
|
||||
<label for="adm-contacts-allow-write" style="margin:0;cursor:pointer;font-size:13px">
|
||||
Allow contact writes <span style="color:var(--text-dim)">(agents can create/update/delete contacts)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div style="margin-top:8px">
|
||||
<button class="btn btn-ghost btn-small" onclick="testAdminCarddav()" id="adm-carddav-test-btn">Test CardDAV</button>
|
||||
<div id="adm-carddav-test-result" style="margin-top:8px;font-size:13px"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button class="btn btn-primary" onclick="saveAdminCaldav()">Save all</button>
|
||||
</div>
|
||||
|
||||
</div><!-- /spane-caldav -->
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
TAB: Pushover (admin)
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
<div id="spane-pushover" style="display:none">
|
||||
<section style="max-width:520px">
|
||||
<h2 class="settings-section-title">Pushover</h2>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin-bottom:20px">
|
||||
Pushover delivers push notifications to iOS/Android devices.
|
||||
The <strong>App Token</strong> is created at <a href="https://pushover.net" target="_blank" style="color:var(--accent)">pushover.net</a>
|
||||
and is shared across all users of this installation.
|
||||
Each user sets their own <strong>User Key</strong> in their personal Settings → Pushover tab.
|
||||
</p>
|
||||
<div class="form-group"><label>App Token</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input type="password" id="adm-pushover-app-token" class="form-input" placeholder="Leave blank to keep existing">
|
||||
<button type="button" class="btn btn-ghost btn-small" onclick="togglePasswordVisibility('adm-pushover-app-token', this)">Show</button>
|
||||
</div>
|
||||
<div id="adm-pushover-app-token-status" style="font-size:12px;color:var(--text-dim);margin-top:4px"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Your User Key <span style="color:var(--text-dim);font-size:11px">(admin's own notifications)</span></label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input type="password" id="adm-pushover-user-key" class="form-input" placeholder="Leave blank to keep existing">
|
||||
<button type="button" class="btn btn-ghost btn-small" onclick="togglePasswordVisibility('adm-pushover-user-key', this)">Show</button>
|
||||
</div>
|
||||
<div id="adm-pushover-user-key-status" style="font-size:12px;color:var(--text-dim);margin-top:4px"></div>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="saveAdminPushover()">Save</button>
|
||||
</section>
|
||||
</div><!-- /spane-pushover -->
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
TAB: Inbox
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
@@ -1081,6 +1187,127 @@
|
||||
|
||||
</div><!-- /spane-mfa -->
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
ADMIN: Webhooks
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
<div id="spane-webhooks" style="display:none">
|
||||
|
||||
<!-- Inbound Endpoints -->
|
||||
<section style="margin-bottom:40px">
|
||||
<h2 class="settings-section-title">Inbound Webhook Triggers</h2>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px">
|
||||
Each endpoint has a secret token. POST <code>/webhook/{token}</code> with <code>{"message":"..."}</code>
|
||||
— or GET <code>/webhook/{token}?q=...</code> for iOS Shortcuts — to trigger the associated agent.
|
||||
</p>
|
||||
<button class="btn btn-primary btn-small" onclick="openWebhookModal(null)" style="margin-bottom:16px">+ Add Endpoint</button>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Name</th>
|
||||
<th>Agent</th>
|
||||
<th>Status</th>
|
||||
<th>Triggers</th>
|
||||
<th>Last triggered</th>
|
||||
<th style="text-align:right">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody id="webhooks-tbody"><tr><td colspan="6" style="color:var(--text-dim)">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Outbound Targets -->
|
||||
<section>
|
||||
<h2 class="settings-section-title">Outbound Webhook Targets</h2>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px">
|
||||
Named targets agents can POST to using the <code>webhook</code> tool.
|
||||
</p>
|
||||
<button class="btn btn-primary btn-small" onclick="openWebhookTargetModal(null)" style="margin-bottom:16px">+ Add Target</button>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Name</th>
|
||||
<th>URL</th>
|
||||
<th>Status</th>
|
||||
<th style="text-align:right">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody id="webhook-targets-tbody"><tr><td colspan="4" style="color:var(--text-dim)">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div><!-- /spane-webhooks -->
|
||||
|
||||
<!-- Webhook endpoint modal -->
|
||||
<div class="modal-overlay" id="webhook-modal" style="display:none">
|
||||
<div class="modal" style="max-width:500px;width:100%">
|
||||
<h3 id="webhook-modal-title" style="margin-bottom:20px">Add Webhook Endpoint</h3>
|
||||
<input type="hidden" id="webhook-modal-id">
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" id="webhook-modal-name" class="form-input" placeholder="e.g. GitHub Push" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description <span style="color:var(--text-dim)">(optional)</span></label>
|
||||
<input type="text" id="webhook-modal-desc" class="form-input" placeholder="What triggers this?">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Agent to trigger</label>
|
||||
<select id="webhook-modal-agent" class="form-input"></select>
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:10px">
|
||||
<input type="checkbox" id="webhook-modal-allow-get" checked style="width:auto">
|
||||
<label for="webhook-modal-allow-get" style="margin:0;cursor:pointer">Allow GET requests (for iOS Shortcuts)</label>
|
||||
</div>
|
||||
<div class="modal-buttons" style="margin-top:20px">
|
||||
<button type="button" class="btn btn-ghost" onclick="closeWebhookModal()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveWebhook()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token reveal modal (shown once after create or rotate) -->
|
||||
<div class="modal-overlay" id="webhook-token-modal" style="display:none">
|
||||
<div class="modal" style="max-width:520px;width:100%">
|
||||
<h3 style="margin-bottom:8px">Webhook Token</h3>
|
||||
<p style="font-size:12px;color:var(--yellow);margin-bottom:16px">Copy this token now — it will not be shown again.</p>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input type="text" id="webhook-token-value" class="form-input" readonly style="font-family:var(--mono);font-size:12px">
|
||||
<button class="btn btn-ghost btn-small" onclick="copyWebhookToken()">Copy</button>
|
||||
</div>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin-top:14px">
|
||||
POST: <code id="webhook-token-url-post"></code><br>
|
||||
GET: <code id="webhook-token-url-get"></code>
|
||||
</p>
|
||||
<div class="modal-buttons" style="margin-top:20px">
|
||||
<button type="button" class="btn btn-primary" onclick="closeWebhookTokenModal()">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Outbound target modal -->
|
||||
<div class="modal-overlay" id="webhook-target-modal" style="display:none">
|
||||
<div class="modal" style="max-width:480px;width:100%">
|
||||
<h3 id="webhook-target-modal-title" style="margin-bottom:20px">Add Webhook Target</h3>
|
||||
<input type="hidden" id="webhook-target-modal-id">
|
||||
<div class="form-group">
|
||||
<label>Name <span style="color:var(--text-dim)">(used by agents)</span></label>
|
||||
<input type="text" id="webhook-target-modal-name" class="form-input" placeholder="e.g. home-assistant" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>URL</label>
|
||||
<input type="text" id="webhook-target-modal-url" class="form-input" placeholder="https://...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Secret header value <span style="color:var(--text-dim)">(optional — sent as Authorization: Bearer)</span></label>
|
||||
<input type="password" id="webhook-target-modal-secret" class="form-input" placeholder="Leave blank to omit">
|
||||
</div>
|
||||
<div class="modal-buttons" style="margin-top:20px">
|
||||
<button type="button" class="btn btn-ghost" onclick="closeWebhookTargetModal()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveWebhookTarget()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
USER SETTINGS: API Keys
|
||||
@@ -1211,38 +1438,79 @@
|
||||
</section>
|
||||
</div><!-- /uspane-emailaccounts -->
|
||||
|
||||
<!-- CalDAV tab -->
|
||||
<!-- CalDAV / CardDAV tab -->
|
||||
<div id="uspane-caldav" style="display:none">
|
||||
<section>
|
||||
<h2 class="settings-section-title">My CalDAV</h2>
|
||||
|
||||
<!-- ── CalDAV (Calendar) ── -->
|
||||
<section style="max-width:560px;margin-bottom:36px;padding-bottom:32px;border-bottom:1px solid var(--border)">
|
||||
<h2 class="settings-section-title">My CalDAV <span style="font-weight:400;font-size:13px;color:var(--text-dim)">(Calendar)</span></h2>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px">
|
||||
Your personal CalDAV server for calendar access. Leave blank to use the system CalDAV server
|
||||
configured by the admin.
|
||||
Used by the <code>caldav</code> tool for reading and writing calendar events.
|
||||
Used by the <code>caldav</code> tool for reading and writing calendar events.
|
||||
</p>
|
||||
<div style="max-width:520px">
|
||||
<div class="form-group"><label>CalDAV Server URL</label>
|
||||
<input type="text" id="my-caldav-url" class="form-input" placeholder="https://mail.example.com"></div>
|
||||
<div class="form-group"><label>CalDAV Server URL</label>
|
||||
<input type="text" id="my-caldav-url" class="form-input" placeholder="https://mail.example.com"></div>
|
||||
<div class="form-group"><label>Username</label>
|
||||
<input type="text" id="my-caldav-username" class="form-input" placeholder="user@example.com"></div>
|
||||
<div class="form-group"><label>Password</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input type="password" id="my-caldav-password" class="form-input" placeholder="Leave blank to keep existing">
|
||||
<button type="button" class="btn btn-ghost btn-small" onclick="togglePasswordVisibility('my-caldav-password', this)">Show</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Calendar name <span style="color:var(--text-dim);font-size:11px">(optional — uses first found if blank)</span></label>
|
||||
<input type="text" id="my-caldav-calendar-name" class="form-input" placeholder="Personal"></div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:4px">
|
||||
<button class="btn btn-ghost btn-small" onclick="testMyCaldavConfig()" id="caldav-test-btn">Test CalDAV</button>
|
||||
</div>
|
||||
<div id="caldav-test-result" style="margin-top:10px;font-size:13px"></div>
|
||||
</section>
|
||||
|
||||
<!-- ── CardDAV (Contacts) ── -->
|
||||
<section style="max-width:560px;margin-bottom:32px">
|
||||
<h2 class="settings-section-title">My CardDAV <span style="font-weight:400;font-size:13px;color:var(--text-dim)">(Contacts)</span></h2>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px">
|
||||
Used by the <code>contacts</code> tool for reading (and optionally writing) address book contacts.
|
||||
</p>
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:10px;margin-bottom:16px">
|
||||
<input type="checkbox" id="my-carddav-same" style="width:auto" onchange="onCarddavSameChange()">
|
||||
<label for="my-carddav-same" style="margin:0;cursor:pointer;font-size:13px">
|
||||
Same server as CalDAV (use CalDAV credentials for contacts)
|
||||
</label>
|
||||
</div>
|
||||
<div id="carddav-custom-fields">
|
||||
<div class="form-group"><label>CardDAV Server URL</label>
|
||||
<input type="text" id="my-carddav-url" class="form-input" placeholder="https://contacts.example.com"></div>
|
||||
<div class="form-group"><label>Username</label>
|
||||
<input type="text" id="my-caldav-username" class="form-input" placeholder="user@example.com"></div>
|
||||
<input type="text" id="my-carddav-username" class="form-input" placeholder="user@example.com"></div>
|
||||
<div class="form-group"><label>Password</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input type="password" id="my-caldav-password" class="form-input" placeholder="Leave blank to keep existing">
|
||||
<button type="button" class="btn btn-ghost btn-small" onclick="togglePasswordVisibility('my-caldav-password', this)">Show</button>
|
||||
<input type="password" id="my-carddav-password" class="form-input" placeholder="Leave blank to keep existing">
|
||||
<button type="button" class="btn btn-ghost btn-small" onclick="togglePasswordVisibility('my-carddav-password', this)">Show</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Calendar name <span style="color:var(--text-dim);font-size:11px">(optional — uses first found if blank)</span></label>
|
||||
<input type="text" id="my-caldav-calendar-name" class="form-input" placeholder="Personal"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<button class="btn btn-primary" onclick="saveMyCaldavConfig()">Save</button>
|
||||
<button class="btn btn-ghost btn-small" onclick="testMyCaldavConfig()" id="caldav-test-btn">Test connection</button>
|
||||
<button class="btn btn-danger btn-small" onclick="deleteMyCaldavConfig()">Clear / use system CalDAV</button>
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:10px;margin-top:8px;padding-top:16px;border-top:1px solid var(--border)">
|
||||
<input type="checkbox" id="my-contacts-allow-write" style="width:auto">
|
||||
<label for="my-contacts-allow-write" style="margin:0;cursor:pointer;font-size:13px">
|
||||
Allow contact writes <span style="color:var(--text-dim)">(create, update, delete — requires confirmation)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="caldav-test-result" style="margin-top:12px;font-size:13px"></div>
|
||||
<p style="margin-top:16px;font-size:12px;color:var(--text-dim)">
|
||||
If left blank, the system CalDAV server will be used (configured by the admin in Credentials).
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:6px">
|
||||
<button class="btn btn-ghost btn-small" onclick="testMyCarddavConfig()" id="carddav-test-btn">Test CardDAV</button>
|
||||
</div>
|
||||
<div id="carddav-test-result" style="margin-top:10px;font-size:13px"></div>
|
||||
</section>
|
||||
|
||||
<!-- ── Save / Clear ── -->
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<button class="btn btn-primary" onclick="saveMyCaldavConfig()">Save all</button>
|
||||
<button class="btn btn-danger btn-small" onclick="deleteMyCaldavConfig()">Clear all</button>
|
||||
</div>
|
||||
<p style="margin-top:12px;font-size:12px;color:var(--text-dim)">
|
||||
Each user must configure their own CalDAV / CardDAV credentials. There is no shared fallback.
|
||||
</p>
|
||||
|
||||
</div><!-- /uspane-caldav -->
|
||||
|
||||
<div id="uspane-telegram" style="display:none">
|
||||
@@ -1335,6 +1603,83 @@
|
||||
</section>
|
||||
</div><!-- /uspane-brain -->
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
USER SETTINGS: Pushover
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
<div id="uspane-pushover" style="display:none">
|
||||
<section style="max-width:520px">
|
||||
<h2 class="settings-section-title">My Pushover Notifications</h2>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px">
|
||||
Set your personal Pushover <strong>User Key</strong> to receive push notifications on your devices.
|
||||
The App Token is shared and managed by the admin.
|
||||
Find your User Key at <a href="https://pushover.net" target="_blank" style="color:var(--accent)">pushover.net</a> → your user page.
|
||||
</p>
|
||||
<div id="my-pushover-status" style="font-size:12px;margin-bottom:12px"></div>
|
||||
<div class="form-group"><label>Your User Key</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input type="password" id="my-pushover-user-key" class="form-input" placeholder="uXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX">
|
||||
<button type="button" class="btn btn-ghost btn-small" onclick="togglePasswordVisibility('my-pushover-user-key', this)">Show</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-primary" onclick="saveMyPushover()">Save</button>
|
||||
<button class="btn btn-danger btn-small" onclick="deleteMyPushover()">Remove</button>
|
||||
</div>
|
||||
</section>
|
||||
</div><!-- /uspane-pushover -->
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
USER SETTINGS: Webhooks
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
<div id="uspane-webhooks" style="display:none">
|
||||
|
||||
<!-- Inbound endpoints -->
|
||||
<section style="margin-bottom:40px">
|
||||
<h2 class="settings-section-title">Inbound Endpoints</h2>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px">
|
||||
Webhook endpoints that trigger your agents. Each has a secret token —
|
||||
POST <code>/webhook/{token}</code> with <code>{"message":"..."}</code>
|
||||
or GET <code>/webhook/{token}?q=...</code> for iOS Shortcuts.
|
||||
</p>
|
||||
<button class="btn btn-primary btn-small" onclick="openMyWebhookModal(null)" style="margin-bottom:16px">+ Add Endpoint</button>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Name</th>
|
||||
<th>Agent</th>
|
||||
<th>Status</th>
|
||||
<th>Triggers</th>
|
||||
<th>Last triggered</th>
|
||||
<th style="text-align:right">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody id="my-webhooks-tbody"><tr><td colspan="6" style="color:var(--text-dim)">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Outbound targets -->
|
||||
<section>
|
||||
<h2 class="settings-section-title">Outbound Targets</h2>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px">
|
||||
Named targets your agents can POST to using the <code>webhook</code> tool.
|
||||
Targets you define here are only visible to your own agents.
|
||||
</p>
|
||||
<button class="btn btn-primary btn-small" onclick="openMyWebhookTargetModal(null)" style="margin-bottom:16px">+ Add Target</button>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Name</th>
|
||||
<th>URL</th>
|
||||
<th>Status</th>
|
||||
<th style="text-align:right">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody id="my-webhook-targets-tbody"><tr><td colspan="4" style="color:var(--text-dim)">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div><!-- /uspane-webhooks -->
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
USER SETTINGS: Profile
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
@@ -1657,31 +2002,9 @@
|
||||
<div class="modal" style="max-width:480px;width:100%">
|
||||
<h3 style="margin-bottom:20px" id="cred-modal-title">Add Credential</h3>
|
||||
<div class="form-group" id="cred-modal-key-group">
|
||||
<label>Credential</label>
|
||||
<select id="cred-modal-key-select" class="form-input" onchange="onCredKeySelectChange()">
|
||||
<option value="">- choose a credential -</option>
|
||||
<optgroup label="CalDAV">
|
||||
<option value="mailcow_host">Mailcow Host</option>
|
||||
<option value="mailcow_username">Mailcow Username</option>
|
||||
<option value="mailcow_password">Mailcow Password</option>
|
||||
<option value="caldav_calendar_name">Calendar Name</option>
|
||||
</optgroup>
|
||||
<optgroup label="Email">
|
||||
<option value="mailcow_imap_host">IMAP Host</option>
|
||||
<option value="mailcow_smtp_host">SMTP Host</option>
|
||||
<option value="mailcow_smtp_port">SMTP Port</option>
|
||||
</optgroup>
|
||||
<optgroup label="Pushover">
|
||||
<option value="pushover_user_key">User Key</option>
|
||||
<option value="pushover_app_token">App Token</option>
|
||||
</optgroup>
|
||||
<option value="__custom__">Custom…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="cred-modal-custom-group" style="display:none">
|
||||
<label>Custom credential name</label>
|
||||
<label>Key name</label>
|
||||
<input type="text" id="cred-modal-key-custom" class="form-input"
|
||||
placeholder="e.g. my_api_key" autocomplete="off">
|
||||
placeholder="e.g. my_api_key or service:token" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Value</label>
|
||||
@@ -1727,6 +2050,58 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- User webhook target modal -->
|
||||
<div class="modal-overlay" id="my-webhook-target-modal" style="display:none">
|
||||
<div class="modal" style="max-width:480px;width:100%">
|
||||
<h3 id="my-webhook-target-modal-title" style="margin-bottom:20px">Add Outbound Target</h3>
|
||||
<input type="hidden" id="my-webhook-target-modal-id">
|
||||
<div class="form-group">
|
||||
<label>Name <span style="color:var(--text-dim)">(used by agents)</span></label>
|
||||
<input type="text" id="my-webhook-target-modal-name" class="form-input" placeholder="e.g. home-assistant" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>URL</label>
|
||||
<input type="text" id="my-webhook-target-modal-url" class="form-input" placeholder="https://...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Secret header value <span style="color:var(--text-dim)">(optional — sent as Authorization: Bearer)</span></label>
|
||||
<input type="password" id="my-webhook-target-modal-secret" class="form-input" placeholder="Leave blank to omit">
|
||||
</div>
|
||||
<div class="modal-buttons" style="margin-top:20px">
|
||||
<button type="button" class="btn btn-ghost" onclick="closeMyWebhookTargetModal()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveMyWebhookTarget()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User webhook modal -->
|
||||
<div class="modal-overlay" id="my-webhook-modal" style="display:none">
|
||||
<div class="modal" style="max-width:500px;width:100%">
|
||||
<h3 id="my-webhook-modal-title" style="margin-bottom:20px">Add Webhook</h3>
|
||||
<input type="hidden" id="my-webhook-modal-id">
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" id="my-webhook-modal-name" class="form-input" placeholder="e.g. iOS Shortcut" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description <span style="color:var(--text-dim)">(optional)</span></label>
|
||||
<input type="text" id="my-webhook-modal-desc" class="form-input" placeholder="What triggers this?">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Agent to trigger</label>
|
||||
<select id="my-webhook-modal-agent" class="form-input"></select>
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:10px">
|
||||
<input type="checkbox" id="my-webhook-modal-allow-get" checked style="width:auto">
|
||||
<label for="my-webhook-modal-allow-get" style="margin:0;cursor:pointer">Allow GET requests (for iOS Shortcuts)</label>
|
||||
</div>
|
||||
<div class="modal-buttons" style="margin-top:20px">
|
||||
<button type="button" class="btn btn-ghost" onclick="closeMyWebhookModal()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveMyWebhook()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>window.IS_ADMIN = {{ current_user.is_admin | tojson }};</script>
|
||||
{% endblock %}
|
||||
|
||||
0
server/webhooks/__init__.py
Normal file
0
server/webhooks/__init__.py
Normal file
138
server/webhooks/endpoints.py
Normal file
138
server/webhooks/endpoints.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
webhooks/endpoints.py — CRUD for inbound webhook trigger endpoints.
|
||||
|
||||
Each endpoint has a secret token. When the token is presented via GET ?q=...
|
||||
or POST {"message": "..."}, the associated agent is triggered.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..database import _rowcount, get_pool
|
||||
|
||||
|
||||
def _utcnow() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
async def create_endpoint(
|
||||
name: str,
|
||||
agent_id: str,
|
||||
description: str = "",
|
||||
allow_get: bool = True,
|
||||
owner_user_id: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a new webhook endpoint. Returns the full row including the plaintext token."""
|
||||
token = secrets.token_urlsafe(32)
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"""
|
||||
INSERT INTO webhook_endpoints
|
||||
(name, token, agent_id, description, allow_get, owner_user_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *
|
||||
""",
|
||||
name, token, agent_id or None, description, allow_get, owner_user_id, _utcnow(),
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def list_endpoints(owner_user_id: str | None = None) -> list[dict]:
|
||||
"""List endpoints. Pass owner_user_id to scope to one user; None returns all (admin). Token never included."""
|
||||
pool = await get_pool()
|
||||
if owner_user_id:
|
||||
rows = await pool.fetch(
|
||||
"SELECT * FROM webhook_endpoints WHERE owner_user_id = $1 ORDER BY created_at DESC",
|
||||
owner_user_id,
|
||||
)
|
||||
else:
|
||||
rows = await pool.fetch(
|
||||
"SELECT * FROM webhook_endpoints ORDER BY created_at DESC"
|
||||
)
|
||||
result = []
|
||||
for row in rows:
|
||||
d = dict(row)
|
||||
d.pop("token", None)
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
async def get_endpoint(endpoint_id: str, owner_user_id: str | None = None) -> dict | None:
|
||||
"""Get one endpoint by ID. Token is not included. Pass owner_user_id to enforce ownership."""
|
||||
pool = await get_pool()
|
||||
if owner_user_id:
|
||||
row = await pool.fetchrow(
|
||||
"SELECT * FROM webhook_endpoints WHERE id = $1::uuid AND owner_user_id = $2",
|
||||
endpoint_id, owner_user_id,
|
||||
)
|
||||
else:
|
||||
row = await pool.fetchrow(
|
||||
"SELECT * FROM webhook_endpoints WHERE id = $1::uuid", endpoint_id
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
d.pop("token", None)
|
||||
return d
|
||||
|
||||
|
||||
async def get_by_token(token: str) -> dict | None:
|
||||
"""Look up an enabled endpoint by its secret token (includes token field)."""
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"SELECT * FROM webhook_endpoints WHERE token = $1 AND enabled = TRUE", token
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def update_endpoint(endpoint_id: str, **fields) -> dict | None:
|
||||
allowed = {"name", "agent_id", "description", "allow_get", "enabled"}
|
||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not updates:
|
||||
return await get_endpoint(endpoint_id)
|
||||
pool = await get_pool()
|
||||
set_clauses = ", ".join(f"{k} = ${i + 2}" for i, k in enumerate(updates))
|
||||
await pool.execute(
|
||||
f"UPDATE webhook_endpoints SET {set_clauses} WHERE id = $1::uuid",
|
||||
endpoint_id, *updates.values(),
|
||||
)
|
||||
return await get_endpoint(endpoint_id)
|
||||
|
||||
|
||||
async def rotate_token(endpoint_id: str) -> str:
|
||||
"""Generate and store a new token. Returns the new plaintext token."""
|
||||
new_token = secrets.token_urlsafe(32)
|
||||
pool = await get_pool()
|
||||
await pool.execute(
|
||||
"UPDATE webhook_endpoints SET token = $1 WHERE id = $2::uuid",
|
||||
new_token, endpoint_id,
|
||||
)
|
||||
return new_token
|
||||
|
||||
|
||||
async def delete_endpoint(endpoint_id: str, owner_user_id: str | None = None) -> bool:
|
||||
pool = await get_pool()
|
||||
if owner_user_id:
|
||||
status = await pool.execute(
|
||||
"DELETE FROM webhook_endpoints WHERE id = $1::uuid AND owner_user_id = $2",
|
||||
endpoint_id, owner_user_id,
|
||||
)
|
||||
else:
|
||||
status = await pool.execute(
|
||||
"DELETE FROM webhook_endpoints WHERE id = $1::uuid", endpoint_id
|
||||
)
|
||||
return _rowcount(status) > 0
|
||||
|
||||
|
||||
async def record_trigger(endpoint_id: str) -> None:
|
||||
"""Increment trigger_count and update last_triggered_at."""
|
||||
pool = await get_pool()
|
||||
await pool.execute(
|
||||
"""
|
||||
UPDATE webhook_endpoints
|
||||
SET last_triggered_at = $1, trigger_count = COALESCE(trigger_count, 0) + 1
|
||||
WHERE id = $2::uuid
|
||||
""",
|
||||
_utcnow(), endpoint_id,
|
||||
)
|
||||
Reference in New Issue
Block a user