|
| 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