Skip to content

Commit b5c4396

Browse files
committed
refac
1 parent 35f8bf9 commit b5c4396

32 files changed

Lines changed: 2251 additions & 120 deletions

backend/open_webui/events.py

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import time
5+
import uuid
6+
from dataclasses import asdict, dataclass, field
7+
from enum import StrEnum
8+
from typing import Any
9+
10+
from open_webui.env import VERSION
11+
from open_webui.models.config import Config
12+
from open_webui.utils.webhook import post_webhook
13+
14+
log = logging.getLogger(__name__)
15+
16+
EVENT_VERSION = 'event.v1'
17+
MAX_STRING_LENGTH = 1000
18+
19+
20+
class EVENTS(StrEnum):
21+
SYSTEM_STARTUP_STARTED = 'system.startup.started'
22+
SYSTEM_STARTUP_COMPLETED = 'system.startup.completed'
23+
SYSTEM_SHUTDOWN_STARTED = 'system.shutdown.started'
24+
SYSTEM_SHUTDOWN_COMPLETED = 'system.shutdown.completed'
25+
CONFIG_IMPORTED = 'config.imported'
26+
CONFIG_UPDATED = 'config.updated'
27+
CONFIG_WEBHOOK_UPDATED = 'config.webhook.updated'
28+
CONFIG_CONNECTIONS_UPDATED = 'config.connections.updated'
29+
CONFIG_TOOL_SERVERS_UPDATED = 'config.tool_servers.updated'
30+
CONFIG_TERMINAL_SERVERS_UPDATED = 'config.terminal_servers.updated'
31+
CONFIG_CODE_EXECUTION_UPDATED = 'config.code_execution.updated'
32+
CONFIG_MODELS_UPDATED = 'config.models.updated'
33+
CONFIG_BANNERS_UPDATED = 'config.banners.updated'
34+
CONFIG_SUGGESTIONS_UPDATED = 'config.suggestions.updated'
35+
AUTH_SIGNUP = 'auth.signup'
36+
AUTH_LOGIN = 'auth.login'
37+
AUTH_LOGOUT = 'auth.logout'
38+
AUTH_PASSWORD_CHANGED = 'auth.password_changed'
39+
AUTH_API_KEY_CREATED = 'auth.api_key.created'
40+
AUTH_API_KEY_DELETED = 'auth.api_key.deleted'
41+
AUTH_OAUTH_SESSION_DELETED = 'auth.oauth_session.deleted'
42+
USER_CREATED = 'user.created'
43+
USER_UPDATED = 'user.updated'
44+
USER_DELETED = 'user.deleted'
45+
USER_ROLE_UPDATED = 'user.role_updated'
46+
USER_STATUS_UPDATED = 'user.status_updated'
47+
USER_SETTINGS_UPDATED = 'user.settings_updated'
48+
USER_PROFILE_UPDATED = 'user.profile_updated'
49+
USER_PERMISSIONS_UPDATED = 'user.permissions_updated'
50+
GROUP_CREATED = 'group.created'
51+
GROUP_UPDATED = 'group.updated'
52+
GROUP_DELETED = 'group.deleted'
53+
GROUP_MEMBER_ADDED = 'group.member_added'
54+
GROUP_MEMBER_REMOVED = 'group.member_removed'
55+
CHAT_CREATED = 'chat.created'
56+
CHAT_IMPORTED = 'chat.imported'
57+
CHAT_UPDATED = 'chat.updated'
58+
CHAT_DELETED = 'chat.deleted'
59+
CHAT_DELETED_ALL = 'chat.deleted_all'
60+
CHAT_COMPACTED = 'chat.compacted'
61+
CHAT_PINNED = 'chat.pinned'
62+
CHAT_UNPINNED = 'chat.unpinned'
63+
CHAT_CLONED = 'chat.cloned'
64+
CHAT_ARCHIVED = 'chat.archived'
65+
CHAT_UNARCHIVED = 'chat.unarchived'
66+
CHAT_SHARED = 'chat.shared'
67+
CHAT_UNSHARED = 'chat.unshared'
68+
CHAT_FOLDER_UPDATED = 'chat.folder_updated'
69+
CHAT_TAG_ADDED = 'chat.tag_added'
70+
CHAT_TAG_REMOVED = 'chat.tag_removed'
71+
MESSAGE_CREATED = 'message.created'
72+
MESSAGE_UPDATED = 'message.updated'
73+
MESSAGE_DELETED = 'message.deleted'
74+
MESSAGE_EVENT_RECEIVED = 'message.event_received'
75+
MESSAGE_REACTION_ADDED = 'message.reaction_added'
76+
MESSAGE_REACTION_REMOVED = 'message.reaction_removed'
77+
MESSAGE_PINNED = 'message.pinned'
78+
MESSAGE_UNPINNED = 'message.unpinned'
79+
CHANNEL_CREATED = 'channel.created'
80+
CHANNEL_UPDATED = 'channel.updated'
81+
CHANNEL_DELETED = 'channel.deleted'
82+
CHANNEL_MEMBER_ADDED = 'channel.member_added'
83+
CHANNEL_MEMBER_REMOVED = 'channel.member_removed'
84+
CHANNEL_MEMBER_ACTIVE_UPDATED = 'channel.member_active_updated'
85+
CHANNEL_WEBHOOK_CREATED = 'channel.webhook.created'
86+
CHANNEL_WEBHOOK_UPDATED = 'channel.webhook.updated'
87+
CHANNEL_WEBHOOK_DELETED = 'channel.webhook.deleted'
88+
FILE_UPLOADED = 'file.uploaded'
89+
FILE_CONTENT_UPDATED = 'file.content_updated'
90+
FILE_RENAMED = 'file.renamed'
91+
FILE_DELETED = 'file.deleted'
92+
FILE_DELETED_ALL = 'file.deleted_all'
93+
FOLDER_CREATED = 'folder.created'
94+
FOLDER_UPDATED = 'folder.updated'
95+
FOLDER_PARENT_UPDATED = 'folder.parent_updated'
96+
FOLDER_ACCESS_UPDATED = 'folder.access_updated'
97+
FOLDER_DELETED = 'folder.deleted'
98+
NOTE_CREATED = 'note.created'
99+
NOTE_UPDATED = 'note.updated'
100+
NOTE_ACCESS_UPDATED = 'note.access_updated'
101+
NOTE_PINNED = 'note.pinned'
102+
NOTE_UNPINNED = 'note.unpinned'
103+
NOTE_DELETED = 'note.deleted'
104+
MEMORY_CREATED = 'memory.created'
105+
MEMORY_UPDATED = 'memory.updated'
106+
MEMORY_DELETED = 'memory.deleted'
107+
MEMORY_RESET = 'memory.reset'
108+
KNOWLEDGE_CREATED = 'knowledge.created'
109+
KNOWLEDGE_UPDATED = 'knowledge.updated'
110+
KNOWLEDGE_DELETED = 'knowledge.deleted'
111+
KNOWLEDGE_RESET = 'knowledge.reset'
112+
KNOWLEDGE_REINDEXED = 'knowledge.reindexed'
113+
KNOWLEDGE_ACCESS_UPDATED = 'knowledge.access_updated'
114+
KNOWLEDGE_FILE_ADDED = 'knowledge.file.added'
115+
KNOWLEDGE_FILE_UPDATED = 'knowledge.file.updated'
116+
KNOWLEDGE_FILE_REMOVED = 'knowledge.file.removed'
117+
KNOWLEDGE_FILE_MOVED = 'knowledge.file.moved'
118+
KNOWLEDGE_DIRECTORY_CREATED = 'knowledge.directory.created'
119+
KNOWLEDGE_DIRECTORY_UPDATED = 'knowledge.directory.updated'
120+
KNOWLEDGE_DIRECTORY_DELETED = 'knowledge.directory.deleted'
121+
KNOWLEDGE_EXTERNAL_CONNECTION_CREATED = 'knowledge.external_connection.created'
122+
KNOWLEDGE_EXTERNAL_CONNECTION_UPDATED = 'knowledge.external_connection.updated'
123+
KNOWLEDGE_EXTERNAL_CONNECTION_DELETED = 'knowledge.external_connection.deleted'
124+
RETRIEVAL_CONTENT_PROCESSED = 'retrieval.content.processed'
125+
RETRIEVAL_COLLECTION_DELETED = 'retrieval.collection.deleted'
126+
RETRIEVAL_VECTOR_DB_RESET = 'retrieval.vector_db.reset'
127+
RETRIEVAL_UPLOADS_RESET = 'retrieval.uploads.reset'
128+
MODEL_CREATED = 'model.created'
129+
MODEL_IMPORTED = 'model.imported'
130+
MODEL_SYNCED = 'model.synced'
131+
MODEL_UPDATED = 'model.updated'
132+
MODEL_DELETED = 'model.deleted'
133+
MODEL_ENABLED = 'model.enabled'
134+
MODEL_DISABLED = 'model.disabled'
135+
MODEL_ACCESS_UPDATED = 'model.access_updated'
136+
MODEL_PROVIDER_CONFIG_UPDATED = 'model.provider_config.updated'
137+
MODEL_PROVIDER_MODEL_CREATED = 'model.provider_model.created'
138+
MODEL_PROVIDER_MODEL_DELETED = 'model.provider_model.deleted'
139+
FUNCTION_CREATED = 'function.created'
140+
FUNCTION_UPDATED = 'function.updated'
141+
FUNCTION_DELETED = 'function.deleted'
142+
FUNCTION_ENABLED = 'function.enabled'
143+
FUNCTION_DISABLED = 'function.disabled'
144+
FUNCTION_VALVES_UPDATED = 'function.valves_updated'
145+
TOOL_CREATED = 'tool.created'
146+
TOOL_UPDATED = 'tool.updated'
147+
TOOL_DELETED = 'tool.deleted'
148+
TOOL_ACCESS_UPDATED = 'tool.access_updated'
149+
TOOL_VALVES_UPDATED = 'tool.valves_updated'
150+
SKILL_CREATED = 'skill.created'
151+
SKILL_UPDATED = 'skill.updated'
152+
SKILL_DELETED = 'skill.deleted'
153+
SKILL_ENABLED = 'skill.enabled'
154+
SKILL_DISABLED = 'skill.disabled'
155+
PROMPT_CREATED = 'prompt.created'
156+
PROMPT_UPDATED = 'prompt.updated'
157+
PROMPT_DELETED = 'prompt.deleted'
158+
PROMPT_ENABLED = 'prompt.enabled'
159+
PROMPT_DISABLED = 'prompt.disabled'
160+
PROMPT_VERSION_UPDATED = 'prompt.version_updated'
161+
PROMPT_ACCESS_UPDATED = 'prompt.access_updated'
162+
PIPELINE_UPLOADED = 'pipeline.uploaded'
163+
PIPELINE_ADDED = 'pipeline.added'
164+
PIPELINE_DELETED = 'pipeline.deleted'
165+
PIPELINE_VALVES_UPDATED = 'pipeline.valves_updated'
166+
CALENDAR_CREATED = 'calendar.created'
167+
CALENDAR_UPDATED = 'calendar.updated'
168+
CALENDAR_DELETED = 'calendar.deleted'
169+
CALENDAR_DEFAULT_UPDATED = 'calendar.default_updated'
170+
CALENDAR_EVENT_CREATED = 'calendar.event.created'
171+
CALENDAR_EVENT_UPDATED = 'calendar.event.updated'
172+
CALENDAR_EVENT_DELETED = 'calendar.event.deleted'
173+
CALENDAR_EVENT_RSVP_UPDATED = 'calendar.event.rsvp_updated'
174+
AUTOMATION_CREATED = 'automation.created'
175+
AUTOMATION_UPDATED = 'automation.updated'
176+
AUTOMATION_ENABLED = 'automation.enabled'
177+
AUTOMATION_DISABLED = 'automation.disabled'
178+
AUTOMATION_DELETED = 'automation.deleted'
179+
AUTOMATION_RUN_STARTED = 'automation.run_started'
180+
AUTOMATION_RUN_COMPLETED = 'automation.run_completed'
181+
AUTOMATION_RUN_FAILED = 'automation.run_failed'
182+
FEEDBACK_CREATED = 'feedback.created'
183+
FEEDBACK_UPDATED = 'feedback.updated'
184+
FEEDBACK_DELETED = 'feedback.deleted'
185+
FEEDBACK_DELETED_ALL = 'feedback.deleted_all'
186+
IMAGE_GENERATED = 'image.generated'
187+
IMAGE_EDITED = 'image.edited'
188+
AUDIO_SPEECH_REQUESTED = 'audio.speech_requested'
189+
AUDIO_TRANSCRIPTION_REQUESTED = 'audio.transcription_requested'
190+
TERMINAL_SESSION_OPENED = 'terminal.session.opened'
191+
TERMINAL_SESSION_CLOSED = 'terminal.session.closed'
192+
193+
194+
EVENT_CATALOG = tuple(event.value for event in EVENTS)
195+
196+
SENSITIVE_KEYS = {
197+
'password',
198+
'hashed_password',
199+
'token',
200+
'access_token',
201+
'refresh_token',
202+
'id_token',
203+
'api_key',
204+
'secret',
205+
'key',
206+
'authorization',
207+
'cookie',
208+
'webhook_token',
209+
}
210+
211+
SAFE_ACTOR_FIELDS = ('id', 'name', 'email', 'role', 'created_at', 'updated_at')
212+
213+
214+
@dataclass
215+
class Event:
216+
schema: str
217+
id: str
218+
event: str
219+
resource: str
220+
operation: str
221+
created_at: int
222+
instance_id: str | None
223+
version: str
224+
source: str
225+
actor: dict[str, Any] | None = None
226+
subject: dict[str, Any] | None = None
227+
data: dict[str, Any] = field(default_factory=dict)
228+
message: str | None = None
229+
230+
def model_dump(self) -> dict[str, Any]:
231+
return asdict(self)
232+
233+
234+
def _sensitive(key: Any) -> bool:
235+
normalized = str(key).lower().replace('-', '_')
236+
return (
237+
normalized in SENSITIVE_KEYS
238+
or normalized.endswith('_token')
239+
or normalized.endswith('_secret')
240+
or normalized.endswith('_api_key')
241+
or normalized.endswith('_key')
242+
)
243+
244+
245+
def _sanitize(value: Any) -> Any:
246+
if hasattr(value, 'model_dump'):
247+
value = value.model_dump()
248+
249+
if isinstance(value, dict):
250+
return {key: _sanitize(item) for key, item in value.items() if not _sensitive(key)}
251+
252+
if isinstance(value, (list, tuple, set)):
253+
return [_sanitize(item) for item in value]
254+
255+
if isinstance(value, str) and len(value) > MAX_STRING_LENGTH:
256+
return f'{value[:MAX_STRING_LENGTH]}...'
257+
258+
return value
259+
260+
261+
def _actor(actor: Any | None) -> dict[str, Any] | None:
262+
actor = _sanitize(actor)
263+
if not actor:
264+
return None
265+
266+
get = actor.get if isinstance(actor, dict) else lambda key: getattr(actor, key, None)
267+
data = {field: get(field) for field in SAFE_ACTOR_FIELDS if get(field) is not None}
268+
if not data:
269+
return None
270+
271+
data['type'] = get('type') or 'user'
272+
return data
273+
274+
275+
def build_event(
276+
request_or_app: Any,
277+
event: EVENTS,
278+
*,
279+
actor: Any | None = None,
280+
subject_id: Any | None = None,
281+
subject_type: str | None = None,
282+
source: str = 'api',
283+
data: dict | None = None,
284+
message: str | None = None,
285+
) -> Event:
286+
event_name = event.value
287+
app = getattr(request_or_app, 'app', request_or_app)
288+
parts = event_name.split('.')
289+
resource = '.'.join(parts[:-1])
290+
instance_id = getattr(getattr(app, 'state', None), 'instance_id', None)
291+
subject = (
292+
{'type': subject_type or resource, 'id': subject_id}
293+
if subject_id is not None or subject_type is not None
294+
else None
295+
)
296+
297+
return Event(
298+
schema=EVENT_VERSION,
299+
id=str(uuid.uuid4()),
300+
event=event_name,
301+
resource=resource,
302+
operation=parts[-1],
303+
created_at=int(time.time()),
304+
instance_id=instance_id,
305+
version=VERSION,
306+
source=source,
307+
actor=_actor(actor),
308+
subject=_sanitize(subject) if subject else None,
309+
data=_sanitize(data or {}),
310+
message=message,
311+
)
312+
313+
314+
class WebhookEventSink:
315+
async def handle_event(self, app: Any, event: Event) -> None:
316+
url = await Config.get('webhook_url')
317+
if not url:
318+
return
319+
320+
name = getattr(getattr(app, 'state', None), 'WEBUI_NAME', 'Open WebUI')
321+
subject = event.subject or {}
322+
subject_id = subject.get('id')
323+
message = event.message or f'{event.event}: {subject.get("type") or event.resource}'
324+
if subject_id:
325+
message = f'{message} ({subject_id})'
326+
327+
await post_webhook(name, url, message, event.model_dump())
328+
329+
330+
EVENT_SINKS = [WebhookEventSink()]
331+
332+
333+
async def publish_event(
334+
request_or_app: Any,
335+
event: EVENTS,
336+
*,
337+
actor: Any | None = None,
338+
subject_id: Any | None = None,
339+
subject_type: str | None = None,
340+
source: str = 'api',
341+
data: dict | None = None,
342+
message: str | None = None,
343+
) -> None:
344+
app = getattr(request_or_app, 'app', request_or_app)
345+
event_payload = build_event(
346+
request_or_app,
347+
event,
348+
actor=actor,
349+
subject_id=subject_id,
350+
subject_type=subject_type,
351+
source=source,
352+
data=data,
353+
message=message,
354+
)
355+
356+
for sink in EVENT_SINKS:
357+
try:
358+
await sink.handle_event(app, event_payload)
359+
except Exception:
360+
log.exception('Event sink failed for %s', event.value)

0 commit comments

Comments
 (0)