diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py
index 54ca0218d7..a3a9050f78 100644
--- a/backend/open_webui/config.py
+++ b/backend/open_webui/config.py
@@ -1455,6 +1455,10 @@ USER_PERMISSIONS_FEATURES_NOTES = (
os.environ.get("USER_PERMISSIONS_FEATURES_NOTES", "True").lower() == "true"
)
+USER_PERMISSIONS_FEATURES_CHANNELS = (
+ os.environ.get("USER_PERMISSIONS_FEATURES_CHANNELS", "True").lower() == "true"
+)
+
USER_PERMISSIONS_FEATURES_API_KEYS = (
os.environ.get("USER_PERMISSIONS_FEATURES_API_KEYS", "False").lower() == "true"
)
@@ -1509,8 +1513,9 @@ DEFAULT_USER_PERMISSIONS = {
"features": {
# General features
"api_keys": USER_PERMISSIONS_FEATURES_API_KEYS,
- "folders": USER_PERMISSIONS_FEATURES_FOLDERS,
"notes": USER_PERMISSIONS_FEATURES_NOTES,
+ "folders": USER_PERMISSIONS_FEATURES_FOLDERS,
+ "channels": USER_PERMISSIONS_FEATURES_CHANNELS,
"direct_tool_servers": USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS,
# Chat features
"web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
diff --git a/backend/open_webui/migrations/versions/90ef40d4714e_update_channel_and_channel_members_table.py b/backend/open_webui/migrations/versions/90ef40d4714e_update_channel_and_channel_members_table.py
new file mode 100644
index 0000000000..8c52a4b22a
--- /dev/null
+++ b/backend/open_webui/migrations/versions/90ef40d4714e_update_channel_and_channel_members_table.py
@@ -0,0 +1,81 @@
+"""Update channel and channel members table
+
+Revision ID: 90ef40d4714e
+Revises: b10670c03dd5
+Create Date: 2025-11-30 06:33:38.790341
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import open_webui.internal.db
+
+
+# revision identifiers, used by Alembic.
+revision: str = "90ef40d4714e"
+down_revision: Union[str, None] = "b10670c03dd5"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Update 'channel' table
+ op.add_column("channel", sa.Column("is_private", sa.Boolean(), nullable=True))
+
+ op.add_column("channel", sa.Column("archived_at", sa.BigInteger(), nullable=True))
+ op.add_column("channel", sa.Column("archived_by", sa.Text(), nullable=True))
+
+ op.add_column("channel", sa.Column("deleted_at", sa.BigInteger(), nullable=True))
+ op.add_column("channel", sa.Column("deleted_by", sa.Text(), nullable=True))
+
+ op.add_column("channel", sa.Column("updated_by", sa.Text(), nullable=True))
+
+ # Update 'channel_member' table
+ op.add_column("channel_member", sa.Column("role", sa.Text(), nullable=True))
+ op.add_column("channel_member", sa.Column("invited_by", sa.Text(), nullable=True))
+ op.add_column(
+ "channel_member", sa.Column("invited_at", sa.BigInteger(), nullable=True)
+ )
+
+ # Create 'channel_webhook' table
+ op.create_table(
+ "channel_webhook",
+ sa.Column("id", sa.Text(), primary_key=True, unique=True, nullable=False),
+ sa.Column("user_id", sa.Text(), nullable=False),
+ sa.Column(
+ "channel_id",
+ sa.Text(),
+ sa.ForeignKey("channel.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("name", sa.Text(), nullable=False),
+ sa.Column("profile_image_url", sa.Text(), nullable=True),
+ sa.Column("token", sa.Text(), nullable=False),
+ sa.Column("last_used_at", sa.BigInteger(), nullable=True),
+ sa.Column("created_at", sa.BigInteger(), nullable=False),
+ sa.Column("updated_at", sa.BigInteger(), nullable=False),
+ )
+
+ pass
+
+
+def downgrade() -> None:
+ # Downgrade 'channel' table
+ op.drop_column("channel", "is_private")
+ op.drop_column("channel", "archived_at")
+ op.drop_column("channel", "archived_by")
+ op.drop_column("channel", "deleted_at")
+ op.drop_column("channel", "deleted_by")
+ op.drop_column("channel", "updated_by")
+
+ # Downgrade 'channel_member' table
+ op.drop_column("channel_member", "role")
+ op.drop_column("channel_member", "invited_by")
+ op.drop_column("channel_member", "invited_at")
+
+ # Drop 'channel_webhook' table
+ op.drop_table("channel_webhook")
+
+ pass
diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py
index 5d452b0216..754f6e3dfa 100644
--- a/backend/open_webui/models/channels.py
+++ b/backend/open_webui/models/channels.py
@@ -4,10 +4,13 @@ import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
-from open_webui.utils.access_control import has_access
+from open_webui.models.groups import Groups
from pydantic import BaseModel, ConfigDict
-from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, case
+from sqlalchemy.dialects.postgresql import JSONB
+
+
+from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, case, cast
from sqlalchemy import or_, func, select, and_, text
from sqlalchemy.sql import exists
@@ -26,12 +29,23 @@ class Channel(Base):
name = Column(Text)
description = Column(Text, nullable=True)
+ # Used to indicate if the channel is private (for 'group' type channels)
+ is_private = Column(Boolean, nullable=True)
+
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True)
created_at = Column(BigInteger)
+
updated_at = Column(BigInteger)
+ updated_by = Column(Text, nullable=True)
+
+ archived_at = Column(BigInteger, nullable=True)
+ archived_by = Column(Text, nullable=True)
+
+ deleted_at = Column(BigInteger, nullable=True)
+ deleted_by = Column(Text, nullable=True)
class ChannelModel(BaseModel):
@@ -39,17 +53,28 @@ class ChannelModel(BaseModel):
id: str
user_id: str
+
type: Optional[str] = None
name: str
description: Optional[str] = None
+ is_private: Optional[bool] = None
+
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
created_at: int # timestamp in epoch (time_ns)
+
updated_at: int # timestamp in epoch (time_ns)
+ updated_by: Optional[str] = None
+
+ archived_at: Optional[int] = None # timestamp in epoch (time_ns)
+ archived_by: Optional[str] = None
+
+ deleted_at: Optional[int] = None # timestamp in epoch (time_ns)
+ deleted_by: Optional[str] = None
class ChannelMember(Base):
@@ -59,7 +84,9 @@ class ChannelMember(Base):
channel_id = Column(Text, nullable=False)
user_id = Column(Text, nullable=False)
+ role = Column(Text, nullable=True)
status = Column(Text, nullable=True)
+
is_active = Column(Boolean, nullable=False, default=True)
is_channel_muted = Column(Boolean, nullable=False, default=False)
@@ -68,6 +95,9 @@ class ChannelMember(Base):
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
+ invited_at = Column(BigInteger, nullable=True)
+ invited_by = Column(Text, nullable=True)
+
joined_at = Column(BigInteger)
left_at = Column(BigInteger, nullable=True)
@@ -84,7 +114,9 @@ class ChannelMemberModel(BaseModel):
channel_id: str
user_id: str
+ role: Optional[str] = None
status: Optional[str] = None
+
is_active: bool = True
is_channel_muted: bool = False
@@ -93,6 +125,9 @@ class ChannelMemberModel(BaseModel):
data: Optional[dict] = None
meta: Optional[dict] = None
+ invited_at: Optional[int] = None # timestamp in epoch (time_ns)
+ invited_by: Optional[str] = None
+
joined_at: Optional[int] = None # timestamp in epoch (time_ns)
left_at: Optional[int] = None # timestamp in epoch (time_ns)
@@ -102,29 +137,128 @@ class ChannelMemberModel(BaseModel):
updated_at: Optional[int] = None # timestamp in epoch (time_ns)
+class ChannelWebhook(Base):
+ __tablename__ = "channel_webhook"
+
+ id = Column(Text, primary_key=True, unique=True)
+ channel_id = Column(Text, nullable=False)
+ user_id = Column(Text, nullable=False)
+
+ name = Column(Text, nullable=False)
+ profile_image_url = Column(Text, nullable=True)
+
+ token = Column(Text, nullable=False)
+ last_used_at = Column(BigInteger, nullable=True)
+
+ created_at = Column(BigInteger, nullable=False)
+ updated_at = Column(BigInteger, nullable=False)
+
+
+class ChannelWebhookModel(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: str
+ channel_id: str
+ user_id: str
+
+ name: str
+ profile_image_url: Optional[str] = None
+
+ token: str
+ last_used_at: Optional[int] = None # timestamp in epoch (time_ns)
+
+ created_at: int # timestamp in epoch (time_ns)
+ updated_at: int # timestamp in epoch (time_ns)
+
+
####################
# Forms
####################
class ChannelResponse(ChannelModel):
+ is_manager: bool = False
write_access: bool = False
+
user_count: Optional[int] = None
class ChannelForm(BaseModel):
- type: Optional[str] = None
- name: str
+ name: str = ""
description: Optional[str] = None
+ is_private: Optional[bool] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
+ group_ids: Optional[list[str]] = None
user_ids: Optional[list[str]] = None
+class CreateChannelForm(ChannelForm):
+ type: Optional[str] = None
+
+
class ChannelTable:
+
+ def _collect_unique_user_ids(
+ self,
+ invited_by: str,
+ user_ids: Optional[list[str]] = None,
+ group_ids: Optional[list[str]] = None,
+ ) -> set[str]:
+ """
+ Collect unique user ids from:
+ - invited_by
+ - user_ids
+ - each group in group_ids
+ Returns a set for efficient SQL diffing.
+ """
+ users = set(user_ids or [])
+ users.add(invited_by)
+
+ for group_id in group_ids or []:
+ users.update(Groups.get_group_user_ids_by_id(group_id))
+
+ return users
+
+ def _create_membership_models(
+ self,
+ channel_id: str,
+ invited_by: str,
+ user_ids: set[str],
+ ) -> list[ChannelMember]:
+ """
+ Takes a set of NEW user IDs (already filtered to exclude existing members).
+ Returns ORM ChannelMember objects to be added.
+ """
+ now = int(time.time_ns())
+ memberships = []
+
+ for uid in user_ids:
+ model = ChannelMemberModel(
+ **{
+ "id": str(uuid.uuid4()),
+ "channel_id": channel_id,
+ "user_id": uid,
+ "status": "joined",
+ "is_active": True,
+ "is_channel_muted": False,
+ "is_channel_pinned": False,
+ "invited_at": now,
+ "invited_by": invited_by,
+ "joined_at": now,
+ "left_at": None,
+ "last_read_at": now,
+ "created_at": now,
+ "updated_at": now,
+ }
+ )
+ memberships.append(ChannelMember(**model.model_dump()))
+
+ return memberships
+
def insert_new_channel(
- self, form_data: ChannelForm, user_id: str
+ self, form_data: CreateChannelForm, user_id: str
) -> Optional[ChannelModel]:
with get_db() as db:
channel = ChannelModel(
@@ -140,32 +274,19 @@ class ChannelTable:
)
new_channel = Channel(**channel.model_dump())
- if form_data.type == "dm":
- # For direct message channels, automatically add the specified users as members
- user_ids = form_data.user_ids or []
- if user_id not in user_ids:
- user_ids.append(user_id) # Ensure the creator is also a member
-
- for uid in user_ids:
- channel_member = ChannelMemberModel(
- **{
- "id": str(uuid.uuid4()),
- "channel_id": channel.id,
- "user_id": uid,
- "status": "joined",
- "is_active": True,
- "is_channel_muted": False,
- "is_channel_pinned": False,
- "joined_at": int(time.time_ns()),
- "left_at": None,
- "last_read_at": int(time.time_ns()),
- "created_at": int(time.time_ns()),
- "updated_at": int(time.time_ns()),
- }
- )
- new_membership = ChannelMember(**channel_member.model_dump())
- db.add(new_membership)
+ if form_data.type in ["group", "dm"]:
+ users = self._collect_unique_user_ids(
+ invited_by=user_id,
+ user_ids=form_data.user_ids,
+ group_ids=form_data.group_ids,
+ )
+ memberships = self._create_membership_models(
+ channel_id=new_channel.id,
+ invited_by=user_id,
+ user_ids=users,
+ )
+ db.add_all(memberships)
db.add(new_channel)
db.commit()
return channel
@@ -175,24 +296,84 @@ class ChannelTable:
channels = db.query(Channel).all()
return [ChannelModel.model_validate(channel) for channel in channels]
- def get_channels_by_user_id(
- self, user_id: str, permission: str = "read"
- ) -> list[ChannelModel]:
- channels = self.get_channels()
+ def _has_permission(self, db, query, filter: dict, permission: str = "read"):
+ group_ids = filter.get("group_ids", [])
+ user_id = filter.get("user_id")
- channel_list = []
- for channel in channels:
- if channel.type == "dm":
- membership = self.get_member_by_channel_and_user_id(channel.id, user_id)
- if membership and membership.is_active:
- channel_list.append(channel)
- else:
- if channel.user_id == user_id or has_access(
- user_id, permission, channel.access_control
- ):
- channel_list.append(channel)
+ dialect_name = db.bind.dialect.name
- return channel_list
+ # Public access
+ conditions = []
+ if group_ids or user_id:
+ conditions.extend(
+ [
+ Channel.access_control.is_(None),
+ cast(Channel.access_control, String) == "null",
+ ]
+ )
+
+ # User-level permission
+ if user_id:
+ conditions.append(Channel.user_id == user_id)
+
+ # Group-level permission
+ if group_ids:
+ group_conditions = []
+ for gid in group_ids:
+ if dialect_name == "sqlite":
+ group_conditions.append(
+ Channel.access_control[permission]["group_ids"].contains([gid])
+ )
+ elif dialect_name == "postgresql":
+ group_conditions.append(
+ cast(
+ Channel.access_control[permission]["group_ids"],
+ JSONB,
+ ).contains([gid])
+ )
+ conditions.append(or_(*group_conditions))
+
+ if conditions:
+ query = query.filter(or_(*conditions))
+
+ return query
+
+ def get_channels_by_user_id(self, user_id: str) -> list[ChannelModel]:
+ with get_db() as db:
+ user_group_ids = [
+ group.id for group in Groups.get_groups_by_member_id(user_id)
+ ]
+
+ membership_channels = (
+ db.query(Channel)
+ .join(ChannelMember, Channel.id == ChannelMember.channel_id)
+ .filter(
+ Channel.deleted_at.is_(None),
+ Channel.archived_at.is_(None),
+ Channel.type.in_(["group", "dm"]),
+ ChannelMember.user_id == user_id,
+ ChannelMember.is_active.is_(True),
+ )
+ .all()
+ )
+
+ query = db.query(Channel).filter(
+ Channel.deleted_at.is_(None),
+ Channel.archived_at.is_(None),
+ or_(
+ Channel.type.is_(None), # True NULL/None
+ Channel.type == "", # Empty string
+ and_(Channel.type != "group", Channel.type != "dm"),
+ ),
+ )
+ query = self._has_permission(
+ db, query, {"user_id": user_id, "group_ids": user_group_ids}
+ )
+
+ standard_channels = query.all()
+
+ all_channels = membership_channels + standard_channels
+ return [ChannelModel.model_validate(c) for c in all_channels]
def get_dm_channel_by_user_ids(self, user_ids: list[str]) -> Optional[ChannelModel]:
with get_db() as db:
@@ -227,6 +408,78 @@ class ChannelTable:
return ChannelModel.model_validate(channel) if channel else None
+ def add_members_to_channel(
+ self,
+ channel_id: str,
+ invited_by: str,
+ user_ids: Optional[list[str]] = None,
+ group_ids: Optional[list[str]] = None,
+ ) -> list[ChannelMemberModel]:
+ with get_db() as db:
+ # 1. Collect all user_ids including groups + inviter
+ requested_users = self._collect_unique_user_ids(
+ invited_by, user_ids, group_ids
+ )
+
+ existing_users = {
+ row.user_id
+ for row in db.query(ChannelMember.user_id)
+ .filter(ChannelMember.channel_id == channel_id)
+ .all()
+ }
+
+ new_user_ids = requested_users - existing_users
+ if not new_user_ids:
+ return [] # Nothing to add
+
+ new_memberships = self._create_membership_models(
+ channel_id, invited_by, new_user_ids
+ )
+
+ db.add_all(new_memberships)
+ db.commit()
+
+ return [
+ ChannelMemberModel.model_validate(membership)
+ for membership in new_memberships
+ ]
+
+ def remove_members_from_channel(
+ self,
+ channel_id: str,
+ user_ids: list[str],
+ ) -> int:
+ with get_db() as db:
+ result = (
+ db.query(ChannelMember)
+ .filter(
+ ChannelMember.channel_id == channel_id,
+ ChannelMember.user_id.in_(user_ids),
+ )
+ .delete(synchronize_session=False)
+ )
+ db.commit()
+ return result # number of rows deleted
+
+ def is_user_channel_manager(self, channel_id: str, user_id: str) -> bool:
+ with get_db() as db:
+ # Check if the user is the creator of the channel
+ # or has a 'manager' role in ChannelMember
+ channel = db.query(Channel).filter(Channel.id == channel_id).first()
+ if channel and channel.user_id == user_id:
+ return True
+
+ membership = (
+ db.query(ChannelMember)
+ .filter(
+ ChannelMember.channel_id == channel_id,
+ ChannelMember.user_id == user_id,
+ ChannelMember.role == "manager",
+ )
+ .first()
+ )
+ return membership is not None
+
def join_channel(
self, channel_id: str, user_id: str
) -> Optional[ChannelMemberModel]:
@@ -398,8 +651,12 @@ class ChannelTable:
return None
channel.name = form_data.name
+ channel.description = form_data.description
+ channel.is_private = form_data.is_private
+
channel.data = form_data.data
channel.meta = form_data.meta
+
channel.access_control = form_data.access_control
channel.updated_at = int(time.time_ns())
diff --git a/backend/open_webui/models/groups.py b/backend/open_webui/models/groups.py
index e5c0612639..a7900e2c78 100644
--- a/backend/open_webui/models/groups.py
+++ b/backend/open_webui/models/groups.py
@@ -11,7 +11,18 @@ from open_webui.models.files import FileMetadataResponse
from pydantic import BaseModel, ConfigDict
-from sqlalchemy import BigInteger, Column, String, Text, JSON, func, ForeignKey
+from sqlalchemy import (
+ BigInteger,
+ Column,
+ String,
+ Text,
+ JSON,
+ and_,
+ func,
+ ForeignKey,
+ cast,
+ or_,
+)
log = logging.getLogger(__name__)
@@ -41,7 +52,6 @@ class Group(Base):
class GroupModel(BaseModel):
- model_config = ConfigDict(from_attributes=True)
id: str
user_id: str
@@ -56,6 +66,8 @@ class GroupModel(BaseModel):
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
+ model_config = ConfigDict(from_attributes=True)
+
class GroupMember(Base):
__tablename__ = "group_member"
@@ -84,17 +96,8 @@ class GroupMemberModel(BaseModel):
####################
-class GroupResponse(BaseModel):
- id: str
- user_id: str
- name: str
- description: str
- permissions: Optional[dict] = None
- data: Optional[dict] = None
- meta: Optional[dict] = None
+class GroupResponse(GroupModel):
member_count: Optional[int] = None
- created_at: int # timestamp in epoch
- updated_at: int # timestamp in epoch
class GroupForm(BaseModel):
@@ -112,6 +115,11 @@ class GroupUpdateForm(GroupForm):
pass
+class GroupListResponse(BaseModel):
+ items: list[GroupResponse] = []
+ total: int = 0
+
+
class GroupTable:
def insert_new_group(
self, user_id: str, form_data: GroupForm
@@ -140,13 +148,87 @@ class GroupTable:
except Exception:
return None
- def get_groups(self) -> list[GroupModel]:
+ def get_all_groups(self) -> list[GroupModel]:
with get_db() as db:
+ groups = db.query(Group).order_by(Group.updated_at.desc()).all()
+ return [GroupModel.model_validate(group) for group in groups]
+
+ def get_groups(self, filter) -> list[GroupResponse]:
+ with get_db() as db:
+ query = db.query(Group)
+
+ if filter:
+ if "query" in filter:
+ query = query.filter(Group.name.ilike(f"%{filter['query']}%"))
+ if "member_id" in filter:
+ query = query.join(
+ GroupMember, GroupMember.group_id == Group.id
+ ).filter(GroupMember.user_id == filter["member_id"])
+
+ if "share" in filter:
+ share_value = filter["share"]
+ json_share = Group.data["config"]["share"].as_boolean()
+
+ if share_value:
+ query = query.filter(
+ or_(
+ Group.data.is_(None),
+ json_share.is_(None),
+ json_share == True,
+ )
+ )
+ else:
+ query = query.filter(
+ and_(Group.data.isnot(None), json_share == False)
+ )
+ groups = query.order_by(Group.updated_at.desc()).all()
return [
- GroupModel.model_validate(group)
- for group in db.query(Group).order_by(Group.updated_at.desc()).all()
+ GroupResponse.model_validate(
+ {
+ **GroupModel.model_validate(group).model_dump(),
+ "member_count": self.get_group_member_count_by_id(group.id),
+ }
+ )
+ for group in groups
]
+ def search_groups(
+ self, filter: Optional[dict] = None, skip: int = 0, limit: int = 30
+ ) -> GroupListResponse:
+ with get_db() as db:
+ query = db.query(Group)
+
+ if filter:
+ if "query" in filter:
+ query = query.filter(Group.name.ilike(f"%{filter['query']}%"))
+ if "member_id" in filter:
+ query = query.join(
+ GroupMember, GroupMember.group_id == Group.id
+ ).filter(GroupMember.user_id == filter["member_id"])
+
+ if "share" in filter:
+ # 'share' is stored in data JSON, support both sqlite and postgres
+ share_value = filter["share"]
+ print("Filtering by share:", share_value)
+ query = query.filter(
+ Group.data.op("->>")("share") == str(share_value)
+ )
+
+ total = query.count()
+ query = query.order_by(Group.updated_at.desc())
+ groups = query.offset(skip).limit(limit).all()
+
+ return {
+ "items": [
+ GroupResponse.model_validate(
+ **GroupModel.model_validate(group).model_dump(),
+ member_count=self.get_group_member_count_by_id(group.id),
+ )
+ for group in groups
+ ],
+ "total": total,
+ }
+
def get_groups_by_member_id(self, user_id: str) -> list[GroupModel]:
with get_db() as db:
return [
@@ -293,7 +375,7 @@ class GroupTable:
) -> list[GroupModel]:
# check for existing groups
- existing_groups = self.get_groups()
+ existing_groups = self.get_all_groups()
existing_group_names = {group.name for group in existing_groups}
new_groups = []
diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py
index 2351c4c54c..98be21463d 100644
--- a/backend/open_webui/models/messages.py
+++ b/backend/open_webui/models/messages.py
@@ -5,7 +5,7 @@ from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.models.tags import TagModel, Tag, Tags
-from open_webui.models.users import Users, UserNameResponse
+from open_webui.models.users import Users, User, UserNameResponse
from open_webui.models.channels import Channels, ChannelMember
@@ -100,7 +100,7 @@ class MessageForm(BaseModel):
class Reactions(BaseModel):
name: str
- user_ids: list[str]
+ users: list[dict]
count: int
@@ -373,6 +373,15 @@ class MessageTable:
self, id: str, user_id: str, name: str
) -> Optional[MessageReactionModel]:
with get_db() as db:
+ # check for existing reaction
+ existing_reaction = (
+ db.query(MessageReaction)
+ .filter_by(message_id=id, user_id=user_id, name=name)
+ .first()
+ )
+ if existing_reaction:
+ return MessageReactionModel.model_validate(existing_reaction)
+
reaction_id = str(uuid.uuid4())
reaction = MessageReactionModel(
id=reaction_id,
@@ -389,17 +398,30 @@ class MessageTable:
def get_reactions_by_message_id(self, id: str) -> list[Reactions]:
with get_db() as db:
- all_reactions = db.query(MessageReaction).filter_by(message_id=id).all()
+ # JOIN User so all user info is fetched in one query
+ results = (
+ db.query(MessageReaction, User)
+ .join(User, MessageReaction.user_id == User.id)
+ .filter(MessageReaction.message_id == id)
+ .all()
+ )
reactions = {}
- for reaction in all_reactions:
+
+ for reaction, user in results:
if reaction.name not in reactions:
reactions[reaction.name] = {
"name": reaction.name,
- "user_ids": [],
+ "users": [],
"count": 0,
}
- reactions[reaction.name]["user_ids"].append(reaction.user_id)
+
+ reactions[reaction.name]["users"].append(
+ {
+ "id": user.id,
+ "name": user.name,
+ }
+ )
reactions[reaction.name]["count"] += 1
return [Reactions(**reaction) for reaction in reactions.values()]
diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py
index 8ddcf59d39..1c44d311ba 100755
--- a/backend/open_webui/models/models.py
+++ b/backend/open_webui/models/models.py
@@ -13,6 +13,8 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import String, cast, or_, and_, func
from sqlalchemy.dialects import postgresql, sqlite
+
+from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
@@ -220,30 +222,44 @@ class ModelsTable:
or has_access(user_id, permission, model.access_control, user_group_ids)
]
- def _has_write_permission(self, query, filter: dict):
- if filter.get("group_ids") or filter.get("user_id"):
- conditions = []
+ def _has_permission(self, db, query, filter: dict, permission: str = "read"):
+ group_ids = filter.get("group_ids", [])
+ user_id = filter.get("user_id")
- # --- ANY group_ids match ("write".group_ids) ---
- if filter.get("group_ids"):
- group_ids = filter["group_ids"]
- like_clauses = []
+ dialect_name = db.bind.dialect.name
- for gid in group_ids:
- like_clauses.append(
- cast(Model.access_control, String).like(
- f'%"write"%"group_ids"%"{gid}"%'
- )
+ # Public access
+ conditions = []
+ if group_ids or user_id:
+ conditions.extend(
+ [
+ Model.access_control.is_(None),
+ cast(Model.access_control, String) == "null",
+ ]
+ )
+
+ # User-level permission
+ if user_id:
+ conditions.append(Model.user_id == user_id)
+
+ # Group-level permission
+ if group_ids:
+ group_conditions = []
+ for gid in group_ids:
+ if dialect_name == "sqlite":
+ group_conditions.append(
+ Model.access_control[permission]["group_ids"].contains([gid])
)
+ elif dialect_name == "postgresql":
+ group_conditions.append(
+ cast(
+ Model.access_control[permission]["group_ids"],
+ JSONB,
+ ).contains([gid])
+ )
+ conditions.append(or_(*group_conditions))
- # ANY → OR
- conditions.append(or_(*like_clauses))
-
- # --- user_id match (owner) ---
- if filter.get("user_id"):
- conditions.append(Model.user_id == filter["user_id"])
-
- # Apply OR across the two groups of conditions
+ if conditions:
query = query.filter(or_(*conditions))
return query
@@ -266,15 +282,20 @@ class ModelsTable:
)
)
- # Apply access control filtering
- query = self._has_write_permission(query, filter)
-
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Model.user_id == user_id)
elif view_option == "shared":
query = query.filter(Model.user_id != user_id)
+ # Apply access control filtering
+ query = self._has_permission(
+ db,
+ query,
+ filter,
+ permission="write",
+ )
+
tag = filter.get("tag")
if tag:
# TODO: This is a simple implementation and should be improved for performance
diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py
index ede5f5e761..ba56b74ece 100644
--- a/backend/open_webui/models/users.py
+++ b/backend/open_webui/models/users.py
@@ -7,6 +7,9 @@ from open_webui.internal.db import Base, JSONField, get_db
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
from open_webui.models.chats import Chats
from open_webui.models.groups import Groups, GroupMember
+from open_webui.models.channels import ChannelMember
+
+
from open_webui.utils.misc import throttle
@@ -104,6 +107,12 @@ class UserModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
+class UserStatusModel(UserModel):
+ is_active: bool = False
+
+ model_config = ConfigDict(from_attributes=True)
+
+
class ApiKey(Base):
__tablename__ = "api_key"
@@ -161,7 +170,13 @@ class UserGroupIdsListResponse(BaseModel):
total: int
-class UserInfoResponse(BaseModel):
+class UserStatus(BaseModel):
+ status_emoji: Optional[str] = None
+ status_message: Optional[str] = None
+ status_expires_at: Optional[int] = None
+
+
+class UserInfoResponse(UserStatus):
id: str
name: str
email: str
@@ -176,7 +191,7 @@ class UserIdNameResponse(BaseModel):
class UserIdNameStatusResponse(BaseModel):
id: str
name: str
- is_active: bool = False
+ is_active: Optional[bool] = None
class UserInfoListResponse(BaseModel):
@@ -311,6 +326,17 @@ class UsersTable:
)
)
+ channel_id = filter.get("channel_id")
+ if channel_id:
+ query = query.filter(
+ exists(
+ select(ChannelMember.id).where(
+ ChannelMember.user_id == User.id,
+ ChannelMember.channel_id == channel_id,
+ )
+ )
+ )
+
user_ids = filter.get("user_ids")
group_ids = filter.get("group_ids")
@@ -417,7 +443,7 @@ class UsersTable:
"total": total,
}
- def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserModel]:
+ def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserStatusModel]:
with get_db() as db:
users = db.query(User).filter(User.id.in_(user_ids)).all()
return [UserModel.model_validate(user) for user in users]
@@ -473,6 +499,21 @@ class UsersTable:
except Exception:
return None
+ def update_user_status_by_id(
+ self, id: str, form_data: UserStatus
+ ) -> Optional[UserModel]:
+ try:
+ with get_db() as db:
+ db.query(User).filter_by(id=id).update(
+ {**form_data.model_dump(exclude_none=True)}
+ )
+ db.commit()
+
+ user = db.query(User).filter_by(id=id).first()
+ return UserModel.model_validate(user)
+ except Exception:
+ return None
+
def update_user_profile_image_url_by_id(
self, id: str, profile_image_url: str
) -> Optional[UserModel]:
diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py
index 1b79d84cfd..42302043ed 100644
--- a/backend/open_webui/routers/auths.py
+++ b/backend/open_webui/routers/auths.py
@@ -17,7 +17,12 @@ from open_webui.models.auths import (
SignupForm,
UpdatePasswordForm,
)
-from open_webui.models.users import UserProfileImageResponse, Users, UpdateProfileForm
+from open_webui.models.users import (
+ UserProfileImageResponse,
+ Users,
+ UpdateProfileForm,
+ UserStatus,
+)
from open_webui.models.groups import Groups
from open_webui.models.oauth_sessions import OAuthSessions
@@ -82,7 +87,7 @@ class SessionUserResponse(Token, UserProfileImageResponse):
permissions: Optional[dict] = None
-class SessionUserInfoResponse(SessionUserResponse):
+class SessionUserInfoResponse(SessionUserResponse, UserStatus):
bio: Optional[str] = None
gender: Optional[str] = None
date_of_birth: Optional[datetime.date] = None
@@ -139,6 +144,9 @@ async def get_session_user(
"bio": user.bio,
"gender": user.gender,
"date_of_birth": user.date_of_birth,
+ "status_emoji": user.status_emoji,
+ "status_message": user.status_message,
+ "status_expires_at": user.status_expires_at,
"permissions": user_permissions,
}
diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py
index d492176a00..0dff67da3e 100644
--- a/backend/open_webui/routers/channels.py
+++ b/backend/open_webui/routers/channels.py
@@ -8,6 +8,8 @@ from pydantic import BaseModel
from open_webui.socket.main import (
+ emit_to_users,
+ enter_room_for_users,
sio,
get_user_ids_from_room,
)
@@ -26,6 +28,7 @@ from open_webui.models.channels import (
ChannelModel,
ChannelForm,
ChannelResponse,
+ CreateChannelForm,
)
from open_webui.models.messages import (
Messages,
@@ -53,6 +56,7 @@ from open_webui.utils.access_control import (
has_access,
get_users_with_access,
get_permitted_group_and_user_ids,
+ has_permission,
)
from open_webui.utils.webhook import post_webhook
from open_webui.utils.channels import extract_mentions, replace_mentions
@@ -76,18 +80,28 @@ class ChannelListItemResponse(ChannelModel):
@router.get("/", response_model=list[ChannelListItemResponse])
-async def get_channels(user=Depends(get_verified_user)):
+async def get_channels(request: Request, user=Depends(get_verified_user)):
+ if user.role != "admin" and not has_permission(
+ user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=ERROR_MESSAGES.UNAUTHORIZED,
+ )
channels = Channels.get_channels_by_user_id(user.id)
-
channel_list = []
for channel in channels:
last_message = Messages.get_last_message_by_channel_id(channel.id)
last_message_at = last_message.created_at if last_message else None
channel_member = Channels.get_member_by_channel_and_user_id(channel.id, user.id)
- unread_count = Messages.get_unread_message_count(
- channel.id, user.id, channel_member.last_read_at if channel_member else None
+ unread_count = (
+ Messages.get_unread_message_count(
+ channel.id, user.id, channel_member.last_read_at
+ )
+ if channel_member
+ else 0
)
user_ids = None
@@ -124,24 +138,141 @@ async def get_all_channels(user=Depends(get_verified_user)):
return Channels.get_channels_by_user_id(user.id)
+############################
+# GetDMChannelByUserId
+############################
+
+
+@router.get("/users/{user_id}", response_model=Optional[ChannelModel])
+async def get_dm_channel_by_user_id(
+ request: Request, user_id: str, user=Depends(get_verified_user)
+):
+ if user.role != "admin" and not has_permission(
+ user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=ERROR_MESSAGES.UNAUTHORIZED,
+ )
+
+ try:
+ existing_channel = Channels.get_dm_channel_by_user_ids([user.id, user_id])
+ if existing_channel:
+ participant_ids = [
+ member.user_id
+ for member in Channels.get_members_by_channel_id(existing_channel.id)
+ ]
+
+ await emit_to_users(
+ "events:channel",
+ {"data": {"type": "channel:created"}},
+ participant_ids,
+ )
+ await enter_room_for_users(
+ f"channel:{existing_channel.id}", participant_ids
+ )
+
+ Channels.update_member_active_status(existing_channel.id, user.id, True)
+ return ChannelModel(**existing_channel.model_dump())
+
+ channel = Channels.insert_new_channel(
+ CreateChannelForm(
+ type="dm",
+ name="",
+ user_ids=[user_id],
+ ),
+ user.id,
+ )
+
+ if channel:
+ participant_ids = [
+ member.user_id
+ for member in Channels.get_members_by_channel_id(channel.id)
+ ]
+
+ await emit_to_users(
+ "events:channel",
+ {"data": {"type": "channel:created"}},
+ participant_ids,
+ )
+ await enter_room_for_users(f"channel:{channel.id}", participant_ids)
+
+ return ChannelModel(**channel.model_dump())
+ else:
+ raise Exception("Error creating channel")
+ except Exception as e:
+ log.exception(e)
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+ )
+
+
############################
# CreateNewChannel
############################
@router.post("/create", response_model=Optional[ChannelModel])
-async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user)):
+async def create_new_channel(
+ request: Request, form_data: CreateChannelForm, user=Depends(get_verified_user)
+):
+ if user.role != "admin" and not has_permission(
+ user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=ERROR_MESSAGES.UNAUTHORIZED,
+ )
+
+ if form_data.type not in ["group", "dm"] and user.role != "admin":
+ # Only admins can create standard channels (joined by default)
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=ERROR_MESSAGES.UNAUTHORIZED,
+ )
+
try:
if form_data.type == "dm":
existing_channel = Channels.get_dm_channel_by_user_ids(
[user.id, *form_data.user_ids]
)
if existing_channel:
+ participant_ids = [
+ member.user_id
+ for member in Channels.get_members_by_channel_id(
+ existing_channel.id
+ )
+ ]
+ await emit_to_users(
+ "events:channel",
+ {"data": {"type": "channel:created"}},
+ participant_ids,
+ )
+ await enter_room_for_users(
+ f"channel:{existing_channel.id}", participant_ids
+ )
+
Channels.update_member_active_status(existing_channel.id, user.id, True)
return ChannelModel(**existing_channel.model_dump())
channel = Channels.insert_new_channel(form_data, user.id)
- return ChannelModel(**channel.model_dump())
+
+ if channel:
+ participant_ids = [
+ member.user_id
+ for member in Channels.get_members_by_channel_id(channel.id)
+ ]
+
+ await emit_to_users(
+ "events:channel",
+ {"data": {"type": "channel:created"}},
+ participant_ids,
+ )
+ await enter_room_for_users(f"channel:{channel.id}", participant_ids)
+
+ return ChannelModel(**channel.model_dump())
+ else:
+ raise Exception("Error creating channel")
except Exception as e:
log.exception(e)
raise HTTPException(
@@ -155,8 +286,8 @@ async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user
class ChannelFullResponse(ChannelResponse):
- user_ids: Optional[list[str]] = None # 'dm' channels only
- users: Optional[list[UserIdNameResponse]] = None # 'dm' channels only
+ user_ids: Optional[list[str]] = None # 'group'/'dm' channels only
+ users: Optional[list[UserIdNameStatusResponse]] = None # 'group'/'dm' channels only
last_read_at: Optional[int] = None # timestamp in epoch (time_ns)
unread_count: int = 0
@@ -173,7 +304,7 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
user_ids = None
users = None
- if channel.type == "dm":
+ if channel.type in ["group", "dm"]:
if not Channels.is_user_channel_member(channel.id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -182,8 +313,11 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
user_ids = [
member.user_id for member in Channels.get_members_by_channel_id(channel.id)
]
+
users = [
- UserIdNameResponse(**user.model_dump())
+ UserIdNameStatusResponse(
+ **{**user.model_dump(), "is_active": Users.is_user_active(user.id)}
+ )
for user in Users.get_users_by_user_ids(user_ids)
]
@@ -197,13 +331,13 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
**channel.model_dump(),
"user_ids": user_ids,
"users": users,
+ "is_manager": Channels.is_user_channel_manager(channel.id, user.id),
"write_access": True,
"user_count": len(user_ids),
"last_read_at": channel_member.last_read_at if channel_member else None,
"unread_count": unread_count,
}
)
-
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
@@ -228,6 +362,7 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
**channel.model_dump(),
"user_ids": user_ids,
"users": users,
+ "is_manager": Channels.is_user_channel_manager(channel.id, user.id),
"write_access": write_access or user.role == "admin",
"user_count": user_count,
"last_read_at": channel_member.last_read_at if channel_member else None,
@@ -265,17 +400,17 @@ async def get_channel_members_by_id(
page = max(1, page)
skip = (page - 1) * limit
- if channel.type == "dm":
+ if channel.type in ["group", "dm"]:
if not Channels.is_user_channel_member(channel.id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
+ if channel.type == "dm":
user_ids = [
member.user_id for member in Channels.get_members_by_channel_id(channel.id)
]
users = Users.get_users_by_user_ids(user_ids)
-
total = len(users)
return {
@@ -287,11 +422,8 @@ async def get_channel_members_by_id(
],
"total": total,
}
-
else:
- filter = {
- "roles": ["!pending"],
- }
+ filter = {}
if query:
filter["query"] = query
@@ -300,10 +432,16 @@ async def get_channel_members_by_id(
if direction:
filter["direction"] = direction
- permitted_ids = get_permitted_group_and_user_ids("read", channel.access_control)
- if permitted_ids:
- filter["user_ids"] = permitted_ids.get("user_ids")
- filter["group_ids"] = permitted_ids.get("group_ids")
+ if channel.type == "group":
+ filter["channel_id"] = channel.id
+ else:
+ filter["roles"] = ["!pending"]
+ permitted_ids = get_permitted_group_and_user_ids(
+ "read", channel.access_control
+ )
+ if permitted_ids:
+ filter["user_ids"] = permitted_ids.get("user_ids")
+ filter["group_ids"] = permitted_ids.get("group_ids")
result = Users.get_users(filter=filter, skip=skip, limit=limit)
@@ -351,6 +489,101 @@ async def update_is_active_member_by_id_and_user_id(
return True
+#################################################
+# AddMembersById
+#################################################
+
+
+class UpdateMembersForm(BaseModel):
+ user_ids: list[str] = []
+ group_ids: list[str] = []
+
+
+@router.post("/{id}/update/members/add")
+async def add_members_by_id(
+ request: Request,
+ id: str,
+ form_data: UpdateMembersForm,
+ user=Depends(get_verified_user),
+):
+ if user.role != "admin" and not has_permission(
+ user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=ERROR_MESSAGES.UNAUTHORIZED,
+ )
+
+ channel = Channels.get_channel_by_id(id)
+ if not channel:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+ )
+
+ if channel.user_id != user.id and user.role != "admin":
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
+
+ try:
+ memberships = Channels.add_members_to_channel(
+ channel.id, user.id, form_data.user_ids, form_data.group_ids
+ )
+
+ return memberships
+ except Exception as e:
+ log.exception(e)
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+ )
+
+
+#################################################
+#
+#################################################
+
+
+class RemoveMembersForm(BaseModel):
+ user_ids: list[str] = []
+
+
+@router.post("/{id}/update/members/remove")
+async def remove_members_by_id(
+ request: Request,
+ id: str,
+ form_data: RemoveMembersForm,
+ user=Depends(get_verified_user),
+):
+ if user.role != "admin" and not has_permission(
+ user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=ERROR_MESSAGES.UNAUTHORIZED,
+ )
+
+ channel = Channels.get_channel_by_id(id)
+ if not channel:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+ )
+
+ if channel.user_id != user.id and user.role != "admin":
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
+
+ try:
+ deleted = Channels.remove_members_from_channel(channel.id, form_data.user_ids)
+
+ return deleted
+ except Exception as e:
+ log.exception(e)
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+ )
+
+
############################
# UpdateChannelById
############################
@@ -358,14 +591,27 @@ async def update_is_active_member_by_id_and_user_id(
@router.post("/{id}/update", response_model=Optional[ChannelModel])
async def update_channel_by_id(
- id: str, form_data: ChannelForm, user=Depends(get_admin_user)
+ request: Request, id: str, form_data: ChannelForm, user=Depends(get_verified_user)
):
+ if user.role != "admin" and not has_permission(
+ user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=ERROR_MESSAGES.UNAUTHORIZED,
+ )
+
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
+ if channel.user_id != user.id and user.role != "admin":
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
+
try:
channel = Channels.update_channel_by_id(id, form_data)
return ChannelModel(**channel.model_dump())
@@ -382,13 +628,28 @@ async def update_channel_by_id(
@router.delete("/{id}/delete", response_model=bool)
-async def delete_channel_by_id(id: str, user=Depends(get_admin_user)):
+async def delete_channel_by_id(
+ request: Request, id: str, user=Depends(get_verified_user)
+):
+ if user.role != "admin" and not has_permission(
+ user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=ERROR_MESSAGES.UNAUTHORIZED,
+ )
+
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
+ if channel.user_id != user.id and user.role != "admin":
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
+
try:
Channels.delete_channel_by_id(id)
return True
@@ -418,7 +679,7 @@ async def get_channel_messages(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
- if channel.type == "dm":
+ if channel.type in ["group", "dm"]:
if not Channels.is_user_channel_member(channel.id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -481,7 +742,7 @@ async def get_pinned_channel_messages(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
- if channel.type == "dm":
+ if channel.type in ["group", "dm"]:
if not Channels.is_user_channel_member(channel.id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -735,7 +996,7 @@ async def new_message_handler(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
- if channel.type == "dm":
+ if channel.type in ["group", "dm"]:
if not Channels.is_user_channel_member(channel.id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -751,7 +1012,7 @@ async def new_message_handler(
try:
message = Messages.insert_new_message(form_data, channel.id, user.id)
if message:
- if channel.type == "dm":
+ if channel.type in ["group", "dm"]:
members = Channels.get_members_by_channel_id(channel.id)
for member in members:
if not member.is_active:
@@ -857,7 +1118,7 @@ async def get_channel_message(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
- if channel.type == "dm":
+ if channel.type in ["group", "dm"]:
if not Channels.is_user_channel_member(channel.id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -912,7 +1173,7 @@ async def pin_channel_message(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
- if channel.type == "dm":
+ if channel.type in ["group", "dm"]:
if not Channels.is_user_channel_member(channel.id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -975,7 +1236,7 @@ async def get_channel_thread_messages(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
- if channel.type == "dm":
+ if channel.type in ["group", "dm"]:
if not Channels.is_user_channel_member(channel.id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -1040,7 +1301,7 @@ async def update_message_by_id(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
- if channel.type == "dm":
+ if channel.type in ["group", "dm"]:
if not Channels.is_user_channel_member(channel.id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -1104,7 +1365,7 @@ async def add_reaction_to_message(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
- if channel.type == "dm":
+ if channel.type in ["group", "dm"]:
if not Channels.is_user_channel_member(channel.id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -1173,7 +1434,7 @@ async def remove_reaction_by_id_and_user_id_and_name(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
- if channel.type == "dm":
+ if channel.type in ["group", "dm"]:
if not Channels.is_user_channel_member(channel.id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -1256,7 +1517,7 @@ async def delete_message_by_id(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
- if channel.type == "dm":
+ if channel.type in ["group", "dm"]:
if not Channels.is_user_channel_member(channel.id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
diff --git a/backend/open_webui/routers/groups.py b/backend/open_webui/routers/groups.py
index b68db3a15e..05d52c5c7b 100755
--- a/backend/open_webui/routers/groups.py
+++ b/backend/open_webui/routers/groups.py
@@ -32,31 +32,17 @@ router = APIRouter()
@router.get("/", response_model=list[GroupResponse])
async def get_groups(share: Optional[bool] = None, user=Depends(get_verified_user)):
- if user.role == "admin":
- groups = Groups.get_groups()
- else:
- groups = Groups.get_groups_by_member_id(user.id)
- group_list = []
+ filter = {}
+ if user.role != "admin":
+ filter["member_id"] = user.id
- for group in groups:
- if share is not None:
- # Check if the group has data and a config with share key
- if (
- group.data
- and "share" in group.data.get("config", {})
- and group.data["config"]["share"] != share
- ):
- continue
+ if share is not None:
+ filter["share"] = share
- group_list.append(
- GroupResponse(
- **group.model_dump(),
- member_count=Groups.get_group_member_count_by_id(group.id),
- )
- )
+ groups = Groups.get_groups(filter=filter)
- return group_list
+ return groups
############################
diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py
index 654f11588a..46baa0eaea 100644
--- a/backend/open_webui/routers/knowledge.py
+++ b/backend/open_webui/routers/knowledge.py
@@ -550,7 +550,11 @@ def remove_file_from_knowledge_by_id(
try:
VECTOR_DB_CLIENT.delete(
collection_name=knowledge.id, filter={"file_id": form_data.file_id}
- )
+ ) # Remove by file_id first
+
+ VECTOR_DB_CLIENT.delete(
+ collection_name=knowledge.id, filter={"hash": file.hash}
+ ) # Remove by hash as well in case of duplicates
except Exception as e:
log.debug("This was most likely caused by bypassing embedding processing")
log.debug(e)
@@ -579,7 +583,6 @@ def remove_file_from_knowledge_by_id(
data["file_ids"] = file_ids
knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data)
-
if knowledge:
files = Files.get_file_metadatas_by_ids(file_ids)
diff --git a/backend/open_webui/routers/scim.py b/backend/open_webui/routers/scim.py
index b5d0e029ec..c2ee4d1c35 100644
--- a/backend/open_webui/routers/scim.py
+++ b/backend/open_webui/routers/scim.py
@@ -719,7 +719,7 @@ async def get_groups(
):
"""List SCIM Groups"""
# Get all groups
- groups_list = Groups.get_groups()
+ groups_list = Groups.get_all_groups()
# Apply pagination
total = len(groups_list)
diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py
index 7c4b801f4d..3c1bbb72a8 100644
--- a/backend/open_webui/routers/users.py
+++ b/backend/open_webui/routers/users.py
@@ -19,8 +19,9 @@ from open_webui.models.users import (
UserGroupIdsModel,
UserGroupIdsListResponse,
UserInfoListResponse,
- UserIdNameListResponse,
+ UserInfoListResponse,
UserRoleUpdateForm,
+ UserStatus,
Users,
UserSettings,
UserUpdateForm,
@@ -102,20 +103,31 @@ async def get_all_users(
return Users.get_users()
-@router.get("/search", response_model=UserIdNameListResponse)
+@router.get("/search", response_model=UserInfoListResponse)
async def search_users(
query: Optional[str] = None,
+ order_by: Optional[str] = None,
+ direction: Optional[str] = None,
+ page: Optional[int] = 1,
user=Depends(get_verified_user),
):
limit = PAGE_ITEM_COUNT
- page = 1 # Always return the first page for search
+ page = max(1, page)
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
+ filter = {}
+ if query:
+ filter["query"] = query
+ if order_by:
+ filter["order_by"] = order_by
+ if direction:
+ filter["direction"] = direction
+
return Users.get_users(filter=filter, skip=skip, limit=limit)
@@ -196,8 +208,9 @@ class ChatPermissions(BaseModel):
class FeaturesPermissions(BaseModel):
api_keys: bool = False
- folders: bool = True
notes: bool = True
+ channels: bool = True
+ folders: bool = True
direct_tool_servers: bool = False
web_search: bool = True
@@ -287,6 +300,43 @@ async def update_user_settings_by_session_user(
)
+############################
+# GetUserStatusBySessionUser
+############################
+
+
+@router.get("/user/status")
+async def get_user_status_by_session_user(user=Depends(get_verified_user)):
+ user = Users.get_user_by_id(user.id)
+ if user:
+ return user
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ERROR_MESSAGES.USER_NOT_FOUND,
+ )
+
+
+############################
+# UpdateUserStatusBySessionUser
+############################
+
+
+@router.post("/user/status/update")
+async def update_user_status_by_session_user(
+ form_data: UserStatus, user=Depends(get_verified_user)
+):
+ user = Users.get_user_by_id(user.id)
+ if user:
+ user = Users.update_user_status_by_id(user.id, form_data)
+ return user
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ERROR_MESSAGES.USER_NOT_FOUND,
+ )
+
+
############################
# GetUserInfoBySessionUser
############################
@@ -338,9 +388,10 @@ async def update_user_info_by_session_user(
############################
-class UserActiveResponse(BaseModel):
+class UserActiveResponse(UserStatus):
name: str
profile_image_url: Optional[str] = None
+
is_active: bool
model_config = ConfigDict(extra="allow")
@@ -365,8 +416,7 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
if user:
return UserActiveResponse(
**{
- "id": user.id,
- "name": user.name,
+ **user.model_dump(),
"is_active": Users.is_user_active(user_id),
}
)
diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py
index 84705648d9..638a89715a 100644
--- a/backend/open_webui/socket/main.py
+++ b/backend/open_webui/socket/main.py
@@ -253,6 +253,38 @@ def get_user_ids_from_room(room):
return active_user_ids
+async def emit_to_users(event: str, data: dict, user_ids: list[str]):
+ """
+ Send a message to specific users using their user:{id} rooms.
+
+ Args:
+ event (str): The event name to emit.
+ data (dict): The payload/data to send.
+ user_ids (list[str]): The target users' IDs.
+ """
+ try:
+ for user_id in user_ids:
+ await sio.emit(event, data, room=f"user:{user_id}")
+ except Exception as e:
+ log.debug(f"Failed to emit event {event} to users {user_ids}: {e}")
+
+
+async def enter_room_for_users(room: str, user_ids: list[str]):
+ """
+ Make all sessions of a user join a specific room.
+ Args:
+ room (str): The room to join.
+ user_ids (list[str]): The target user's IDs.
+ """
+ try:
+ for user_id in user_ids:
+ session_ids = get_session_ids_from_room(f"user:{user_id}")
+ for sid in session_ids:
+ await sio.enter_room(sid, room)
+ except Exception as e:
+ log.debug(f"Failed to make users {user_ids} join room {room}: {e}")
+
+
@sio.on("usage")
async def usage(sid, data):
if sid in SESSION_POOL:
@@ -309,11 +341,13 @@ async def user_join(sid, data):
)
await sio.enter_room(sid, f"user:{user.id}")
+
# Join all the channels
channels = Channels.get_channels_by_user_id(user.id)
log.debug(f"{channels=}")
for channel in channels:
await sio.enter_room(sid, f"channel:{channel.id}")
+
return {"id": user.id, "name": user.name}
diff --git a/backend/open_webui/utils/audit.py b/backend/open_webui/utils/audit.py
index 0cef3c91f8..dc1226a080 100644
--- a/backend/open_webui/utils/audit.py
+++ b/backend/open_webui/utils/audit.py
@@ -194,7 +194,7 @@ class AuditLoggingMiddleware:
auth_header = request.headers.get("Authorization")
try:
- user = get_current_user(
+ user = await get_current_user(
request, None, None, get_http_authorization_cred(auth_header)
)
return user
diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py
index 1f96bacdb4..bc5b741146 100644
--- a/backend/open_webui/utils/middleware.py
+++ b/backend/open_webui/utils/middleware.py
@@ -858,7 +858,7 @@ async def chat_image_generation_handler(
}
)
- system_message_content = f"