This commit is contained in:
Timothy Jaeryang Baek
2026-07-16 00:58:34 -04:00
parent 771a024b40
commit c55e373b99
14 changed files with 921 additions and 161 deletions
+29 -1
View File
@@ -161,6 +161,12 @@ class EventDefinitions(BaseModel):
CHAT_CREATED: EventDefinition = EventDefinition(
name='chat.created', description='A chat was created.', message='Chat created'
)
CHAT_FINISHED: EventDefinition = EventDefinition(
name='chat.finished', description='A chat response finished.', message='Chat finished'
)
CHAT_FAILED: EventDefinition = EventDefinition(
name='chat.failed', description='A chat response failed.', message='Chat failed'
)
CHAT_IMPORTED: EventDefinition = EventDefinition(
name='chat.imported', description='A chat was imported.', message='Chat imported'
)
@@ -622,6 +628,12 @@ class EventDefinitions(BaseModel):
TERMINAL_SESSION_CLOSED: EventDefinition = EventDefinition(
name='terminal.session.closed', description='A terminal session was closed.', message='Terminal Session closed'
)
NOTIFICATION_TEST: EventDefinition = EventDefinition(
name='notification.test', description='A notification target test was sent.', message='Notification test'
)
NOTIFICATION_MANUAL: EventDefinition = EventDefinition(
name='notification.manual', description='A manual notification was sent.', message='Notification sent'
)
EVENTS = EventDefinitions()
@@ -629,6 +641,7 @@ EVENT_DEFINITIONS = tuple(getattr(EVENTS, field_name) for field_name in EventDef
EVENT_DEFINITIONS_BY_NAME = {definition.name: definition for definition in EVENT_DEFINITIONS}
EVENT_CATALOG = tuple(definition.name for definition in EVENT_DEFINITIONS)
EVENT_CATALOG_SET = set(EVENT_CATALOG)
CHAT_NOTIFICATION_EVENTS = (EVENTS.CHAT_FINISHED.name, EVENTS.CHAT_FAILED.name)
def get_event_catalog() -> list[dict[str, str]]:
@@ -1024,6 +1037,21 @@ class WebhookEventSink:
schedule_webhook_dispatch(app, event)
def schedule_notification_dispatch(app: Any, event: Event) -> None:
try:
from open_webui.utils.notifications import dispatch_notification_event
asyncio.create_task(dispatch_notification_event(app, event))
except RuntimeError:
log.exception('Notification delivery could not be scheduled for %s', event.event)
class NotificationEventSink:
async def handle_event(self, app: Any, event: Event, request: Any | None = None) -> None:
if event.event in CHAT_NOTIFICATION_EVENTS:
schedule_notification_dispatch(app, event)
async def dispatch_event_functions(app: Any, event: Event, request: Any | None = None) -> None:
if not ENABLE_PLUGINS:
return
@@ -1084,7 +1112,7 @@ class EventFunctionSink:
schedule_event_function_dispatch(app, event, request)
EVENT_SINKS = [EventFunctionSink(), WebhookEventSink()]
EVENT_SINKS = [EventFunctionSink(), WebhookEventSink(), NotificationEventSink()]
async def publish_event(
+2
View File
@@ -154,6 +154,7 @@ from open_webui.routers import (
knowledge,
memories,
models,
notifications,
notes,
ollama,
openai,
@@ -761,6 +762,7 @@ app.include_router(notes.router, prefix='/api/v1/notes', tags=['notes'])
app.include_router(models.router, prefix='/api/v1/models', tags=['models'])
app.include_router(notifications.router, prefix='/api/v1/notifications', tags=['notifications'])
app.include_router(knowledge.router, prefix='/api/v1/knowledge', tags=['knowledge'])
app.include_router(prompts.router, prefix='/api/v1/prompts', tags=['prompts'])
app.include_router(tools.router, prefix='/api/v1/tools', tags=['tools'])
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from open_webui.constants import ERROR_MESSAGES
from open_webui.models.config import Config
from open_webui.utils.access_control import has_permission
from open_webui.utils.auth import get_verified_user
from open_webui.utils.notifications import (
create_target,
delete_target,
list_targets,
set_default_target,
test_target,
update_target,
)
router = APIRouter()
class NotificationTargetForm(BaseModel):
id: str | None = None
type: str = 'webhook'
name: str = Field(default='Webhook')
enabled: bool = True
events: list[str] = Field(default_factory=lambda: ['chat.finished', 'chat.failed'])
delivery: str = 'away'
config: dict[str, Any] = Field(default_factory=dict)
async def _check_notifications_access(user) -> None:
if not await Config.get('ui.enable_user_webhooks'):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
if user.role != 'admin' and not await has_permission(
user.id, 'features.webhooks', await Config.get('user.permissions')
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
@router.get('/events')
async def get_notification_events(user=Depends(get_verified_user)):
await _check_notifications_access(user)
return [
{'event': 'chat.finished', 'label': 'Chat finished'},
{'event': 'chat.failed', 'label': 'Chat failed'},
]
@router.get('/targets')
async def get_notification_targets(user=Depends(get_verified_user)):
await _check_notifications_access(user)
return await list_targets(user.id)
@router.post('/targets')
async def create_notification_target(form_data: NotificationTargetForm, user=Depends(get_verified_user)):
await _check_notifications_access(user)
try:
return await create_target(user.id, form_data.model_dump())
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.put('/targets/{target_id}')
async def update_notification_target(
target_id: str, form_data: NotificationTargetForm, user=Depends(get_verified_user)
):
await _check_notifications_access(user)
try:
return await update_target(user.id, target_id, form_data.model_dump(exclude_unset=True))
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.delete('/targets/{target_id}')
async def delete_notification_target(target_id: str, user=Depends(get_verified_user)):
await _check_notifications_access(user)
if not await delete_target(user.id, target_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
return True
@router.put('/targets/{target_id}/default')
async def set_default_notification_target(target_id: str, user=Depends(get_verified_user)):
await _check_notifications_access(user)
try:
return await set_default_target(user.id, target_id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post('/targets/{target_id}/test')
async def test_notification_target(request: Request, target_id: str, user=Depends(get_verified_user)):
await _check_notifications_access(user)
try:
app_name = getattr(request.app.state, 'WEBUI_NAME', 'Open WebUI')
return await test_target(user.id, target_id, app_name)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+28
View File
@@ -52,6 +52,7 @@ from open_webui.routers.retrieval import search_web as _search_web
from open_webui.tasks import stop_item_tasks
from open_webui.events import EVENTS, publish_event
from open_webui.socket.main import sio
from open_webui.utils.notifications import notify_target
from open_webui.utils.sanitize import sanitize_code
log = logging.getLogger(__name__)
@@ -111,6 +112,33 @@ async def _has_read_access_to_file(
# =============================================================================
async def notify(
message: str,
target: str = '',
title: str = '',
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Send a notification to the user's configured notification target.
:param message: Notification body.
:param target: Optional target id or name. Empty uses the default target.
:param title: Optional notification title.
"""
user_id = (__user__ or {}).get('id')
if not user_id:
return 'Notification failed: user not found.'
app_name = getattr(getattr(__request__, 'app', None), 'state', None)
app_name = getattr(app_name, 'WEBUI_NAME', 'Open WebUI')
try:
result = await notify_target(user_id, message, target=target, title=title, app_name=app_name)
return f'Notification sent to {result.get("target_id")}.'
except Exception as e:
return f'Notification failed: {e}'
async def get_current_timestamp(
__request__: Request = None,
__user__: dict = None,
+51 -41
View File
@@ -48,6 +48,7 @@ from open_webui.models.models import Models
from open_webui.models.notes import Notes
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.users import UserModel, Users
from open_webui.events import EVENTS, publish_event
from open_webui.retrieval.utils import get_sources_from_items
from open_webui.routers.images import (
CreateImageForm,
@@ -129,13 +130,37 @@ from open_webui.utils.tools import (
get_tools,
get_updated_tool_function,
)
from open_webui.utils.webhook import post_webhook
from starlette.responses import JSONResponse, Response, StreamingResponse
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
async def publish_chat_finished_event(request: Request, user: UserModel, metadata: dict, title: str, content: str):
chat_id = metadata.get('chat_id')
if getattr(request.state, 'internal', False) is True or not chat_id or chat_id.startswith(('channel:', 'local:')):
return
webui_url = await Config.get('webui.url')
await publish_event(
request,
EVENTS.CHAT_FINISHED,
actor=user,
subject_id=chat_id,
subject_type='chat',
data={
'user_id': user.id,
'chat_id': chat_id,
'message_id': metadata.get('message_id'),
'model_id': metadata.get('model_id'),
'title': title,
'url': f'{webui_url}/c/{chat_id}' if webui_url else f'/c/{chat_id}',
'message': content,
},
message=title or 'Chat finished',
)
# We believe in one maker of all models, seen and unseen,
# and in the reasoning which proceeds from the architect.
# We look for the resurrection of dead processes and the
@@ -3585,26 +3610,7 @@ async def non_streaming_chat_response_handler(response, ctx):
},
)
# Send a webhook notification if the user is not active
if (
getattr(request.state, 'internal', False) is not True
and await Config.get('ui.enable_user_webhooks')
and not await Users.is_user_active(user.id)
):
webhook_url = await Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
webui_url = await Config.get('webui.url')
await post_webhook(
request.app.state.WEBUI_NAME,
webhook_url,
f'{content}\n\n{title} - {webui_url}/c/{metadata["chat_id"]}',
{
'action': 'chat',
'message': content,
'title': title,
'url': f'{webui_url}/c/{metadata["chat_id"]}',
},
)
await publish_chat_finished_event(request, user, metadata, title, content)
ctx['assistant_message'] = {
'content': content,
@@ -3617,6 +3623,29 @@ async def non_streaming_chat_response_handler(response, ctx):
response = build_response_object(response, merge_events_into_response(response_data, events))
except Exception as e:
log.debug(f'Error occurred while processing request: {e}')
chat_id = metadata.get('chat_id')
if (
getattr(request.state, 'internal', False) is not True
and chat_id
and not chat_id.startswith(('channel:', 'local:'))
):
webui_url = await Config.get('webui.url')
await publish_event(
request,
EVENTS.CHAT_FAILED,
actor=user,
subject_id=chat_id,
subject_type='chat',
data={
'user_id': user.id,
'chat_id': chat_id,
'message_id': metadata.get('message_id'),
'model_id': metadata.get('model_id'),
'url': f'{webui_url}/c/{chat_id}' if webui_url else f'/c/{chat_id}',
'message': str(e),
},
message='Chat failed',
)
pass
return response
@@ -5310,26 +5339,7 @@ async def streaming_chat_response_handler(response, ctx):
touch=False,
)
# Send a webhook notification if the user is not active
if (
getattr(request.state, 'internal', False) is not True
and await Config.get('ui.enable_user_webhooks')
and not await Users.is_user_active(user.id)
):
webhook_url = await Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
webui_url = await Config.get('webui.url')
await post_webhook(
request.app.state.WEBUI_NAME,
webhook_url,
f'{content}\n\n{title} - {webui_url}/c/{metadata["chat_id"]}',
{
'action': 'chat',
'message': content,
'title': title,
'url': f'{webui_url}/c/{metadata["chat_id"]}',
},
)
await publish_chat_finished_event(request, user, metadata, title, content)
await event_emitter(
{
+244
View File
@@ -0,0 +1,244 @@
from __future__ import annotations
import logging
import time
import uuid
from typing import Any
from open_webui.models.config import Config
from open_webui.models.users import Users
from open_webui.retrieval.web.utils import validate_url
from open_webui.utils.webhook import post_webhook
VALID_EVENTS = {'chat.finished', 'chat.failed'}
VALID_DELIVERY = {'away', 'always'}
DEFAULT_TARGET_ID = 'webhook'
log = logging.getLogger(__name__)
def _normalize_target(target: dict[str, Any], existing: dict[str, Any] | None = None) -> dict[str, Any]:
existing = existing or {}
now = int(time.time())
target_type = str(target.get('type') or existing.get('type') or 'webhook').strip()
if target_type != 'webhook':
raise ValueError('Unsupported notification target type')
name = str(target.get('name') or existing.get('name') or 'Webhook').strip() or 'Webhook'
slug = ''.join(ch.lower() if ch.isalnum() else '-' for ch in name).strip('-')
while '--' in slug:
slug = slug.replace('--', '-')
target_id = str(target.get('id') or existing.get('id') or slug[:48] or uuid.uuid4().hex[:8]).strip()
config = dict(existing.get('config') or {})
config.update(target.get('config') or {})
url = str(config.get('url') or '').strip()
if not url:
raise ValueError('Webhook URL is required')
if '...' in url:
url = str((existing.get('config') or {}).get('url') or '').strip()
validate_url(url)
config['url'] = url
events = target.get('events', existing.get('events') or sorted(VALID_EVENTS))
events = [str(event) for event in events if str(event) in VALID_EVENTS]
if not events:
raise ValueError('At least one notification event is required')
delivery = str(target.get('delivery') or existing.get('delivery') or 'away')
if delivery not in VALID_DELIVERY:
raise ValueError('Invalid notification delivery mode')
return {
'id': target_id,
'type': target_type,
'name': name,
'enabled': bool(target.get('enabled', existing.get('enabled', True))),
'events': events,
'delivery': delivery,
'config': config,
'created_at': int(existing.get('created_at') or now),
'updated_at': now,
}
def _public_target(target: dict[str, Any]) -> dict[str, Any]:
config = dict(target.get('config') or {})
if config.get('url'):
url = str(config['url'])
config['url'] = '********' if len(url) <= 12 else f'{url[:8]}...{url[-4:]}'
return {**target, 'config': config}
async def _load_notifications(user_id: str) -> dict[str, Any]:
user = await Users.get_user_by_id(user_id)
if not user:
raise ValueError('User not found')
settings = getattr(user, 'settings', None)
settings = settings.model_dump(exclude_none=True) if hasattr(settings, 'model_dump') else dict(settings or {})
notifications = dict(settings.get('notifications') or {})
targets = notifications.get('targets')
if not isinstance(targets, list) or not targets:
legacy_url = str(settings.get('ui', {}).get('notifications', {}).get('webhook_url') or '').strip()
if legacy_url:
target = _normalize_target(
{
'id': DEFAULT_TARGET_ID,
'name': 'Webhook',
'type': 'webhook',
'enabled': True,
'events': sorted(VALID_EVENTS),
'delivery': 'away',
'config': {'url': legacy_url},
}
)
notifications = {'targets': [target], 'default_target_id': DEFAULT_TARGET_ID}
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
else:
notifications['targets'] = [target for target in targets if isinstance(target, dict)]
notifications.setdefault(
'default_target_id', notifications['targets'][0].get('id') if notifications['targets'] else None
)
return notifications
async def list_targets(user_id: str) -> dict[str, Any]:
notifications = await _load_notifications(user_id)
return {
'targets': [_public_target(target) for target in notifications.get('targets') or []],
'default_target_id': notifications.get('default_target_id'),
}
async def create_target(user_id: str, payload: dict[str, Any]) -> dict[str, Any]:
notifications = await _load_notifications(user_id)
targets = notifications.get('targets') or []
target = _normalize_target(payload)
if any(existing.get('id') == target['id'] for existing in targets):
target['id'] = f'{target["id"]}-{uuid.uuid4().hex[:6]}'
targets.append(target)
notifications['targets'] = targets
notifications.setdefault('default_target_id', target['id'])
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
return _public_target(target)
async def update_target(user_id: str, target_id: str, payload: dict[str, Any]) -> dict[str, Any]:
notifications = await _load_notifications(user_id)
targets = notifications.get('targets') or []
for index, existing in enumerate(targets):
if existing.get('id') == target_id:
updated = _normalize_target({'id': target_id, **payload}, existing=existing)
targets[index] = updated
notifications['targets'] = targets
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
return _public_target(updated)
raise ValueError('Notification target not found')
async def delete_target(user_id: str, target_id: str) -> bool:
notifications = await _load_notifications(user_id)
targets = notifications.get('targets') or []
next_targets = [target for target in targets if target.get('id') != target_id]
if len(next_targets) == len(targets):
return False
notifications['targets'] = next_targets
if notifications.get('default_target_id') == target_id:
notifications['default_target_id'] = next_targets[0].get('id') if next_targets else None
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
return True
async def set_default_target(user_id: str, target_id: str) -> dict[str, Any]:
notifications = await _load_notifications(user_id)
if not any(target.get('id') == target_id for target in notifications.get('targets') or []):
raise ValueError('Notification target not found')
notifications['default_target_id'] = target_id
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
return await list_targets(user_id)
def _find_target(notifications: dict[str, Any], target: str = '') -> dict[str, Any] | None:
targets = notifications.get('targets') or []
target = target.strip()
target_id = target or str(notifications.get('default_target_id') or '')
for item in targets:
if item.get('id') == target_id or item.get('name') == target:
return item
return None
async def _send_webhook(app_name: str, target: dict[str, Any], message: str, data: dict[str, Any], title: str = ''):
url = str((target.get('config') or {}).get('url') or '').strip()
if not url:
raise ValueError('Webhook URL is required')
ok = await post_webhook(app_name, url, title or message, data, description=message if title else None)
if not ok:
raise ValueError('Webhook delivery failed')
async def test_target(user_id: str, target_id: str, app_name: str = 'Open WebUI') -> dict[str, Any]:
notifications = await _load_notifications(user_id)
target = _find_target(notifications, target_id)
if not target:
raise ValueError('Notification target not found')
await _send_webhook(
app_name,
target,
'This is a test notification from Open WebUI.',
{'event': 'notification.test', 'action': 'test', 'user_id': user_id},
'Test notification',
)
return {'ok': True}
async def notify_target(
user_id: str, message: str, target: str = '', title: str = '', app_name: str = 'Open WebUI'
) -> dict[str, Any]:
notifications = await _load_notifications(user_id)
item = _find_target(notifications, target)
if not item:
raise ValueError('Notification target not found')
if not item.get('enabled', True):
raise ValueError('Notification target is disabled')
await _send_webhook(
app_name,
item,
message,
{'event': 'notification.manual', 'action': 'notify', 'user_id': user_id, 'message': message, 'title': title},
title or 'Notification',
)
return {'ok': True, 'target_id': item.get('id')}
async def dispatch_notification_event(app: Any, event: Any) -> None:
if event.event not in VALID_EVENTS or not await Config.get('ui.enable_user_webhooks'):
return
from open_webui.events import event_user_ids
app_name = getattr(getattr(app, 'state', None), 'WEBUI_NAME', 'Open WebUI')
for user_id in event_user_ids(event):
try:
notifications = await _load_notifications(user_id)
is_active = await Users.is_user_active(user_id)
for target in notifications.get('targets') or []:
if not target.get('enabled', True):
continue
if event.event not in target.get('events', []):
continue
if target.get('delivery', 'away') == 'away' and is_active:
continue
data = event.model_dump()
title = event.message or data.get('message') or event.event
message = str((event.data or {}).get('message') or title)
await _send_webhook(app_name, target, message, data, title)
except Exception:
log.exception('Notification delivery failed for user %s and event %s', user_id, event.event)
+9
View File
@@ -68,6 +68,7 @@ from open_webui.tools.builtin import (
list_knowledge_bases,
list_memories,
list_memory_paths,
notify,
query_knowledge_bases,
query_knowledge_files,
read_memory_path,
@@ -503,6 +504,7 @@ async def get_builtin_tools(
'channels.enable',
'automations.enable',
'calendar.enable',
'ui.enable_user_webhooks',
'subagents.enable',
'subagents.background_enabled',
)
@@ -684,6 +686,13 @@ async def get_builtin_tools(
[search_calendar_events, create_calendar_event, update_calendar_event, delete_calendar_event]
)
if (
is_builtin_tool_enabled('notifications')
and config.get('ui.enable_user_webhooks')
and await has_user_permission('webhooks')
):
builtin_functions.append(notify)
if getattr(request.state, 'internal', False) is True:
from open_webui.utils.subagents import MUTATING_MEMORY_TOOLS