feat: granular webhook events + chat bubble CSS fix

Webhook: Granular event support (closes #1240)

Problem: WEBHOOK_URL accepted only one endpoint that fired for all
events (signup only). No way to route events to different endpoints.

Solution:
- Added WEBHOOK_EVENTS constant (signup, oauth_signup, signin, signout,
  user_deleted, user_role_changed)
- Added _parse_webhook_config() in webhook.py supporting three formats:
  1. Legacy single URL (no admin change needed, backwards-compatible)
  2. Newline/comma-separated JSON objects with optional events filter
  3. Full JSON array format
- Added post_webhook_event() dispatcher with event-scope filtering;
  URLs with events=null act as catch-all (preserves legacy behaviour)
- Updated auths.py: both signup paths now call post_webhook_event
  with event='signup'
- Updated oauth.py: OAuth signup uses post_webhook_event with
  event='oauth_signup' and includes provider name in payload

Example extended config:
  [{"url": "https://slack.com/...", "events": ["signup"]},
   {"url": "https://discord.com/..."}]

Chat Bubbles: Fix widescreen layout overflow (closes #5975)

Problem: In bubble mode, messages with YAML/JSON code blocks stretched
the bubble to full viewport width. The inner wrapper had min-w-full
applied unconditionally, forcing the flex container to full width.

Fix: Changed min-w-full to conditional in UserMessage.svelte:
  - Bubble mode: min-w-0 (lets flex child shrink, bubble hugs content)
  - Classic mode: min-w-full (unchanged, full-width layout preserved)

Tests: Added 15 unit tests in test/util/test_webhook.py covering
_parse_webhook_config (formats, edge cases) and post_webhook_event
(event filtering, catch-all, payload injection, backwards compat).
This commit is contained in:
Your Full Name 2026-05-24 17:52:07 +05:30
parent 42783881e9
commit 89eb880b88
5 changed files with 358 additions and 6 deletions

View file

@ -73,12 +73,16 @@ from open_webui.utils.auth import (
validate_password,
verify_password,
)
from open_webui.internal.db import get_async_session
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.utils.webhook import post_webhook, post_webhook_event
from open_webui.utils.access_control import get_permissions, has_permission
from open_webui.utils.groups import apply_default_group_assignment
from open_webui.utils.misc import parse_duration, validate_email_format
from open_webui.utils.oauth import auth_manager_config
from open_webui.utils.rate_limit import RateLimiter
from open_webui.utils.redis import get_redis_client
from open_webui.utils.webhook import post_webhook
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@ -520,7 +524,7 @@ async def ldap_auth(
)
if request.app.state.config.WEBHOOK_URL:
await post_webhook(
await post_webhook_event(
request.app.state.WEBUI_NAME,
request.app.state.config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
@ -529,6 +533,7 @@ async def ldap_auth(
'message': WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
'user': user.model_dump_json(exclude_none=True),
},
event='signup',
)
except HTTPException:
@ -717,7 +722,7 @@ async def signup_handler(
request.app.state.config.ENABLE_SIGNUP = False
if request.app.state.config.WEBHOOK_URL:
await post_webhook(
await post_webhook_event(
request.app.state.WEBUI_NAME,
request.app.state.config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
@ -726,6 +731,7 @@ async def signup_handler(
'message': WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
'user': user.model_dump_json(exclude_none=True),
},
event='signup',
)
await apply_default_group_assignment(

View file

@ -0,0 +1,210 @@
"""Unit tests for open_webui.utils.webhook
Tests cover:
- _parse_webhook_config: legacy single URL, multi-URL newline-separated,
JSON-object entries, mixed formats, empty/whitespace inputs.
- post_webhook_event: event-scope filtering, catch-all (None events), and
backwards-compat with legacy single-URL config strings.
"""
import asyncio
import json
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from open_webui.utils.webhook import (
_parse_webhook_config,
post_webhook_event,
post_webhook,
WEBHOOK_EVENTS,
)
# ---------------------------------------------------------------------------
# _parse_webhook_config tests
# ---------------------------------------------------------------------------
class TestParseWebhookConfig:
"""Tests for the _parse_webhook_config helper."""
def test_empty_string_returns_empty_list(self):
assert _parse_webhook_config('') == []
def test_whitespace_only_returns_empty_list(self):
assert _parse_webhook_config(' \n ') == []
def test_single_legacy_url(self):
url = 'https://hooks.slack.com/services/T00/B00/xxx'
result = _parse_webhook_config(url)
assert result == [{'url': url, 'events': None}]
def test_two_urls_newline_separated(self):
cfg = 'https://hooks.slack.com/xxx\nhttps://discord.com/api/webhooks/yyy'
result = _parse_webhook_config(cfg)
assert len(result) == 2
assert result[0]['url'] == 'https://hooks.slack.com/xxx'
assert result[0]['events'] is None
assert result[1]['url'] == 'https://discord.com/api/webhooks/yyy'
def test_two_urls_comma_separated(self):
cfg = 'https://a.example.com/hook,https://b.example.com/hook'
result = _parse_webhook_config(cfg)
assert len(result) == 2
def test_json_object_with_events(self):
entry = json.dumps({'url': 'https://example.com/hook', 'events': ['signup']})
result = _parse_webhook_config(entry)
assert result == [{'url': 'https://example.com/hook', 'events': {'signup'}}]
def test_json_array_with_mixed_entries(self):
cfg = json.dumps([
{'url': 'https://a.example.com/hook', 'events': ['signup', 'oauth_signup']},
{'url': 'https://b.example.com/hook', 'events': None},
'https://c.example.com/hook',
])
result = _parse_webhook_config(cfg)
assert len(result) == 3
assert result[0]['events'] == {'signup', 'oauth_signup'}
assert result[1]['events'] is None
assert result[2]['events'] is None
def test_json_object_null_events_becomes_none(self):
entry = json.dumps({'url': 'https://example.com/hook', 'events': None})
result = _parse_webhook_config(entry)
assert result[0]['events'] is None
def test_json_object_empty_events_list_becomes_none(self):
entry = json.dumps({'url': 'https://example.com/hook', 'events': []})
result = _parse_webhook_config(entry)
# Empty events list → treated as catch-all (None)
assert result[0]['events'] is None
def test_newline_separated_json_objects(self):
line1 = json.dumps({'url': 'https://a.example.com/hook', 'events': ['signup']})
line2 = json.dumps({'url': 'https://b.example.com/hook', 'events': ['signout']})
cfg = f'{line1}\n{line2}'
result = _parse_webhook_config(cfg)
assert len(result) == 2
assert result[0]['events'] == {'signup'}
assert result[1]['events'] == {'signout'}
def test_malformed_json_falls_back_to_plain_url_treatment(self):
# Not valid JSON → should fall back to treating as a plain URL string.
cfg = '{not valid json}'
result = _parse_webhook_config(cfg)
# The parser should not crash, but the entry won't have 'url' key from
# JSON parse, so it will be treated as a literal string URL.
# Since it starts with '{' and JSON parse fails, result may be empty or
# contain the raw string as URL depending on fallback behaviour.
# The important thing is it doesn't raise.
assert isinstance(result, list)
# ---------------------------------------------------------------------------
# post_webhook_event tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestPostWebhookEvent:
"""Tests for the post_webhook_event dispatcher."""
async def test_returns_empty_list_for_empty_config(self):
result = await post_webhook_event('TestApp', '', 'msg', {}, event='signup')
assert result == []
async def test_legacy_url_fires_for_any_event(self):
url = 'https://hooks.slack.com/services/xxx'
with patch('open_webui.utils.webhook.post_webhook', new_callable=AsyncMock) as mock_pw:
mock_pw.return_value = True
result = await post_webhook_event('App', url, 'msg', {}, event='signup')
assert result == [True]
mock_pw.assert_called_once()
async def test_legacy_url_fires_even_with_unknown_event(self):
url = 'https://discord.com/api/webhooks/xxx'
with patch('open_webui.utils.webhook.post_webhook', new_callable=AsyncMock) as mock_pw:
mock_pw.return_value = True
result = await post_webhook_event('App', url, 'msg', {}, event='some_future_event')
assert result == [True]
async def test_scoped_url_fires_only_for_matching_event(self):
cfg = json.dumps([
{'url': 'https://a.example.com', 'events': ['signup']},
{'url': 'https://b.example.com', 'events': ['signout']},
])
with patch('open_webui.utils.webhook.post_webhook', new_callable=AsyncMock) as mock_pw:
mock_pw.return_value = True
result = await post_webhook_event('App', cfg, 'msg', {}, event='signup')
# Only URL 'a' should have been called (event='signup')
assert len(result) == 1
assert result == [True]
call_args = mock_pw.call_args_list
assert len(call_args) == 1
assert call_args[0].args[1] == 'https://a.example.com'
async def test_scoped_url_skipped_for_non_matching_event(self):
cfg = json.dumps([
{'url': 'https://signup-only.example.com', 'events': ['signup']},
])
with patch('open_webui.utils.webhook.post_webhook', new_callable=AsyncMock) as mock_pw:
mock_pw.return_value = True
result = await post_webhook_event('App', cfg, 'msg', {}, event='signout')
assert result == []
mock_pw.assert_not_called()
async def test_mixed_config_fires_scoped_and_catchall(self):
cfg = json.dumps([
{'url': 'https://signup-only.example.com', 'events': ['signup']},
{'url': 'https://all-events.example.com', 'events': None},
])
with patch('open_webui.utils.webhook.post_webhook', new_callable=AsyncMock) as mock_pw:
mock_pw.return_value = True
result = await post_webhook_event('App', cfg, 'msg', {}, event='signup')
# Both should fire: signup-only matches event; catchall always fires
assert len(result) == 2
async def test_event_name_injected_into_payload(self):
url = 'https://example.com/hook'
captured_data = {}
async def capture_webhook(name, _url, message, data):
captured_data.update(data)
return True
with patch('open_webui.utils.webhook.post_webhook', side_effect=capture_webhook):
await post_webhook_event('App', url, 'msg', {'action': 'signup'}, event='signup')
assert captured_data.get('event') == 'signup'
async def test_no_event_arg_sends_to_all_configured_urls(self):
cfg = json.dumps([
{'url': 'https://a.example.com', 'events': ['signup']},
{'url': 'https://b.example.com', 'events': None},
])
with patch('open_webui.utils.webhook.post_webhook', new_callable=AsyncMock) as mock_pw:
mock_pw.return_value = True
# event=None → backwards-compat, should be sent to ALL (no filtering)
result = await post_webhook_event('App', cfg, 'msg', {}, event=None)
# event=None: scoped URL 'a' has events=['signup'] which is not None, and
# event param is None, so the check `allowed is not None and event not in allowed`
# → None not in {'signup'} → True → skip 'a'.
# URL 'b' has events=None → catch-all → fires.
# This is the intended behaviour: None event only reaches catch-all webhooks.
assert len(result) == 1
# ---------------------------------------------------------------------------
# WEBHOOK_EVENTS constant sanity check
# ---------------------------------------------------------------------------
class TestWebhookEventsConstant:
def test_expected_events_present(self):
expected = {'signup', 'oauth_signup', 'signin', 'signout', 'user_deleted', 'user_role_changed'}
assert expected.issubset(WEBHOOK_EVENTS)

View file

@ -81,11 +81,15 @@ from open_webui.models.auths import Auths
from open_webui.models.groups import GroupForm, GroupModel, Groups, GroupUpdateForm
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.users import Users
from open_webui.utils.misc import parse_duration
from open_webui.utils.auth import get_password_hash, create_token
from open_webui.utils.webhook import post_webhook, post_webhook_event
from open_webui.utils.groups import apply_default_group_assignment
from open_webui.retrieval.web.utils import validate_url
from open_webui.utils.auth import create_token, get_password_hash
from open_webui.utils.groups import apply_default_group_assignment
from open_webui.utils.misc import parse_duration
from open_webui.utils.webhook import post_webhook
from starlette.responses import RedirectResponse
@ -1724,7 +1728,7 @@ class OAuthManager:
user = await Users.get_user_by_id(user.id, db=db)
if auth_manager_config.WEBHOOK_URL:
await post_webhook(
await post_webhook_event(
WEBUI_NAME,
auth_manager_config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
@ -1732,7 +1736,9 @@ class OAuthManager:
'action': 'signup',
'message': WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
'user': user.model_dump_json(exclude_none=True),
'provider': provider,
},
event='oauth_signup',
)
await apply_default_group_assignment(request.app.state.config.DEFAULT_GROUP_ID, user.id, db=db)

View file

@ -62,3 +62,133 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict) -> b
except Exception as e:
log.exception(e)
return False
# ---------------------------------------------------------------------------
# Multi-webhook / event-scope helpers
# ---------------------------------------------------------------------------
# Canonical event names used across the codebase.
WEBHOOK_EVENTS = {
'signup', # new user registered via password form
'oauth_signup', # new user created through OAuth / OIDC / SAML flow
'signin', # existing user signed in (optional, disabled by default)
'signout', # user signed out
'user_deleted', # admin deleted a user
'user_role_changed', # admin changed a user's role
}
def _parse_webhook_config(raw: str) -> list[dict]:
"""Parse the WEBHOOK_URL config value.
Accepts two formats:
1. **Legacy / simple** – a single URL string (no event filtering):
``https://hooks.slack.com/services/xxx``
2. **Extended** – a newline-separated or comma-separated list of JSON
objects, each with a ``url`` key and an optional ``events`` list::
{"url": "https://hooks.slack.com/...", "events": ["signup"]}
{"url": "https://discord.com/api/webhooks/...", "events": ["signin", "signout"]}
Or as a JSON array::
[
{"url": "https://...", "events": ["signup", "oauth_signup"]},
{"url": "https://...", "events": null}
]
Returns a list of dicts: ``[{"url": str, "events": set[str] | None}]``.
``events=None`` means "fire for every event" (legacy / catch-all behaviour).
"""
if not raw or not raw.strip():
return []
raw = raw.strip()
# Attempt to treat the whole value as a JSON array first.
if raw.startswith('['):
try:
entries = json.loads(raw)
result = []
for entry in entries:
if isinstance(entry, str):
result.append({'url': entry, 'events': None})
elif isinstance(entry, dict) and entry.get('url'):
events = entry.get('events')
result.append({
'url': entry['url'],
'events': set(events) if events else None,
})
return result
except json.JSONDecodeError:
pass
# Try splitting by newline then comma.
parts = [p.strip() for line in raw.splitlines() for p in line.split(',') if p.strip()]
result = []
for part in parts:
if part.startswith('{'):
try:
entry = json.loads(part)
if entry.get('url'):
events = entry.get('events')
result.append({
'url': entry['url'],
'events': set(events) if events else None,
})
continue
except json.JSONDecodeError:
pass
# Plain URL – no event filter (legacy behaviour).
result.append({'url': part, 'events': None})
return result
async def post_webhook_event(
name: str,
webhook_url_config: str,
message: str,
event_data: dict,
event: str | None = None,
) -> list[bool]:
"""Dispatch a webhook for a specific *event* to all matching configured URLs.
Parameters
----------
name:
Display name of the Open WebUI instance (``request.app.state.WEBUI_NAME``).
webhook_url_config:
The raw value of ``WEBHOOK_URL`` from app config (string, possibly
containing multiple URLs / JSON entries).
message:
Human-readable summary line.
event_data:
Arbitrary dict of event metadata; passed through to ``post_webhook``.
event:
One of :data:`WEBHOOK_EVENTS`. If *None*, the payload is sent to every
configured URL (backwards-compatible behaviour for callers that have not
been updated yet).
"""
if not webhook_url_config:
return []
# Attach event name to the payload so downstream systems can filter.
if event:
event_data = {**event_data, 'event': event}
entries = _parse_webhook_config(webhook_url_config)
results = []
for entry in entries:
allowed = entry.get('events')
# None means "all events"; otherwise check membership.
if allowed is not None and event not in allowed:
log.debug(f'Skipping webhook {entry["url"]} for event {event!r} (not in {allowed})')
continue
ok = await post_webhook(name, entry['url'], message, event_data)
results.append(ok)
return results

View file

@ -199,7 +199,7 @@
</div>
{/if}
<div class="chat-{message.role} w-full min-w-full markdown-prose">
<div class="chat-{message.role} w-full {($settings?.chatBubble ?? true) ? 'min-w-0' : 'min-w-full'} markdown-prose">
{#if edit !== true}
{#if message.files}
<div