From 745396867888718289a2dfcf0809b3e162e00629 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 25 Jun 2026 14:19:30 +0100 Subject: [PATCH] refac --- backend/open_webui/events.py | 139 ++++- backend/open_webui/main.py | 104 +++- src/lib/apis/index.ts | 119 ++++- .../components/admin/Settings/Events.svelte | 478 ++++++++++++++++++ .../components/admin/Settings/General.svelte | 31 +- 5 files changed, 812 insertions(+), 59 deletions(-) create mode 100644 src/lib/components/admin/Settings/Events.svelte diff --git a/backend/open_webui/events.py b/backend/open_webui/events.py index 5b6bafbf21..a8a74865aa 100644 --- a/backend/open_webui/events.py +++ b/backend/open_webui/events.py @@ -9,12 +9,15 @@ from typing import Any from open_webui.env import VERSION from open_webui.models.config import Config +from open_webui.retrieval.web.utils import validate_url from open_webui.utils.webhook import post_webhook log = logging.getLogger(__name__) -EVENT_VERSION = 'event.v1' MAX_STRING_LENGTH = 1000 +EVENT_WEBHOOKS_CONFIG_KEY = 'events.webhooks' +LEGACY_WEBHOOK_CONFIG_KEY = 'webhook_url' +DEFAULT_WEBHOOK_ID = 'default' class EVENTS(StrEnum): @@ -192,6 +195,7 @@ class EVENTS(StrEnum): EVENT_CATALOG = tuple(event.value for event in EVENTS) +EVENT_CATALOG_SET = set(EVENT_CATALOG) SENSITIVE_KEYS = { 'password', @@ -211,6 +215,126 @@ SENSITIVE_KEYS = { SAFE_ACTOR_FIELDS = ('id', 'name', 'email', 'role', 'created_at', 'updated_at') +def normalize_event_webhook(webhook: dict[str, Any], *, create: bool = False) -> dict[str, Any]: + now = int(time.time()) + webhook_id = str(webhook.get('id') or uuid.uuid4()) + url = str(webhook.get('url') or '').strip() + + events = [str(event).strip() for event in (webhook.get('events') or ['*']) if str(event).strip()] + events = events or ['*'] + for event_filter in events: + if event_filter == '*': + continue + if event_filter.endswith('.*'): + prefix = event_filter[:-2] + if prefix and any(event.startswith(f'{prefix}.') for event in EVENT_CATALOG): + continue + raise ValueError(f'Invalid event pattern: {event_filter}') + if event_filter not in EVENT_CATALOG_SET: + raise ValueError(f'Invalid event: {event_filter}') + + return { + 'id': webhook_id, + 'name': str(webhook.get('name') or ('Default webhook' if webhook_id == DEFAULT_WEBHOOK_ID else 'Webhook')), + 'url': url, + 'enabled': bool(webhook.get('enabled', True)), + 'events': events, + 'created_at': int(webhook.get('created_at') or now), + 'updated_at': now if create or webhook.get('updated_at') is None else int(webhook.get('updated_at') or now), + } + + +def event_webhook_matches(webhook: dict[str, Any], event_name: str) -> bool: + if not webhook.get('enabled', True): + return False + + for event_filter in webhook.get('events') or ['*']: + if event_filter == '*': + return True + if event_filter.endswith('.*') and event_name.startswith(f'{event_filter[:-2]}.'): + return True + if event_name == event_filter: + return True + return False + + +async def get_event_webhooks() -> list[dict[str, Any]]: + webhooks = await Config.get(EVENT_WEBHOOKS_CONFIG_KEY, []) or [] + if not isinstance(webhooks, list): + return [] + return [normalize_event_webhook(webhook) for webhook in webhooks if isinstance(webhook, dict)] + + +async def migrate_legacy_webhook_config() -> list[dict[str, Any]]: + webhooks = await get_event_webhooks() + if any(webhook.get('id') == DEFAULT_WEBHOOK_ID for webhook in webhooks): + return webhooks + + now = int(time.time()) + legacy_url = await Config.get(LEGACY_WEBHOOK_CONFIG_KEY) or '' + if not legacy_url: + return webhooks + + webhooks = [ + { + 'id': DEFAULT_WEBHOOK_ID, + 'name': 'Default webhook', + 'url': legacy_url, + 'enabled': True, + 'events': ['*'], + 'created_at': now, + 'updated_at': now, + }, + *webhooks, + ] + await Config.upsert({EVENT_WEBHOOKS_CONFIG_KEY: webhooks}) + return webhooks + + +async def upsert_event_webhook(webhook: dict[str, Any]) -> dict[str, Any]: + webhooks = await get_event_webhooks() + url = str(webhook.get('url') or '').strip() + if url: + validate_url(url) + + normalized = normalize_event_webhook(webhook, create=True) + replaced = False + next_webhooks = [] + + for existing in webhooks: + if existing.get('id') == normalized['id']: + next_webhooks.append( + { + **existing, + **normalized, + 'created_at': existing.get('created_at') or normalized['created_at'], + } + ) + replaced = True + else: + next_webhooks.append(existing) + + if not replaced: + next_webhooks.append(normalized) + + await Config.upsert({EVENT_WEBHOOKS_CONFIG_KEY: next_webhooks}) + return next(webhook for webhook in next_webhooks if webhook.get('id') == normalized['id']) + + +async def delete_event_webhook(webhook_id: str) -> bool: + webhooks = await get_event_webhooks() + next_webhooks = [webhook for webhook in webhooks if webhook.get('id') != webhook_id] + if len(next_webhooks) == len(webhooks): + return False + + values = {EVENT_WEBHOOKS_CONFIG_KEY: next_webhooks} + if webhook_id == DEFAULT_WEBHOOK_ID: + values[LEGACY_WEBHOOK_CONFIG_KEY] = '' + + await Config.upsert(values) + return True + + @dataclass class Event: schema: str @@ -295,7 +419,7 @@ def build_event( ) return Event( - schema=EVENT_VERSION, + schema=VERSION, id=str(uuid.uuid4()), event=event_name, resource=resource, @@ -313,10 +437,6 @@ def build_event( class WebhookEventSink: async def handle_event(self, app: Any, event: Event) -> None: - url = await Config.get('webhook_url') - if not url: - return - name = getattr(getattr(app, 'state', None), 'WEBUI_NAME', 'Open WebUI') subject = event.subject or {} subject_id = subject.get('id') @@ -324,7 +444,12 @@ class WebhookEventSink: if subject_id: message = f'{message} ({subject_id})' - await post_webhook(name, url, message, event.model_dump()) + for webhook in await get_event_webhooks(): + if webhook.get('url') and event_webhook_matches(webhook, event.event): + try: + await post_webhook(name, webhook['url'], message, event.model_dump()) + except Exception: + log.exception('Event webhook failed for %s', webhook.get('id')) EVENT_SINKS = [WebhookEventSink()] diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 91267faaab..2b7cfa3f93 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -116,12 +116,20 @@ from open_webui.env import ( WEBUI_SESSION_COOKIE_SAME_SITE, WEBUI_SESSION_COOKIE_SECURE, ) +from open_webui.events import ( + EVENT_CATALOG, + EVENTS, + delete_event_webhook, + get_event_webhooks, + migrate_legacy_webhook_config, + publish_event, + upsert_event_webhook, +) from open_webui.internal.db import engine, get_async_session from open_webui.models.access_grants import AccessGrants from open_webui.models.channels import Channels from open_webui.models.chats import ChatForm, Chats from open_webui.models.config import Config -from open_webui.events import EVENT_CATALOG, EVENT_VERSION, EVENTS, publish_event from open_webui.models.functions import Functions from open_webui.models.messages import Messages from open_webui.models.models import Models @@ -298,6 +306,7 @@ async def lifespan(app: FastAPI): await import_legacy_config_json() await seed_registered_defaults() await initialize_runtime_config(app) + await migrate_legacy_webhook_config() await publish_event(app, EVENTS.SYSTEM_STARTUP_STARTED, source='system') if LICENSE_KEY: @@ -2005,37 +2014,102 @@ async def get_app_config(request: Request): } -class UrlForm(BaseModel): +class EventWebhookForm(BaseModel): + name: str | None = None url: str + enabled: bool = True + events: list[str] | None = None -@app.get('/api/webhook') -async def get_webhook_url(user=Depends(get_admin_user)): - return { - 'url': await Config.get('webhook_url'), - } +class EventWebhookUpdateForm(BaseModel): + name: str | None = None + url: str | None = None + enabled: bool | None = None + events: list[str] | None = None @app.get('/api/events') async def get_event_catalog(user=Depends(get_admin_user)): return { - 'schema': EVENT_VERSION, + 'schema': VERSION, 'events': list(EVENT_CATALOG), } -@app.post('/api/webhook') -async def update_webhook_url(form_data: UrlForm, user=Depends(get_admin_user)): - await Config.upsert({'webhook_url': form_data.url}) - app.state.WEBHOOK_URL = form_data.url +@app.get('/api/events/webhooks') +async def get_event_webhooks_api(user=Depends(get_admin_user)): + return await get_event_webhooks() + + +@app.post('/api/events/webhooks') +async def create_event_webhook(form_data: EventWebhookForm, user=Depends(get_admin_user)): + try: + webhook = await upsert_event_webhook( + { + 'name': form_data.name, + 'url': form_data.url, + 'enabled': form_data.enabled, + 'events': form_data.events, + } + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + await publish_event( app, EVENTS.CONFIG_WEBHOOK_UPDATED, actor=user, - subject_id='webhook_url', subject_type='config', - data={'enabled': bool(form_data.url)}, + subject_id=webhook['id'], + subject_type='config', + data={'action': 'created', 'enabled': webhook.get('enabled'), 'events': webhook.get('events')}, ) - return {'url': form_data.url} + return webhook + + +@app.put('/api/events/webhooks/{webhook_id}') +async def update_event_webhook(webhook_id: str, form_data: EventWebhookUpdateForm, user=Depends(get_admin_user)): + webhooks = await get_event_webhooks() + existing = next((webhook for webhook in webhooks if webhook.get('id') == webhook_id), None) + if not existing: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Webhook not found') + + try: + webhook = await upsert_event_webhook( + { + **existing, + **form_data.model_dump(exclude_unset=True), + 'id': webhook_id, + } + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + await publish_event( + app, + EVENTS.CONFIG_WEBHOOK_UPDATED, + actor=user, + subject_id=webhook_id, + subject_type='config', + data={'action': 'updated', 'enabled': webhook.get('enabled'), 'events': webhook.get('events')}, + ) + return webhook + + +@app.delete('/api/events/webhooks/{webhook_id}') +async def delete_event_webhook_api(webhook_id: str, user=Depends(get_admin_user)): + deleted = await delete_event_webhook(webhook_id) + if not deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Webhook not found') + + await publish_event( + app, + EVENTS.CONFIG_WEBHOOK_UPDATED, + actor=user, + subject_id=webhook_id, + subject_type='config', + data={'action': 'deleted'}, + ) + return {'status': True} @app.get('/api/version') diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 24b096cd75..e2efd33d71 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -1572,10 +1572,20 @@ export const getVersionUpdates = async (token: string) => { return res; }; -export const getWebhookUrl = async (token: string) => { +export type EventWebhook = { + id: string; + name: string; + url: string; + enabled: boolean; + events: string[]; + created_at?: number; + updated_at?: number; +}; + +export const getEvents = async (token: string) => { let error = null; - const res = await fetch(`${WEBUI_BASE_URL}/api/webhook`, { + const res = await fetch(`${WEBUI_BASE_URL}/api/events`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -1596,21 +1606,18 @@ export const getWebhookUrl = async (token: string) => { throw error; } - return res.url; + return res; }; -export const updateWebhookUrl = async (token: string, url: string) => { +export const getEventWebhooks = async (token: string): Promise => { let error = null; - const res = await fetch(`${WEBUI_BASE_URL}/api/webhook`, { - method: 'POST', + const res = await fetch(`${WEBUI_BASE_URL}/api/events/webhooks`, { + method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` - }, - body: JSON.stringify({ - url: url - }) + } }) .then(async (res) => { if (!res.ok) throw await res.json(); @@ -1626,7 +1633,97 @@ export const updateWebhookUrl = async (token: string, url: string) => { throw error; } - return res.url; + return res; +}; + +export const createEventWebhook = async ( + token: string, + webhook: Partial +): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_BASE_URL}/api/events/webhooks`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(webhook) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateEventWebhook = async ( + token: string, + id: string, + webhook: Partial +): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_BASE_URL}/api/events/webhooks/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(webhook) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteEventWebhook = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_BASE_URL}/api/events/webhooks/${id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err; + return null; + }); + + if (error) { + throw error; + } + + return res; }; export interface ModelConfig { diff --git a/src/lib/components/admin/Settings/Events.svelte b/src/lib/components/admin/Settings/Events.svelte new file mode 100644 index 0000000000..664a80d68d --- /dev/null +++ b/src/lib/components/admin/Settings/Events.svelte @@ -0,0 +1,478 @@ + + + +
+
+

+ {editing ? $i18n.t('Edit event webhook') : $i18n.t('Add event webhook')} +

+ + +
+ +
+
+
+
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+ + + + +
+
+
+ +
+ + +
+ + {#if !allEvents} +
+ {#if form.events.length > 0} +
+ {#each form.events as event} +
+ {event} + +
+ {/each} +
+ {/if} + +
+ { + if (event.key === 'Enter') { + event.preventDefault(); + addPattern(); + } + }} + /> + +
+ +
+ {#each filteredEvents as event} + + {/each} +
+ +
+ {$i18n.t( + 'Event names may change as Open WebUI evolves. Use broad patterns like user.* for integrations that should continue across new related events.' + )} +
+
+ {/if} + +
+
+ {#if editing} + + {/if} +
+ + +
+
+
+
+
+
+
+ + { + deleteWebhook(); + }} +/> + +
+
{$i18n.t('Events')}
+
+ +
+
+
{$i18n.t('Webhooks')}
+ + + + +
+ +
+ {#each webhooks as webhook} +
+
+
+ + {webhook.id === 'default' ? $i18n.t('Default webhook') : webhook.name} + + + {#if webhook.id === 'default'} + · {$i18n.t('Default')} + {/if} + · {urlHost(webhook.url)} · {eventSummary(webhook)} + +
+
+ +
+ + + + + + { + toggleWebhook(webhook, event.detail); + }} + /> + +
+
+ {/each} +
+ + {#if webhooks.length === 0} +
+ {$i18n.t('No event webhooks configured.')} +
+ {/if} + +
+
+ {$i18n.t('Send product events as JSON to external services. Chat destinations receive readable messages.')} +
+
+
+
diff --git a/src/lib/components/admin/Settings/General.svelte b/src/lib/components/admin/Settings/General.svelte index e65f95a7ed..5ffc6c4e91 100644 --- a/src/lib/components/admin/Settings/General.svelte +++ b/src/lib/components/admin/Settings/General.svelte @@ -2,7 +2,7 @@ import DOMPurify from 'dompurify'; import { v4 as uuidv4 } from 'uuid'; - import { getBackendConfig, getVersionUpdates, getWebhookUrl, updateWebhookUrl } from '$lib/apis'; + import { getBackendConfig, getVersionUpdates } from '$lib/apis'; import { getAdminConfig, updateAdminConfig } from '$lib/apis/auths'; import { getBanners, setBanners } from '$lib/apis/configs'; import Switch from '$lib/components/common/Switch.svelte'; @@ -15,6 +15,7 @@ import { toast } from 'svelte-sonner'; import Textarea from '$lib/components/common/Textarea.svelte'; import Banners from './Interface/Banners.svelte'; + import Events from './Events.svelte'; const i18n = getContext('i18n'); @@ -27,7 +28,6 @@ }; let adminConfig = null; - let webhookUrl = ''; let banners: Banner[] = []; @@ -51,7 +51,6 @@ }; const updateHandler = async () => { - webhookUrl = await updateWebhookUrl(localStorage.token, webhookUrl); const res = await updateAdminConfig(localStorage.token, adminConfig); await updateBanners(); @@ -66,15 +65,7 @@ }; onMount(async () => { - await Promise.all([ - (async () => { - adminConfig = await getAdminConfig(localStorage.token); - })(), - - (async () => { - webhookUrl = await getWebhookUrl(localStorage.token); - })() - ]); + adminConfig = await getAdminConfig(localStorage.token); banners = [...$_banners]; }); @@ -385,22 +376,10 @@ -
-
-
{$i18n.t('Webhook URL')}
-
- -
- -
-
+ +
{$i18n.t('UI')}