mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-15 18:55:07 +03:00
520 lines
20 KiB
Python
520 lines
20 KiB
Python
"""Resource-aware producers and reads for the general inbox."""
|
|
|
|
from collections import defaultdict
|
|
|
|
from open_webui.models.notifications import Notification, insert_notifications
|
|
from sqlalchemy import and_, false, or_, select, true
|
|
from sqlalchemy.orm import load_only
|
|
|
|
|
|
def source_models():
|
|
from open_webui.models.automations import Automation, AutomationRun
|
|
from open_webui.models.calendar import Calendar, CalendarEvent
|
|
from open_webui.models.channels import Channel
|
|
from open_webui.models.chat_messages import ChatMessage
|
|
from open_webui.models.comments import Comment
|
|
from open_webui.models.messages import Message
|
|
from open_webui.utils.comments import resource_models
|
|
|
|
return {
|
|
**resource_models(),
|
|
'automation': Automation,
|
|
'automation_run': AutomationRun,
|
|
'calendar': Calendar,
|
|
'calendar_event': CalendarEvent,
|
|
'channel': Channel,
|
|
'chat_message': ChatMessage,
|
|
'comment': Comment,
|
|
'message': Message,
|
|
}
|
|
|
|
|
|
async def validate_source(event_type, resource_type, resource_id, object_type, object_id, db):
|
|
if event_type == 'system.announcement':
|
|
if any((resource_type, resource_id, object_type, object_id)):
|
|
raise ValueError('System announcements cannot attach resources')
|
|
return
|
|
models = source_models()
|
|
if (
|
|
not all((resource_type, resource_id, object_type, object_id))
|
|
or resource_type not in models
|
|
or object_type not in models
|
|
):
|
|
raise ValueError('Incomplete notification source')
|
|
obj = await db.get(models[object_type], object_id)
|
|
if obj is None:
|
|
raise ValueError('Notification object does not exist')
|
|
valid = False
|
|
if event_type.startswith('comment.'):
|
|
valid = (
|
|
object_type == 'comment'
|
|
and not obj.deleted_at
|
|
and (obj.resource_type, obj.resource_id) == (resource_type, resource_id)
|
|
)
|
|
elif event_type.startswith('channel.'):
|
|
valid = object_type == 'message' and resource_type == 'channel' and obj.channel_id == resource_id
|
|
elif event_type == 'chat.mentioned':
|
|
valid = (
|
|
object_type == 'chat_message'
|
|
and resource_type == 'chat'
|
|
and obj.chat_id == resource_id
|
|
and obj.role == 'assistant'
|
|
and obj.done
|
|
and not obj.error
|
|
)
|
|
elif event_type == 'automation.run_failed':
|
|
valid = (
|
|
object_type == 'automation_run'
|
|
and resource_type == 'automation'
|
|
and obj.automation_id == resource_id
|
|
and obj.status == 'error'
|
|
)
|
|
elif event_type == 'calendar.alert':
|
|
valid = (
|
|
object_type == 'calendar_event'
|
|
and resource_type == 'calendar'
|
|
and obj.calendar_id == resource_id
|
|
and not obj.is_cancelled
|
|
)
|
|
if not valid:
|
|
raise ValueError('Notification object does not belong to resource')
|
|
|
|
|
|
async def resource_queries(user, db):
|
|
"""One set of indexed permission subqueries, shared by listing, counts and writes."""
|
|
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
|
from open_webui.env import ENABLE_ADMIN_CHAT_ACCESS
|
|
from open_webui.models.access_grants import AccessGrants
|
|
from open_webui.models.channels import ChannelMember
|
|
from open_webui.models.config import Config
|
|
from open_webui.models.folders import Folder
|
|
from open_webui.models.groups import Groups
|
|
from open_webui.models.shared_chats import SharedChat
|
|
from open_webui.utils.access_control import has_permission
|
|
|
|
if user.role not in {'user', 'admin'}:
|
|
return {}
|
|
groups = [g.id for g in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)]
|
|
config = await Config.get_many(
|
|
'comments.enable',
|
|
'notes.enable',
|
|
'channels.enable',
|
|
'calendar.enable',
|
|
'automations.enable',
|
|
'user.permissions',
|
|
)
|
|
admin = user.role == 'admin'
|
|
|
|
def grant(kind, column):
|
|
return AccessGrants.has_grant_expression(user.id, kind, column, user_group_ids=groups)
|
|
|
|
async def enabled(feature):
|
|
return config.get(f'{feature}.enable') and (
|
|
admin or await has_permission(user.id, f'features.{feature}', config.get('user.permissions'), db=db)
|
|
)
|
|
|
|
models = source_models()
|
|
queries = {}
|
|
for kind in ('model', 'knowledge', 'prompt', 'skill', 'tool', 'note'):
|
|
if not config.get('comments.enable') or (kind == 'note' and not await enabled('notes')):
|
|
continue
|
|
model = models[kind]
|
|
allowed = (
|
|
true()
|
|
if admin and (kind == 'note' or BYPASS_ADMIN_ACCESS_CONTROL)
|
|
else or_(model.user_id == user.id, grant(kind, model.id))
|
|
)
|
|
queries[kind] = select(model.id).where(allowed)
|
|
chat = models['chat']
|
|
# UNION (not UNION ALL) terminates even if legacy folder data contains a cycle.
|
|
folders = (
|
|
select(Folder.id)
|
|
.where(or_(Folder.user_id == user.id, grant('folder', Folder.id)))
|
|
.cte('inbox_folders', recursive=True)
|
|
)
|
|
folders = folders.union(select(Folder.id).join(folders, Folder.parent_id == folders.c.id))
|
|
shared = select(SharedChat.chat_id).where(
|
|
SharedChat.chat['share_mode'].as_string() == 'continue', grant('shared_chat', SharedChat.chat_id)
|
|
)
|
|
queries['chat'] = select(chat.id).where(
|
|
or_(
|
|
chat.user_id == user.id,
|
|
true() if admin and ENABLE_ADMIN_CHAT_ACCESS else false(),
|
|
and_(true() if admin else false(), chat.meta['internal'].as_boolean().is_(True)),
|
|
chat.id.in_(shared),
|
|
chat.folder_id.in_(select(folders.c.id)),
|
|
)
|
|
)
|
|
if await enabled('channels'):
|
|
channel = models['channel']
|
|
member = (
|
|
select(ChannelMember.id)
|
|
.where(
|
|
ChannelMember.channel_id == channel.id,
|
|
ChannelMember.user_id == user.id,
|
|
ChannelMember.is_active.is_(True),
|
|
)
|
|
.exists()
|
|
)
|
|
queries['channel'] = select(channel.id).where(
|
|
channel.deleted_at.is_(None),
|
|
or_(
|
|
and_(channel.type.in_(['group', 'dm']), member),
|
|
and_(
|
|
or_(channel.type.is_(None), channel.type.not_in(['group', 'dm'])),
|
|
true() if admin else grant('channel', channel.id),
|
|
),
|
|
),
|
|
)
|
|
if await enabled('calendar'):
|
|
model = models['calendar']
|
|
queries['calendar'] = select(model.id).where(
|
|
true() if admin else or_(model.user_id == user.id, grant('calendar', model.id))
|
|
)
|
|
if await enabled('automations'):
|
|
model = models['automation']
|
|
queries['automation'] = select(model.id).where(model.user_id == user.id)
|
|
return queries
|
|
|
|
|
|
async def eligible_recipients(recipients, resource_type, resource_id, actor_id, db):
|
|
from open_webui.models.chats import Chats
|
|
from open_webui.models.config import Config
|
|
from open_webui.models.users import User, UserModel
|
|
|
|
if not await Config.get('comments.enable'):
|
|
recipients = {id: kind for id, kind in recipients.items() if not kind.startswith('comment.')}
|
|
users = (await db.scalars(select(User).where(User.id.in_(recipients), User.role.in_(['user', 'admin'])))).all()
|
|
eligible = {}
|
|
for row in users:
|
|
kind = recipients[row.id]
|
|
if row.id == actor_id and kind.startswith(('comment.', 'channel.')):
|
|
continue
|
|
if kind == 'chat.mentioned':
|
|
if await Chats.has_chat_access(resource_id, UserModel.model_validate(row), permission='read', db=db):
|
|
eligible[row.id] = kind
|
|
continue
|
|
if resource_type is not None:
|
|
queries = await resource_queries(UserModel.model_validate(row), db=db)
|
|
query = queries.get(resource_type)
|
|
if query is None or not await db.scalar(
|
|
select(query.where(source_models()[resource_type].id == resource_id).exists())
|
|
):
|
|
continue
|
|
eligible[row.id] = kind
|
|
return eligible
|
|
|
|
|
|
async def visible_clause(user, db):
|
|
from open_webui.models.config import Config
|
|
|
|
queries = await resource_queries(user, db=db)
|
|
comments_enabled = await Config.get('comments.enable')
|
|
models = source_models()
|
|
resources = or_(
|
|
*[
|
|
and_(Notification.resource_type == kind, Notification.resource_id.in_(query))
|
|
for kind, query in queries.items()
|
|
]
|
|
)
|
|
objects = []
|
|
for kind, parent_column, events in (
|
|
('comment', 'resource_id', ['comment.created', 'comment.replied', 'comment.mentioned']),
|
|
('message', 'channel_id', ['channel.mentioned', 'channel.replied']),
|
|
('chat_message', 'chat_id', ['chat.mentioned']),
|
|
('automation_run', 'automation_id', ['automation.run_failed']),
|
|
('calendar_event', 'calendar_id', ['calendar.alert']),
|
|
):
|
|
model = models[kind]
|
|
conditions = [model.id == Notification.object_id, getattr(model, parent_column) == Notification.resource_id]
|
|
if kind == 'comment':
|
|
conditions += [
|
|
true() if comments_enabled else false(),
|
|
model.resource_type == Notification.resource_type,
|
|
model.deleted_at.is_(None),
|
|
]
|
|
if kind == 'calendar_event':
|
|
conditions.append(model.is_cancelled.is_(False))
|
|
objects.append(
|
|
and_(
|
|
Notification.event_type.in_(events),
|
|
Notification.object_type == kind,
|
|
select(model.id).where(*conditions).exists(),
|
|
)
|
|
)
|
|
return (
|
|
and_(
|
|
Notification.user_id == user.id,
|
|
or_(
|
|
and_(
|
|
Notification.event_type == 'system.announcement',
|
|
Notification.resource_type.is_(None),
|
|
Notification.object_type.is_(None),
|
|
),
|
|
and_(resources, or_(*objects)),
|
|
),
|
|
)
|
|
if user.role in {'user', 'admin'}
|
|
else false()
|
|
)
|
|
|
|
|
|
def chat_message_text(message):
|
|
from open_webui.utils.misc import get_output_text
|
|
|
|
if isinstance(message.output, list):
|
|
return get_output_text(message.output)
|
|
if isinstance(message.content, str):
|
|
return message.content
|
|
return ''.join(
|
|
part.get('text', '')
|
|
for part in message.content or []
|
|
if isinstance(part, dict) and part.get('type') in {'text', 'output_text'}
|
|
)
|
|
|
|
|
|
async def hydrate(rows, db):
|
|
"""Fetch each actor/resource/object type once, never once per notification."""
|
|
from open_webui.models.users import User
|
|
from open_webui.utils.channels import replace_mentions
|
|
|
|
models = source_models()
|
|
ids = defaultdict(set)
|
|
for row in rows:
|
|
if row.resource_type:
|
|
ids[row.resource_type].add(row.resource_id)
|
|
if row.object_type:
|
|
ids[row.object_type].add(row.object_id)
|
|
objects = {}
|
|
for kind, values in ids.items():
|
|
model = models[kind]
|
|
fields = ['id', 'title', 'name']
|
|
if kind in {'comment', 'message'}:
|
|
fields += ['content', 'target_type', 'target_id', 'parent_id']
|
|
elif kind == 'chat_message':
|
|
fields += ['content', 'output', 'chat_id']
|
|
columns = [getattr(model, field) for field in fields if hasattr(model, field)]
|
|
objects[kind] = {
|
|
obj.id: obj
|
|
for obj in (await db.scalars(select(model).options(load_only(*columns)).where(model.id.in_(values)))).all()
|
|
}
|
|
user_ids = {r.actor_id for r in rows if r.actor_id}
|
|
for message in objects.get('chat_message', {}).values():
|
|
user_ids.update(mentioned_users(chat_message_text(message)))
|
|
users = {
|
|
u.id: {'id': u.id, 'name': u.name}
|
|
for u in (
|
|
await db.scalars(select(User).options(load_only(User.id, User.name)).where(User.id.in_(user_ids)))
|
|
).all()
|
|
}
|
|
result = []
|
|
for row in rows:
|
|
resource = objects.get(row.resource_type, {}).get(row.resource_id)
|
|
obj = objects.get(row.object_type, {}).get(row.object_id)
|
|
title = getattr(resource, 'title', None) or getattr(resource, 'name', None) or ''
|
|
preview = (
|
|
replace_mentions(getattr(obj, 'content', '') or '')[:300]
|
|
if row.object_type in {'comment', 'message'}
|
|
else ''
|
|
)
|
|
data = dict(row.data or {})
|
|
if row.object_type == 'comment':
|
|
data.update(target_type=obj.target_type, target_id=obj.target_id, parent_id=obj.parent_id)
|
|
elif row.object_type == 'message':
|
|
data['parent_id'] = obj.parent_id
|
|
elif row.object_type == 'chat_message':
|
|
content = chat_message_text(obj)
|
|
for id in mentioned_users(content):
|
|
if id in users:
|
|
content = content.replace(f'<@U:{id}>', f'@{users[id]["name"]}')
|
|
preview = replace_mentions(content)[:300]
|
|
data['message_id'] = obj.id.removeprefix(f'{obj.chat_id}-')
|
|
elif row.object_type == 'calendar_event':
|
|
title = obj.title
|
|
result.append(
|
|
{
|
|
**{c.name: getattr(row, c.name) for c in Notification.__table__.columns},
|
|
'actor': users.get(row.actor_id),
|
|
'title': title,
|
|
'preview': preview,
|
|
'data': data,
|
|
# Epoch nanoseconds must not become imprecise JavaScript cursor values.
|
|
'created_at': str(row.created_at),
|
|
'updated_at': str(row.updated_at),
|
|
'seen_at': str(row.seen_at) if row.seen_at is not None else None,
|
|
'read_at': str(row.read_at) if row.read_at is not None else None,
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def mentioned_users(content):
|
|
from open_webui.utils.channels import extract_mentions
|
|
|
|
return {m['id'] for m in extract_mentions(content or '') if m['id_type'] == 'U'}
|
|
|
|
|
|
async def comment_notifications(comment, owner_id, *, reply_to=None, previous_mentions=None, event_key=None, db):
|
|
mentions = mentioned_users(comment.content)
|
|
recipients = {}
|
|
if previous_mentions is None:
|
|
recipients = {
|
|
id: 'comment.replied' if comment.parent_id else 'comment.created'
|
|
for id in (owner_id, getattr(reply_to, 'user_id', None))
|
|
if id
|
|
}
|
|
else:
|
|
mentions -= set(previous_mentions)
|
|
recipients.update({id: 'comment.mentioned' for id in mentions})
|
|
return await insert_notifications(
|
|
recipients=recipients,
|
|
event_key=event_key
|
|
or f'comment:{comment.id}:{(comment.updated_at if previous_mentions is not None else "created")}',
|
|
actor_id=comment.user_id,
|
|
resource_type=comment.resource_type,
|
|
resource_id=comment.resource_id,
|
|
object_type='comment',
|
|
object_id=comment.id,
|
|
db=db,
|
|
)
|
|
|
|
|
|
async def channel_notifications(message, *, event_key=None, actor_type='user', db):
|
|
from open_webui.models.channels import ChannelMember
|
|
from open_webui.models.messages import Message
|
|
|
|
mentions = mentioned_users(message.content)
|
|
target = (
|
|
await db.get(Message, message.reply_to_id or message.parent_id)
|
|
if message.reply_to_id or message.parent_id
|
|
else None
|
|
)
|
|
recipients = (
|
|
{target.user_id: 'channel.replied'}
|
|
if target and target.channel_id == message.channel_id and target.user_id
|
|
else {}
|
|
)
|
|
recipients.update({id: 'channel.mentioned' for id in mentions})
|
|
muted = (
|
|
await db.scalars(
|
|
select(ChannelMember.user_id).where(
|
|
ChannelMember.channel_id == message.channel_id,
|
|
ChannelMember.user_id.in_(recipients),
|
|
ChannelMember.is_channel_muted.is_(True),
|
|
)
|
|
)
|
|
).all()
|
|
for id in muted:
|
|
if id not in mentions:
|
|
recipients.pop(id, None)
|
|
return await insert_notifications(
|
|
recipients=recipients,
|
|
event_key=event_key or f'message:{message.id}:created',
|
|
actor_id=message.user_id if actor_type == 'user' else None,
|
|
resource_type='channel',
|
|
resource_id=message.channel_id,
|
|
object_type='message',
|
|
object_id=message.id,
|
|
db=db,
|
|
)
|
|
|
|
|
|
async def consume_event(event, db):
|
|
"""Transactional inbox consumer; source producers publish facts, not recipients."""
|
|
from open_webui.events import EVENTS
|
|
from open_webui.models.config import Config
|
|
|
|
if not await Config.get('notifications.enable'):
|
|
return set()
|
|
|
|
subject_id = (event.subject or {}).get('id')
|
|
models = source_models()
|
|
if event.event == EVENTS.CHAT_FINISHED.name:
|
|
from open_webui.utils.chat_id import is_saved_chat_id
|
|
|
|
message_id = event.data.get('message_id')
|
|
if not is_saved_chat_id(subject_id) or not message_id:
|
|
return set()
|
|
message = await db.get(models['chat_message'], f'{subject_id}-{message_id}')
|
|
if (
|
|
not message
|
|
or message.chat_id != subject_id
|
|
or message.role != 'assistant'
|
|
or not message.done
|
|
or message.error
|
|
):
|
|
return set()
|
|
return await insert_notifications(
|
|
recipients={id: 'chat.mentioned' for id in mentioned_users(chat_message_text(message))},
|
|
event_key=f'chat:{subject_id}:message:{message_id}:mentioned',
|
|
resource_type='chat',
|
|
resource_id=subject_id,
|
|
object_type='chat_message',
|
|
object_id=message.id,
|
|
data={'model_name': event.data.get('model_name') or message.model_id or 'Assistant'},
|
|
db=db,
|
|
)
|
|
elif event.event in {EVENTS.COMMENT_CREATED.name, EVENTS.COMMENT_UPDATED.name}:
|
|
comment = await db.get(models['comment'], subject_id)
|
|
if not comment or comment.deleted_at:
|
|
raise ValueError('Comment event references an unavailable comment')
|
|
resource_model = models[comment.resource_type]
|
|
owner_id = await db.scalar(select(resource_model.user_id).where(resource_model.id == comment.resource_id))
|
|
reply_to = (
|
|
await db.get(models['comment'], comment.reply_to_id or comment.parent_id) if comment.parent_id else None
|
|
)
|
|
recipients = await comment_notifications(
|
|
comment,
|
|
owner_id,
|
|
reply_to=reply_to,
|
|
previous_mentions=event.data.get('previous_mentions', [])
|
|
if event.event == EVENTS.COMMENT_UPDATED.name
|
|
else None,
|
|
event_key=event.id,
|
|
db=db,
|
|
)
|
|
if event.event == EVENTS.COMMENT_UPDATED.name:
|
|
existing = await db.scalars(
|
|
select(Notification.user_id)
|
|
.where(Notification.object_type == 'comment', Notification.object_id == comment.id)
|
|
.distinct()
|
|
)
|
|
recipients.update(existing.all())
|
|
return recipients
|
|
elif event.event == EVENTS.MESSAGE_CREATED.name:
|
|
message = await db.get(models['message'], subject_id)
|
|
if message and message.channel_id:
|
|
return await channel_notifications(
|
|
message, event_key=event.id, actor_type=(event.actor or {}).get('type', 'system'), db=db
|
|
)
|
|
elif event.event == EVENTS.AUTOMATION_RUN_FAILED.name:
|
|
run = await db.get(models['automation_run'], event.data.get('run_id'))
|
|
automation = await db.get(models['automation'], subject_id)
|
|
if not run or not automation or run.automation_id != automation.id:
|
|
raise ValueError('Automation failure event requires its persisted run')
|
|
return await insert_notifications(
|
|
recipients={automation.user_id: event.event},
|
|
event_key=event.id,
|
|
resource_type='automation',
|
|
resource_id=automation.id,
|
|
object_type='automation_run',
|
|
object_id=run.id,
|
|
db=db,
|
|
)
|
|
elif event.event == EVENTS.CALENDAR_ALERT.name:
|
|
calendar_event = await db.get(models['calendar_event'], subject_id)
|
|
if not calendar_event or event.data.get('start_at') != calendar_event.start_at:
|
|
raise ValueError('Calendar alert must reference the current occurrence')
|
|
return await insert_notifications(
|
|
recipients={calendar_event.user_id: event.event},
|
|
event_key=event.id,
|
|
resource_type='calendar',
|
|
resource_id=calendar_event.calendar_id,
|
|
object_type='calendar_event',
|
|
object_id=calendar_event.id,
|
|
data={'start_at': calendar_event.start_at},
|
|
db=db,
|
|
)
|
|
|
|
return set()
|