mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-14 09:42:27 -06:00
6be11d4fc9
Fourteen modules import `json` without using it. Ruff flags every one with F401, and a word-boundary search for `json` in each file matches only the import line itself, including inside strings, comments and annotations. Two exclusions, both deliberate. Migration files are left alone: the import is equally dead there, but those files are frozen history and not worth the churn. `models/chats.py` has the same dead import and is handled in its own change, so it is skipped here to avoid two changes touching the same line. No behaviour change.
626 lines
22 KiB
Python
626 lines
22 KiB
Python
import time
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
|
from open_webui.models.channels import ChannelMember, Channels
|
|
from open_webui.models.tags import Tag, TagModel, Tags
|
|
from open_webui.models.users import User, UserNameResponse, Users
|
|
from pydantic import BaseModel, ConfigDict, field_validator
|
|
from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, and_, delete, func, or_, select, text
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.sql import exists
|
|
|
|
####################
|
|
# Message DB Schema
|
|
####################
|
|
|
|
|
|
class MessageReaction(Base):
|
|
__tablename__ = 'message_reaction'
|
|
id = Column(Text, primary_key=True, unique=True)
|
|
user_id = Column(Text)
|
|
message_id = Column(Text)
|
|
name = Column(Text)
|
|
created_at = Column(BigInteger)
|
|
|
|
|
|
class MessageReactionModel(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: str
|
|
user_id: str
|
|
message_id: str
|
|
name: str
|
|
created_at: int # timestamp in epoch
|
|
|
|
|
|
class Message(Base):
|
|
__tablename__ = 'message'
|
|
id = Column(Text, primary_key=True, unique=True)
|
|
|
|
user_id = Column(Text)
|
|
channel_id = Column(Text, nullable=True)
|
|
|
|
reply_to_id = Column(Text, nullable=True)
|
|
parent_id = Column(Text, nullable=True)
|
|
|
|
# Pins
|
|
is_pinned = Column(Boolean, nullable=False, default=False)
|
|
pinned_at = Column(BigInteger, nullable=True)
|
|
pinned_by = Column(Text, nullable=True)
|
|
|
|
content = Column(Text)
|
|
data = Column(JSON, nullable=True)
|
|
meta = Column(JSON, nullable=True)
|
|
|
|
created_at = Column(BigInteger) # time_ns
|
|
updated_at = Column(BigInteger) # time_ns
|
|
|
|
|
|
class MessageModel(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: str
|
|
user_id: str
|
|
channel_id: Optional[str] = None
|
|
|
|
reply_to_id: Optional[str] = None
|
|
parent_id: Optional[str] = None
|
|
|
|
# Pins
|
|
is_pinned: bool = False
|
|
pinned_by: Optional[str] = None
|
|
pinned_at: Optional[int] = None # timestamp in epoch (time_ns)
|
|
|
|
content: str
|
|
data: Optional[dict] = None
|
|
meta: Optional[dict] = None
|
|
|
|
created_at: int # timestamp in epoch (time_ns)
|
|
updated_at: int # timestamp in epoch (time_ns)
|
|
|
|
|
|
####################
|
|
# Forms
|
|
####################
|
|
|
|
|
|
class MessageForm(BaseModel):
|
|
temp_id: Optional[str] = None
|
|
content: str
|
|
reply_to_id: Optional[str] = None
|
|
parent_id: Optional[str] = None
|
|
data: Optional[dict] = None
|
|
meta: Optional[dict] = None
|
|
|
|
|
|
class Reactions(BaseModel):
|
|
name: str
|
|
users: list[dict]
|
|
count: int
|
|
|
|
|
|
class MessageUserResponse(MessageModel):
|
|
user: Optional[UserNameResponse] = None
|
|
|
|
|
|
class MessageUserSlimResponse(MessageUserResponse):
|
|
data: bool | None = None
|
|
|
|
@field_validator('data', mode='before')
|
|
def convert_data_to_bool(cls, v):
|
|
# No data or not a dict → False
|
|
if not isinstance(v, dict):
|
|
return False
|
|
|
|
# True if ANY value in the dict is non-empty
|
|
return any(bool(val) for val in v.values())
|
|
|
|
|
|
class MessageReplyToResponse(MessageUserResponse):
|
|
reply_to_message: Optional[MessageUserSlimResponse] = None
|
|
|
|
|
|
class MessageWithReactionsResponse(MessageUserSlimResponse):
|
|
reactions: list[Reactions]
|
|
|
|
|
|
class MessageResponse(MessageReplyToResponse):
|
|
latest_reply_at: Optional[int]
|
|
reply_count: int
|
|
reactions: list[Reactions]
|
|
|
|
|
|
class MessageTable:
|
|
async def insert_new_message(
|
|
self,
|
|
form_data: MessageForm,
|
|
channel_id: str,
|
|
user_id: str,
|
|
db: Optional[AsyncSession] = None,
|
|
) -> Optional[MessageModel]:
|
|
async with get_async_db_context(db) as db:
|
|
channel_member = await Channels.join_channel(channel_id, user_id)
|
|
|
|
id = str(uuid.uuid4())
|
|
ts = int(time.time_ns())
|
|
|
|
message = MessageModel(
|
|
**{
|
|
'id': id,
|
|
'user_id': user_id,
|
|
'channel_id': channel_id,
|
|
'reply_to_id': form_data.reply_to_id,
|
|
'parent_id': form_data.parent_id,
|
|
'is_pinned': False,
|
|
'pinned_at': None,
|
|
'pinned_by': None,
|
|
'content': form_data.content,
|
|
'data': form_data.data,
|
|
'meta': form_data.meta,
|
|
'created_at': ts,
|
|
'updated_at': ts,
|
|
}
|
|
)
|
|
result = Message(**message.model_dump())
|
|
|
|
db.add(result)
|
|
await db.commit()
|
|
await db.refresh(result)
|
|
return MessageModel.model_validate(result) if result else None
|
|
|
|
async def get_message_by_id(
|
|
self,
|
|
id: str,
|
|
include_thread_replies: Optional[bool] = True,
|
|
db: Optional[AsyncSession] = None,
|
|
) -> Optional[MessageResponse]:
|
|
async with get_async_db_context(db) as db:
|
|
message = await db.get(Message, id)
|
|
if not message:
|
|
return None
|
|
|
|
reply_to_message = (
|
|
await self.get_message_by_id(message.reply_to_id, include_thread_replies=False, db=db)
|
|
if message.reply_to_id
|
|
else None
|
|
)
|
|
|
|
reactions = await self.get_reactions_by_message_id(id, db=db)
|
|
|
|
thread_replies = []
|
|
if include_thread_replies:
|
|
thread_replies = await self.get_thread_replies_by_message_id(id, db=db)
|
|
|
|
# Check if message was sent by webhook (webhook info in meta takes precedence)
|
|
webhook_info = message.meta.get('webhook') if message.meta else None
|
|
if webhook_info and webhook_info.get('id'):
|
|
# Look up webhook by ID to get current name
|
|
webhook = await Channels.get_webhook_by_id(webhook_info.get('id'), db=db)
|
|
if webhook:
|
|
user_info = {
|
|
'id': webhook.id,
|
|
'name': webhook.name,
|
|
'role': 'webhook',
|
|
}
|
|
else:
|
|
# Webhook was deleted, use placeholder
|
|
user_info = {
|
|
'id': webhook_info.get('id'),
|
|
'name': 'Deleted Webhook',
|
|
'role': 'webhook',
|
|
}
|
|
else:
|
|
user = await Users.get_user_by_id(message.user_id, db=db)
|
|
user_info = user.model_dump() if user else None
|
|
|
|
return MessageResponse.model_validate(
|
|
{
|
|
**MessageModel.model_validate(message).model_dump(),
|
|
'user': user_info,
|
|
'reply_to_message': (reply_to_message.model_dump() if reply_to_message else None),
|
|
'latest_reply_at': (thread_replies[0].created_at if thread_replies else None),
|
|
'reply_count': len(thread_replies),
|
|
'reactions': reactions,
|
|
}
|
|
)
|
|
|
|
async def _resolve_user_info(self, message: Message, db: AsyncSession) -> Optional[dict]:
|
|
"""Resolve user info from message, handling webhook messages."""
|
|
webhook_info = message.meta.get('webhook') if message.meta else None
|
|
if webhook_info and webhook_info.get('id'):
|
|
webhook = await Channels.get_webhook_by_id(webhook_info.get('id'), db=db)
|
|
if webhook:
|
|
return {
|
|
'id': webhook.id,
|
|
'name': webhook.name,
|
|
'role': 'webhook',
|
|
}
|
|
else:
|
|
return {
|
|
'id': webhook_info.get('id'),
|
|
'name': 'Deleted Webhook',
|
|
'role': 'webhook',
|
|
}
|
|
return None
|
|
|
|
async def get_thread_replies_by_message_id(
|
|
self, id: str, db: Optional[AsyncSession] = None
|
|
) -> list[MessageReplyToResponse]:
|
|
async with get_async_db_context(db) as db:
|
|
result = await db.execute(select(Message).filter_by(parent_id=id).order_by(Message.created_at.desc()))
|
|
all_messages = result.scalars().all()
|
|
|
|
messages = []
|
|
for message in all_messages:
|
|
reply_to_message = (
|
|
await self.get_message_by_id(message.reply_to_id, include_thread_replies=False, db=db)
|
|
if message.reply_to_id
|
|
else None
|
|
)
|
|
|
|
user_info = await self._resolve_user_info(message, db)
|
|
|
|
messages.append(
|
|
MessageReplyToResponse.model_validate(
|
|
{
|
|
**MessageModel.model_validate(message).model_dump(),
|
|
'user': user_info,
|
|
'reply_to_message': (reply_to_message.model_dump() if reply_to_message else None),
|
|
}
|
|
)
|
|
)
|
|
return messages
|
|
|
|
async def get_reply_user_ids_by_message_id(self, id: str, db: Optional[AsyncSession] = None) -> list[str]:
|
|
async with get_async_db_context(db) as db:
|
|
result = await db.execute(select(Message.user_id).filter_by(parent_id=id))
|
|
return [row[0] for row in result.all()]
|
|
|
|
async def get_messages_by_channel_id(
|
|
self,
|
|
channel_id: str,
|
|
skip: int = 0,
|
|
limit: int = 50,
|
|
db: Optional[AsyncSession] = None,
|
|
) -> list[MessageReplyToResponse]:
|
|
async with get_async_db_context(db) as db:
|
|
result = await db.execute(
|
|
select(Message)
|
|
.filter_by(channel_id=channel_id, parent_id=None)
|
|
.order_by(Message.created_at.desc())
|
|
.offset(skip)
|
|
.limit(limit)
|
|
)
|
|
all_messages = result.scalars().all()
|
|
|
|
messages = []
|
|
for message in all_messages:
|
|
reply_to_message = (
|
|
await self.get_message_by_id(message.reply_to_id, include_thread_replies=False, db=db)
|
|
if message.reply_to_id
|
|
else None
|
|
)
|
|
|
|
user_info = await self._resolve_user_info(message, db)
|
|
|
|
messages.append(
|
|
MessageReplyToResponse.model_validate(
|
|
{
|
|
**MessageModel.model_validate(message).model_dump(),
|
|
'user': user_info,
|
|
'reply_to_message': (reply_to_message.model_dump() if reply_to_message else None),
|
|
}
|
|
)
|
|
)
|
|
return messages
|
|
|
|
async def get_messages_by_parent_id(
|
|
self,
|
|
channel_id: str,
|
|
parent_id: str,
|
|
skip: int = 0,
|
|
limit: int = 50,
|
|
db: Optional[AsyncSession] = None,
|
|
) -> list[MessageReplyToResponse]:
|
|
async with get_async_db_context(db) as db:
|
|
message = await db.get(Message, parent_id)
|
|
|
|
# Thread parent must belong to the requested channel; never disclose a foreign-channel message.
|
|
if not message or message.channel_id != channel_id:
|
|
return []
|
|
|
|
result = await db.execute(
|
|
select(Message)
|
|
.filter_by(channel_id=channel_id, parent_id=parent_id)
|
|
.order_by(Message.created_at.desc())
|
|
.offset(skip)
|
|
.limit(limit)
|
|
)
|
|
all_messages = list(result.scalars().all())
|
|
|
|
# If length of all_messages is less than limit, then add the parent message
|
|
if len(all_messages) < limit:
|
|
all_messages.append(message)
|
|
|
|
messages = []
|
|
for message in all_messages:
|
|
reply_to_message = (
|
|
await self.get_message_by_id(message.reply_to_id, include_thread_replies=False, db=db)
|
|
if message.reply_to_id
|
|
else None
|
|
)
|
|
|
|
user_info = await self._resolve_user_info(message, db)
|
|
|
|
messages.append(
|
|
MessageReplyToResponse.model_validate(
|
|
{
|
|
**MessageModel.model_validate(message).model_dump(),
|
|
'user': user_info,
|
|
'reply_to_message': (reply_to_message.model_dump() if reply_to_message else None),
|
|
}
|
|
)
|
|
)
|
|
return messages
|
|
|
|
async def get_last_message_by_channel_id(
|
|
self, channel_id: str, db: Optional[AsyncSession] = None
|
|
) -> Optional[MessageModel]:
|
|
async with get_async_db_context(db) as db:
|
|
result = await db.execute(
|
|
select(Message).filter_by(channel_id=channel_id).order_by(Message.created_at.desc()).limit(1)
|
|
)
|
|
message = result.scalars().first()
|
|
return MessageModel.model_validate(message) if message else None
|
|
|
|
async def get_pinned_messages_by_channel_id(
|
|
self,
|
|
channel_id: str,
|
|
skip: int = 0,
|
|
limit: int = 50,
|
|
db: Optional[AsyncSession] = None,
|
|
) -> list[MessageModel]:
|
|
async with get_async_db_context(db) as db:
|
|
result = await db.execute(
|
|
select(Message)
|
|
.filter_by(channel_id=channel_id, is_pinned=True)
|
|
.order_by(Message.pinned_at.desc())
|
|
.offset(skip)
|
|
.limit(limit)
|
|
)
|
|
all_messages = result.scalars().all()
|
|
return [MessageModel.model_validate(message) for message in all_messages]
|
|
|
|
async def update_message_by_id(
|
|
self, id: str, form_data: MessageForm, db: Optional[AsyncSession] = None
|
|
) -> Optional[MessageModel]:
|
|
async with get_async_db_context(db) as db:
|
|
message = await db.get(Message, id)
|
|
message.content = form_data.content
|
|
message.data = {
|
|
**(message.data if message.data else {}),
|
|
**(form_data.data if form_data.data else {}),
|
|
}
|
|
message.meta = {
|
|
**(message.meta if message.meta else {}),
|
|
**(form_data.meta if form_data.meta else {}),
|
|
}
|
|
message.updated_at = int(time.time_ns())
|
|
await db.commit()
|
|
await db.refresh(message)
|
|
return MessageModel.model_validate(message) if message else None
|
|
|
|
async def update_is_pinned_by_id(
|
|
self,
|
|
id: str,
|
|
is_pinned: bool,
|
|
pinned_by: Optional[str] = None,
|
|
db: Optional[AsyncSession] = None,
|
|
) -> Optional[MessageModel]:
|
|
async with get_async_db_context(db) as db:
|
|
message = await db.get(Message, id)
|
|
message.is_pinned = is_pinned
|
|
message.pinned_at = int(time.time_ns()) if is_pinned else None
|
|
message.pinned_by = pinned_by if is_pinned else None
|
|
await db.commit()
|
|
await db.refresh(message)
|
|
return MessageModel.model_validate(message) if message else None
|
|
|
|
async def get_unread_message_count(
|
|
self,
|
|
channel_id: str,
|
|
user_id: str,
|
|
last_read_at: Optional[int] = None,
|
|
db: Optional[AsyncSession] = None,
|
|
) -> int:
|
|
async with get_async_db_context(db) as db:
|
|
stmt = select(func.count(Message.id)).filter(
|
|
Message.channel_id == channel_id,
|
|
Message.parent_id == None, # only count top-level messages
|
|
Message.created_at > (last_read_at if last_read_at else 0),
|
|
)
|
|
if user_id:
|
|
stmt = stmt.filter(Message.user_id != user_id)
|
|
result = await db.execute(stmt)
|
|
return result.scalar()
|
|
|
|
async def add_reaction_to_message(
|
|
self, id: str, user_id: str, name: str, db: Optional[AsyncSession] = None
|
|
) -> Optional[MessageReactionModel]:
|
|
async with get_async_db_context(db) as db:
|
|
# check for existing reaction
|
|
result = await db.execute(select(MessageReaction).filter_by(message_id=id, user_id=user_id, name=name))
|
|
existing_reaction = result.scalars().first()
|
|
if existing_reaction:
|
|
return MessageReactionModel.model_validate(existing_reaction)
|
|
|
|
reaction_id = str(uuid.uuid4())
|
|
reaction = MessageReactionModel(
|
|
id=reaction_id,
|
|
user_id=user_id,
|
|
message_id=id,
|
|
name=name,
|
|
created_at=int(time.time_ns()),
|
|
)
|
|
result = MessageReaction(**reaction.model_dump())
|
|
db.add(result)
|
|
await db.commit()
|
|
await db.refresh(result)
|
|
return MessageReactionModel.model_validate(result) if result else None
|
|
|
|
async def get_reactions_by_message_id(self, id: str, db: Optional[AsyncSession] = None) -> list[Reactions]:
|
|
async with get_async_db_context(db) as db:
|
|
# JOIN User so all user info is fetched in one query
|
|
result = await db.execute(
|
|
select(MessageReaction, User)
|
|
.join(User, MessageReaction.user_id == User.id)
|
|
.filter(MessageReaction.message_id == id)
|
|
)
|
|
results = result.all()
|
|
|
|
reactions = {}
|
|
|
|
for reaction, user in results:
|
|
if reaction.name not in reactions:
|
|
reactions[reaction.name] = {
|
|
'name': reaction.name,
|
|
'users': [],
|
|
'count': 0,
|
|
}
|
|
|
|
reactions[reaction.name]['users'].append(
|
|
{
|
|
'id': user.id,
|
|
'name': user.name,
|
|
}
|
|
)
|
|
reactions[reaction.name]['count'] += 1
|
|
|
|
return [Reactions(**reaction) for reaction in reactions.values()]
|
|
|
|
async def get_reactions_by_message_ids(
|
|
self, ids: list[str], db: Optional[AsyncSession] = None
|
|
) -> dict[str, list[Reactions]]:
|
|
"""Batch-fetch reactions for multiple messages in a single query.
|
|
|
|
Returns a dict mapping each message_id to its list of Reactions.
|
|
Messages with no reactions map to an empty list.
|
|
"""
|
|
if not ids:
|
|
return {}
|
|
|
|
async with get_async_db_context(db) as db:
|
|
result = await db.execute(
|
|
select(MessageReaction, User)
|
|
.join(User, MessageReaction.user_id == User.id)
|
|
.filter(MessageReaction.message_id.in_(ids))
|
|
)
|
|
rows = result.all()
|
|
|
|
# Group by (message_id, reaction_name)
|
|
grouped: dict[str, dict[str, dict]] = {mid: {} for mid in ids}
|
|
for reaction, user in rows:
|
|
mid = reaction.message_id
|
|
if mid not in grouped:
|
|
grouped[mid] = {}
|
|
if reaction.name not in grouped[mid]:
|
|
grouped[mid][reaction.name] = {
|
|
'name': reaction.name,
|
|
'users': [],
|
|
'count': 0,
|
|
}
|
|
grouped[mid][reaction.name]['users'].append(
|
|
{
|
|
'id': user.id,
|
|
'name': user.name,
|
|
}
|
|
)
|
|
grouped[mid][reaction.name]['count'] += 1
|
|
|
|
return {mid: [Reactions(**r) for r in reactions.values()] for mid, reactions in grouped.items()}
|
|
|
|
async def get_thread_reply_counts_by_message_ids(
|
|
self, ids: list[str], db: Optional[AsyncSession] = None
|
|
) -> dict[str, tuple[int, int | None]]:
|
|
"""Batch-fetch reply counts and latest reply timestamps for multiple parent messages.
|
|
|
|
Returns a dict mapping each parent message_id to a
|
|
(reply_count, latest_reply_created_at) tuple.
|
|
Messages with no replies are omitted from the result.
|
|
"""
|
|
if not ids:
|
|
return {}
|
|
|
|
async with get_async_db_context(db) as db:
|
|
result = await db.execute(
|
|
select(
|
|
Message.parent_id,
|
|
func.count(Message.id),
|
|
func.max(Message.created_at),
|
|
)
|
|
.filter(Message.parent_id.in_(ids))
|
|
.group_by(Message.parent_id)
|
|
)
|
|
return {row[0]: (row[1], row[2]) for row in result.all()}
|
|
|
|
async def remove_reaction_by_id_and_user_id_and_name(
|
|
self, id: str, user_id: str, name: str, db: Optional[AsyncSession] = None
|
|
) -> bool:
|
|
async with get_async_db_context(db) as db:
|
|
await db.execute(delete(MessageReaction).filter_by(message_id=id, user_id=user_id, name=name))
|
|
await db.commit()
|
|
return True
|
|
|
|
async def delete_reactions_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
|
|
async with get_async_db_context(db) as db:
|
|
await db.execute(delete(MessageReaction).filter_by(message_id=id))
|
|
await db.commit()
|
|
return True
|
|
|
|
async def delete_replies_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
|
|
async with get_async_db_context(db) as db:
|
|
await db.execute(delete(Message).filter_by(parent_id=id))
|
|
await db.commit()
|
|
return True
|
|
|
|
async def delete_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
|
|
async with get_async_db_context(db) as db:
|
|
await db.execute(delete(Message).filter_by(id=id))
|
|
|
|
# Delete all reactions to this message
|
|
await db.execute(delete(MessageReaction).filter_by(message_id=id))
|
|
|
|
await db.commit()
|
|
return True
|
|
|
|
async def search_messages_by_channel_ids(
|
|
self,
|
|
channel_ids: list[str],
|
|
query: str,
|
|
start_timestamp: Optional[int] = None,
|
|
end_timestamp: Optional[int] = None,
|
|
limit: int = 10,
|
|
db: Optional[AsyncSession] = None,
|
|
) -> list[MessageModel]:
|
|
"""Search messages in specified channels by content."""
|
|
async with get_async_db_context(db) as db:
|
|
stmt = select(Message).filter(
|
|
Message.channel_id.in_(channel_ids),
|
|
Message.content.ilike(f'%{query}%'),
|
|
)
|
|
|
|
if start_timestamp:
|
|
stmt = stmt.filter(Message.created_at >= start_timestamp)
|
|
if end_timestamp:
|
|
stmt = stmt.filter(Message.created_at <= end_timestamp)
|
|
|
|
stmt = stmt.order_by(Message.created_at.desc()).limit(limit)
|
|
result = await db.execute(stmt)
|
|
messages = result.scalars().all()
|
|
return [MessageModel.model_validate(msg) for msg in messages]
|
|
|
|
|
|
Messages = MessageTable()
|