Coverage for slidge/db/models.py: 95%
529 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-10 12:26 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-10 12:26 +0000
1from __future__ import annotations
3import logging
4import warnings
5from collections.abc import Callable, Iterable
6from contextlib import nullcontext
7from datetime import UTC, date, datetime
8from enum import IntEnum
9from functools import lru_cache
10from pathlib import Path
11from typing import Any, Literal
12from xml.etree import ElementTree as ET
14import slixmpp.plugins.xep_0234.stanza
15import slixmpp.plugins.xep_0264.stanza
16import slixmpp.plugins.xep_0300.stanza
17import slixmpp.plugins.xep_0372.stanza
18import slixmpp.plugins.xep_0385.stanza
19import slixmpp.plugins.xep_0447.stanza
20import sqlalchemy as sa
21import sqlalchemy.orm
22from slixmpp import JID, Message
23from slixmpp.plugins.xep_0292.stanza import VCard4
24from slixmpp.plugins.xep_0297 import Forwarded
25from slixmpp.plugins.xep_0444 import XEP_0444
26from slixmpp.plugins.xep_0492.stanza import WhenLiteral
27from slixmpp.types import MucAffiliation, MucRole, PresenceShows
28from sqlalchemy import JSON, Column, ForeignKey, Index, Table, UniqueConstraint
29from sqlalchemy.orm import Mapped, mapped_column, relationship, sessionmaker
31from ..core import config
32from ..util.types import (
33 Avatar as AvatarPublicMetadata,
34)
35from ..util.types import (
36 AvatarMetadata,
37 ClientType,
38 Hat,
39 RoomType,
40)
41from .meta import Base, JSONSerializable, JSONSerializableTypes
44class ArchivedMessageSource(IntEnum):
45 """
46 Whether an archived message comes from ``LegacyRoom.backfill()`` or was received
47 as a "live" message.
48 """
50 LIVE = 1
51 BACKFILL = 2
54class _JidMixin:
55 jid_localpart: Mapped[str]
57 @property
58 def jid(self) -> JID:
59 return JID(f"{self.jid_localpart}@{config.JID}")
62class _EnsureLoadedMixin:
63 id: int | None
65 def ensure_loaded(
66 self,
67 group: str,
68 session_maker: sessionmaker[Any] | Callable[[], sa.orm.Session],
69 ) -> None:
70 if self.id is None:
71 return
73 if sa.orm.object_session(self) is not None:
74 return
76 state = sa.inspect(self)
77 if state is None:
78 return
80 group_keys = _group_keys(
81 self.__class__, # type:ignore[arg-type]
82 group,
83 )
85 unloaded = group_keys & state.unloaded
87 if not unloaded:
88 return
90 cls = self.__class__
91 stmt = sa.select(*(getattr(cls, key) for key in unloaded)).where(
92 cls.id == self.id # type:ignore[arg-type]
93 )
94 with session_maker() as session, session.no_autoflush:
95 row = session.execute(stmt).one()
97 for key, value in zip(unloaded, row):
98 self._set_committed_value(key, value)
100 def ensure_relation(
101 self,
102 relation: str,
103 session_maker: sessionmaker[Any] | Callable[[], sa.orm.Session],
104 ) -> None:
105 if self.id is None:
106 return
108 if sa.orm.object_session(self) is not None:
109 return
111 if self._is_loaded(relation):
112 return
114 fk = getattr(self, f"{relation}_id")
116 if fk is None:
117 self._set_committed_value(relation, None)
118 return
120 mapper = sa.inspect(type(self))
121 assert mapper is not None
122 model = mapper.relationships[relation].mapper.class_
124 with session_maker() as session, session.no_autoflush:
125 stored = session.execute(
126 sa.select(model).where(model.id == fk)
127 ).scalar_one_or_none()
128 self._set_committed_value(relation, stored)
130 def _is_unloaded(self, attr: str) -> bool:
131 return attr in sa.orm.attributes.instance_state(self).unloaded
133 def _is_loaded(self, attr: str) -> bool:
134 return not self._is_unloaded(attr)
136 def _set_committed_value(self, attr: str, value: object) -> None:
137 sa.orm.attributes.set_committed_value(self, attr, value)
140@lru_cache
141def _group_keys(cls: type[Base], group: str) -> frozenset[str]:
142 keys = frozenset(a.key for a in sa.inspect(cls).column_attrs if a.group == group)
143 if not keys:
144 raise ValueError(f"{cls.__name__} has no deferred group {group!r}")
145 return keys
148class GatewayUser(Base):
149 """
150 A user, registered to the gateway component.
151 """
153 __tablename__ = "user_account"
154 id: Mapped[int] = mapped_column(primary_key=True)
155 jid: Mapped[JID] = mapped_column(unique=True)
156 registration_date: Mapped[datetime] = mapped_column(
157 sa.DateTime, server_default=sa.func.now()
158 )
160 legacy_module_data: Mapped[JSONSerializable] = mapped_column(default={})
161 """
162 Arbitrary non-relational data that legacy modules can use
163 """
164 preferences: Mapped[JSONSerializable] = mapped_column(default={})
165 avatar_hash: Mapped[str | None] = mapped_column(default=None)
166 """
167 Hash of the user's avatar, to avoid re-publishing the same avatar on the
168 legacy network
169 """
171 contacts: Mapped[list[Contact]] = relationship(
172 back_populates="user", cascade="all, delete-orphan"
173 )
174 rooms: Mapped[list[Room]] = relationship(
175 back_populates="user", cascade="all, delete-orphan"
176 )
177 attachments: Mapped[list[Attachment]] = relationship(cascade="all, delete-orphan")
178 spaces: Mapped[list[Space]] = relationship(
179 back_populates="user", cascade="all, delete-orphan"
180 )
182 def __repr__(self) -> str:
183 return f"User(id={self.id!r}, jid={self.jid!r})"
185 def get(self, field: str, default: str = "") -> JSONSerializableTypes:
186 # """
187 # Get fields from the registration form (required to comply with slixmpp backend protocol)
188 #
189 # :param field: Name of the field
190 # :param default: Default value to return if the field is not present
191 #
192 # :return: Value of the field
193 # """
194 return self.legacy_module_data.get(field, default)
196 @property
197 def registration_form(self) -> dict[str | int, Any]:
198 # Kept for retrocompat, should be
199 # FIXME: delete me
200 warnings.warn(
201 "GatewayUser.registration_form is deprecated.", DeprecationWarning
202 )
203 return self.legacy_module_data
206class Avatar(Base):
207 """
208 Avatars of contacts, rooms and participants.
210 To comply with XEPs, we convert them all to PNG before storing them.
211 """
213 __tablename__ = "avatar"
215 id: Mapped[int] = mapped_column(primary_key=True)
217 hash: Mapped[str] = mapped_column(unique=True, nullable=False)
218 height: Mapped[int] = mapped_column()
219 width: Mapped[int] = mapped_column()
221 legacy_id: Mapped[str | None] = mapped_column(unique=True, nullable=True)
223 http_id: Mapped[str | None] = mapped_column(nullable=True)
224 http_type: Mapped[str | None] = mapped_column(nullable=True)
225 http_bytes: Mapped[int | None] = mapped_column(nullable=True)
226 http_url: Mapped[str | None] = mapped_column(nullable=True)
227 http_height: Mapped[int | None] = mapped_column(nullable=True)
228 http_width: Mapped[int | None] = mapped_column(nullable=True)
230 # this is only used when avatars are available as HTTP URLs and do not
231 # have a legacy_id
232 url: Mapped[str | None] = mapped_column(unique=True, default=None)
233 etag: Mapped[str | None] = mapped_column(default=None)
234 last_modified: Mapped[str | None] = mapped_column(default=None)
236 contacts: Mapped[list[Contact]] = relationship(back_populates="avatar")
237 rooms: Mapped[list[Room]] = relationship(back_populates="avatar")
239 def set_http_metadata(self, meta: AvatarMetadata | None) -> None:
240 if meta is None:
241 return
242 self.http_id = meta.id
243 self.http_bytes = meta.bytes
244 self.http_type = f"image/{meta.type}"
245 self.http_url = meta.url
246 self.http_height = meta.height
247 self.http_width = meta.width
249 def to_meta(self) -> AvatarPublicMetadata:
250 return AvatarPublicMetadata(unique_id=self.legacy_id, url=self.url)
253space_owner_association = Table(
254 "space_owner_association",
255 Base.metadata,
256 Column("space_id", ForeignKey("space.id"), primary_key=True),
257 Column("contact_id", ForeignKey("contact.id"), primary_key=True),
258)
261class _BatchUpdateMixin:
262 def _normalize_stored_updates(self, values: dict[str, object]) -> dict[str, object]:
263 return values
266class _Presence:
267 online: Mapped[bool] = mapped_column(
268 nullable=True,
269 default=True,
270 deferred=True,
271 deferred_group="presence",
272 server_default="true",
273 )
274 last_seen: Mapped[datetime | None] = mapped_column(
275 nullable=True, deferred=True, deferred_group="presence"
276 )
277 pstatus: Mapped[str | None] = mapped_column(
278 nullable=True, deferred=True, deferred_group="presence"
279 )
280 pshow: Mapped[PresenceShows | None] = mapped_column(
281 nullable=True, deferred=True, deferred_group="presence"
282 )
285class Contact(Base, _Presence, _EnsureLoadedMixin, _JidMixin, _BatchUpdateMixin):
286 """
287 Legacy contacts
288 """
290 __tablename__ = "contact"
291 __table_args__ = (
292 UniqueConstraint(
293 "user_account_id", "legacy_id", name="uq_contact_user_account_id_legacy_id"
294 ),
295 UniqueConstraint(
296 "user_account_id",
297 "jid_localpart",
298 name="uq_contact_user_account_id_jid_localpart",
299 ),
300 )
302 id: Mapped[int] = mapped_column(primary_key=True)
303 user_account_id: Mapped[int] = mapped_column(
304 ForeignKey("user_account.id"), deferred=True
305 )
306 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="contacts")
307 legacy_id: Mapped[str] = mapped_column(nullable=False)
309 jid_localpart: Mapped[str] = mapped_column(nullable=False)
311 avatar_id: Mapped[int | None] = mapped_column(
312 ForeignKey("avatar.id"), nullable=True
313 )
314 avatar: Mapped[Avatar | None] = relationship(back_populates="contacts")
316 nick: Mapped[str | None] = mapped_column(nullable=True)
318 is_friend: Mapped[bool] = mapped_column(default=False)
319 added_to_roster: Mapped[bool] = mapped_column(default=False)
320 sent_order: Mapped[list[ContactSent]] = relationship(
321 back_populates="contact", cascade="all, delete-orphan"
322 )
324 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(
325 default=None, nullable=True
326 )
327 more_metadata_fetched: Mapped[bool] = mapped_column(default=False)
328 # vcard fields
329 full_name: Mapped[str | None] = mapped_column(
330 nullable=True, deferred=True, deferred_group="vcard"
331 )
332 birthday: Mapped[date | None] = mapped_column(
333 nullable=True, deferred=True, deferred_group="vcard"
334 )
335 phones: Mapped[list[str] | None] = mapped_column(
336 JSON, nullable=True, deferred=True, deferred_group="vcard"
337 )
338 bio: Mapped[str | None] = mapped_column(
339 nullable=True, deferred=True, deferred_group="vcard"
340 )
341 url: Mapped[str | None] = mapped_column(
342 nullable=True, deferred=True, deferred_group="vcard"
343 )
344 email: Mapped[str | None] = mapped_column(
345 nullable=True, deferred=True, deferred_group="vcard"
346 )
347 country: Mapped[str | None] = mapped_column(
348 nullable=True, deferred=True, deferred_group="vcard"
349 )
350 locality: Mapped[str | None] = mapped_column(
351 nullable=True, deferred=True, deferred_group="vcard"
352 )
353 pronouns: Mapped[str | None] = mapped_column(
354 nullable=True, deferred=True, deferred_group="vcard"
355 )
357 # No "merge" cascade: committing a Contact must never write its Participants.
358 # Otherwise a stale, transient Participant left in this (loaded) collection
359 # by the back_populates of Participant.contact gets INSERTed a second time on
360 # the next Contact.commit().
361 participants: Mapped[list[Participant]] = relationship(
362 back_populates="contact", cascade="save-update"
363 )
365 client_type: Mapped[ClientType] = mapped_column(nullable=False, default="pc")
367 messages: Mapped[list[DirectMessages]] = relationship(cascade="all, delete-orphan")
368 threads: Mapped[list[DirectThreads]] = relationship(cascade="all, delete-orphan")
370 spaces_created: Mapped[list[Space]] = relationship(
371 back_populates="creator",
372 cascade="all, delete-orphan",
373 )
374 spaces_owned: Mapped[list[Space]] = relationship(
375 back_populates="owners",
376 secondary=space_owner_association,
377 )
379 last_sent_msg_legacy_id: Mapped[str | None] = mapped_column(
380 deferred=True, deferred_group="last_msg"
381 )
382 last_sent_msg_date: Mapped[datetime | None] = mapped_column(
383 deferred=True, deferred_group="last_msg"
384 )
386 def vcard(self, domain: str) -> VCard4:
387 vcard = VCard4()
388 vcard.add_impp(f"xmpp:{self.jid_localpart}@{domain}")
390 if n := self.nick:
391 vcard.add_nickname(n)
392 if self.full_name:
393 vcard["full_name"] = self.full_name
394 elif n:
395 vcard["full_name"] = n
397 if self.birthday:
398 vcard["birthday"] = self.birthday
400 if self.bio:
401 vcard.add_note(self.bio)
402 if self.url:
403 vcard.add_url(self.url)
404 if self.email:
405 vcard.add_email(self.email)
407 if self.phones:
408 for p in self.phones:
409 vcard.add_tel(p)
410 if self.country or self.locality:
411 vcard.add_address(self.country, self.locality)
412 if self.pronouns:
413 vcard["pronouns"]["text"] = self.pronouns
415 return vcard
417 def load_participants(
418 self, orm: sessionmaker[Any] | Callable[[], sa.orm.Session]
419 ) -> None:
420 if self._is_loaded("participants"):
421 return
423 stmt = (
424 sa.select(Participant)
425 .options(sa.orm.selectinload(Participant.room))
426 .where(Participant.contact_id == self.id)
427 )
429 existing_session = sa.orm.object_session(self)
430 ctx = nullcontext(existing_session) if existing_session is not None else orm()
431 with ctx as session:
432 parts = session.execute(stmt).scalars()
433 self._set_committed_value("participants", parts)
435 def get_participants_for_avatar_propagation(
436 self, session_maker: sessionmaker[Any] | Callable[[], sa.orm.Session]
437 ) -> Iterable[Participant]:
438 with session_maker() as orm:
439 yield from orm.scalars(
440 sa.Select(Participant)
441 .join(Participant.room)
442 .where(
443 Participant.contact_id == self.id,
444 Room.user_resources.is_not(None),
445 )
446 .options(sa.orm.undefer_group("presence"))
447 )
450class ContactSent(Base):
451 """
452 Keep track of XMPP msg ids sent by a specific contact for networks in which
453 all messages need to be marked as read.
455 (XMPP displayed markers convey a "read up to here" semantic.)
456 """
458 __tablename__ = "contact_sent"
459 __table_args__ = (
460 UniqueConstraint(
461 "contact_id", "msg_id", name="uq_contact_sent_contact_id_msg_id"
462 ),
463 )
465 id: Mapped[int] = mapped_column(primary_key=True)
466 contact_id: Mapped[int] = mapped_column(ForeignKey("contact.id"))
467 contact: Mapped[Contact] = relationship(back_populates="sent_order")
468 msg_id: Mapped[str] = mapped_column()
471class Room(Base, _EnsureLoadedMixin, _JidMixin, _BatchUpdateMixin):
472 """
473 Legacy room
474 """
476 __table_args__ = (
477 UniqueConstraint(
478 "user_account_id", "legacy_id", name="uq_room_user_account_id_legacy_id"
479 ),
480 UniqueConstraint(
481 "user_account_id",
482 "jid_localpart",
483 name="uq_room_user_account_id_jid_localpart",
484 ),
485 )
487 __tablename__ = "room"
488 id: Mapped[int] = mapped_column(primary_key=True)
489 user_account_id: Mapped[int] = mapped_column(
490 ForeignKey("user_account.id"), deferred=True
491 )
492 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="rooms")
493 legacy_id: Mapped[str] = mapped_column(nullable=False)
495 jid_localpart: Mapped[str] = mapped_column(nullable=False)
497 avatar_id: Mapped[int | None] = mapped_column(
498 ForeignKey("avatar.id"), nullable=True
499 )
500 avatar: Mapped[Avatar | None] = relationship(back_populates="rooms")
502 name: Mapped[str | None] = mapped_column(nullable=True)
504 description: Mapped[str | None] = mapped_column(
505 nullable=True, deferred=True, deferred_group="disco"
506 )
507 n_participants: Mapped[int | None] = mapped_column(
508 default=None, deferred=True, deferred_group="disco"
509 )
511 subject: Mapped[str | None] = mapped_column(
512 nullable=True, deferred=True, deferred_group="subject"
513 )
514 subject_date: Mapped[datetime | None] = mapped_column(
515 nullable=True, deferred=True, deferred_group="subject"
516 )
517 subject_setter: Mapped[str | None] = mapped_column(
518 nullable=True, deferred=True, deferred_group="subject"
519 )
521 type: Mapped[RoomType] = mapped_column(default=RoomType.CHANNEL)
523 user_nick: Mapped[str | None] = mapped_column()
524 user_resources: Mapped[str | None] = mapped_column(nullable=True)
526 participants_filled: Mapped[bool] = mapped_column(default=False)
527 history_filled: Mapped[bool] = mapped_column(default=False)
529 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(default=None)
531 participants: Mapped[list[Participant]] = relationship(
532 back_populates="room",
533 primaryjoin="Participant.room_id == Room.id",
534 cascade="all, delete-orphan",
535 )
537 archive: Mapped[list[ArchivedMessage]] = relationship(cascade="all, delete-orphan")
539 space_id: Mapped[int | None] = mapped_column(ForeignKey("space.id"), nullable=True)
540 space: Mapped[Space | None] = relationship(back_populates="rooms")
542 notify_settings: Mapped[WhenLiteral | None] = mapped_column(
543 nullable=True, deferred_group="bookmarks"
544 )
545 pinned: Mapped[bool | None] = mapped_column(
546 nullable=True, deferred_group="bookmarks"
547 )
549 def load_participants(
550 self,
551 orm: sessionmaker[Any] | Callable[[], sa.orm.Session],
552 with_presence: bool = False,
553 ) -> None:
554 log.debug("Loading participants with_presence=%s", with_presence)
555 if self._is_loaded("participants") and all(
556 participant._is_loaded("contact") for participant in self.participants
557 ):
558 if with_presence:
559 if all(
560 participant.contact._is_loaded("pshow")
561 and participant.contact._is_loaded("avatar")
562 for participant in self.participants
563 if participant.contact
564 ):
565 log.debug("No need to load participants, presences are here")
566 return
567 else:
568 log.debug("No need to load participants")
569 return
571 log.debug("Loading participants for real")
573 contact_loader = sa.orm.joinedload(Participant.contact)
574 if with_presence:
575 contact_loader = contact_loader.undefer_group("presence").joinedload(
576 Contact.avatar
577 )
578 stmt = (
579 sa.select(Participant)
580 .where(Participant.room_id == self.id)
581 .options(contact_loader)
582 )
584 existing_session = sa.orm.object_session(self)
585 ctx = nullcontext(existing_session) if existing_session is not None else orm()
586 with ctx as session:
587 parts = session.execute(stmt).scalars()
588 self._set_committed_value("participants", parts)
590 def update_participant(self, part: Participant) -> None:
591 if self._is_unloaded("participants"):
592 return
594 self._set_committed_value(
595 "participants", [p for p in self.participants if p.id != part.id] + [part]
596 )
598 def delete_participant(self, part: Participant) -> None:
599 if self._is_unloaded("participants"):
600 return
601 self._set_committed_value(
602 "participants", [p for p in self.participants if p.id != part.id]
603 )
606class ArchivedMessage(Base):
607 """
608 Messages of rooms, that we store to act as a MAM server
609 """
611 __tablename__ = "mam"
612 __table_args__ = (
613 UniqueConstraint("room_id", "stanza_id", name="uq_mam_room_id_stanza_id"),
614 Index("ix_mam_room_id_legacy_id", "room_id", "legacy_id"),
615 Index("ix_mam_room_id_timestamp", "room_id", "timestamp"),
616 )
618 id: Mapped[int] = mapped_column(primary_key=True)
619 room_id: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
620 room: Mapped[Room] = relationship(lazy=True, back_populates="archive")
622 stanza_id: Mapped[str] = mapped_column(nullable=False)
623 origin_id: Mapped[str | None] = mapped_column(nullable=True)
624 timestamp: Mapped[datetime] = mapped_column(nullable=False)
626 author_resource: Mapped[str | None] = mapped_column(nullable=True)
627 author_affiliation: Mapped[str | None] = mapped_column()
628 author_role: Mapped[str | None] = mapped_column()
629 author_jid_localpart: Mapped[str | None] = mapped_column()
631 from_user: Mapped[bool] = mapped_column()
633 occupant_id: Mapped[str | None] = mapped_column()
635 source: Mapped[ArchivedMessageSource] = mapped_column(nullable=False)
636 legacy_id: Mapped[str | None] = mapped_column(nullable=True)
637 thread_legacy_id: Mapped[str | None] = mapped_column(nullable=True)
639 # payload is for XML we don't have dedicated columns for
640 payload: Mapped[str | None] = mapped_column(nullable=True)
642 displayed_id: Mapped[str | None] = mapped_column(nullable=True)
643 reaction_id: Mapped[str | None] = mapped_column(nullable=True)
644 reaction_emojis: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
645 body: Mapped[str | None] = mapped_column(nullable=True)
646 thread: Mapped[str | None] = mapped_column(nullable=True)
648 displayed_by_user: Mapped[bool] = mapped_column(default=False, nullable=True)
650 def stanza(
651 self, ns: str = "jabber:client", user_jid: JID | str | None = None
652 ) -> Message:
653 msg = Message(
654 stype="groupchat",
655 sfrom=f"{self.room.jid}/{self.author_resource}"
656 if self.author_resource
657 else self.room.jid,
658 )
659 if ns != "jabber:client":
660 msg.xml.tag = f"{{{ns}}}message"
661 msg.namespace = ns
663 msg["stanza_id"]["id"] = self.stanza_id
664 msg["stanza_id"]["by"] = self.room.jid
666 if self.origin_id:
667 msg["id"] = msg["origin_id"]["id"] = self.origin_id
668 else:
669 msg["id"] = self.stanza_id
671 if self.occupant_id:
672 msg["occupant-id"]["id"] = self.occupant_id
673 elif self.author_jid_localpart:
674 msg["occupant-id"]["id"] = f"{self.author_jid_localpart}@{config.JID}"
675 else:
676 log.warning("Cannot set an occupant ID for this archived message")
678 if self.author_affiliation:
679 msg["muc"]["affiliation"] = self.author_affiliation
680 if self.author_role:
681 msg["muc"]["role"] = self.author_role
682 if self.from_user:
683 msg["muc"]["jid"] = str(user_jid)
684 elif self.author_jid_localpart:
685 msg["muc"]["jid"] = JID(f"{self.author_jid_localpart}@{config.JID}")
687 if self.payload:
688 for child in ET.fromstring(f'<x xmlns="{ns}">{self.payload}</x>'):
689 msg.append(child)
690 if self.displayed_id:
691 msg["displayed"]["id"] = self.displayed_id
692 if self.reaction_id:
693 XEP_0444.set_reactions(msg, self.reaction_id, self.reaction_emojis or [])
694 if self.body:
695 msg["body"] = self.body
696 if self.thread:
697 msg["thread"] = self.thread
699 return msg
701 def forwarded(self, user_jid: JID | str) -> Forwarded:
702 forwarded = Forwarded()
703 forwarded["delay"]["stamp"] = self.timestamp.replace(tzinfo=UTC)
704 forwarded.append(self.stanza(user_jid=user_jid))
705 return forwarded
708class _LegacyToXmppIdsBase:
709 """
710 XMPP-client generated IDs, and mapping to the corresponding legacy IDs.
712 A single legacy ID can map to several XMPP ids.
713 """
715 id: Mapped[int] = mapped_column(primary_key=True)
716 legacy_id: Mapped[str] = mapped_column(nullable=False)
717 xmpp_id: Mapped[str] = mapped_column(nullable=False)
720class DirectMessages(_LegacyToXmppIdsBase, Base):
721 __tablename__ = "direct_msg"
722 __table_args__ = (Index("ix_direct_msg_legacy_id", "legacy_id", "foreign_key"),)
723 foreign_key: Mapped[int] = mapped_column(ForeignKey("contact.id"), nullable=False)
726class DirectThreads(_LegacyToXmppIdsBase, Base):
727 __tablename__ = "direct_thread"
728 __table_args__ = (Index("ix_direct_direct_thread_id", "legacy_id", "foreign_key"),)
729 foreign_key: Mapped[int] = mapped_column(ForeignKey("contact.id"), nullable=False)
732class Attachment(Base):
733 """
734 Legacy attachments
735 """
737 __tablename__ = "attachment"
738 __table_args__ = (
739 UniqueConstraint(
740 "user_account_id",
741 "legacy_file_id",
742 name="uq_attachment_user_account_id_legacy_file_id",
743 ),
744 )
746 id: Mapped[int] = mapped_column(primary_key=True)
747 user_account_id: Mapped[int] = mapped_column(
748 ForeignKey("user_account.id"), deferred=True
749 )
750 user: Mapped[GatewayUser] = relationship(back_populates="attachments")
752 legacy_file_id: Mapped[str | None] = mapped_column(index=True, nullable=True)
753 url: Mapped[str] = mapped_column(index=True, nullable=False)
755 size: Mapped[int | None] = mapped_column(nullable=True)
756 sha256: Mapped[str | None] = mapped_column(nullable=True)
758 thumbhash: Mapped[str | None] = mapped_column(nullable=True)
759 width: Mapped[int | None] = mapped_column(nullable=True)
760 height: Mapped[int | None] = mapped_column(nullable=True)
761 content_type: Mapped[str | None] = mapped_column(nullable=True)
762 desc: Mapped[str | None] = mapped_column(nullable=True)
763 name: Mapped[str | None] = mapped_column(nullable=True)
764 date: Mapped[datetime | None] = mapped_column(nullable=True)
765 disposition: Mapped[Literal["inline", "attachment"] | None] = mapped_column(
766 nullable=True
767 )
769 def _hash(self) -> slixmpp.plugins.xep_0300.stanza.Hash | None:
770 if self.sha256 is None:
771 return None
772 hash_elem = slixmpp.plugins.xep_0300.stanza.Hash()
773 hash_elem["algo"] = "sha-256"
774 hash_elem["value"] = self.sha256
775 return hash_elem
777 def _thumbnail(self) -> slixmpp.plugins.xep_0264.stanza.Thumbnail | None:
778 if self.thumbhash is None:
779 return None
780 thumbnail = slixmpp.plugins.xep_0264.stanza.Thumbnail()
781 thumbnail["width"] = self.width
782 thumbnail["height"] = self.height
783 thumbnail["media-type"] = "image/thumbhash"
784 thumbnail["uri"] = "data:image/thumbhash;base64," + self.thumbhash
785 return thumbnail
787 def sims(self) -> slixmpp.plugins.xep_0372.stanza.Reference:
788 ref = slixmpp.plugins.xep_0372.stanza.Reference()
789 ref["type"] = "data"
790 ref["uri"] = self.url
792 sims = slixmpp.plugins.xep_0385.stanza.Sims()
793 sims["sources"].append(ref)
794 sims.enable("file")
795 if self.content_type is not None:
796 sims["file"]["media-type"] = self.content_type
797 if self.desc is not None:
798 sims["file"]["desc"] = self.desc
799 if self.size is not None:
800 sims["file"]["size"] = self.size
801 if self.name is not None:
802 sims["file"]["name"] = self.name
803 if self.date is not None:
804 sims["file"]["date"] = self.date
805 if self.thumbhash is not None:
806 sims["file"].append(self._thumbnail())
807 if self.sha256 is not None:
808 sims["file"].append(self._hash())
810 wrapper = slixmpp.plugins.xep_0372.stanza.Reference()
811 wrapper["type"] = "data"
812 wrapper.append(sims)
814 return wrapper
816 def sfs(self) -> slixmpp.plugins.xep_0447.stanza.StatelessFileSharing:
817 ref = slixmpp.plugins.xep_0447.stanza.UrlData()
818 ref["target"] = self.url
820 sfs = slixmpp.plugins.xep_0447.stanza.StatelessFileSharing()
821 sfs["sources"].append(ref)
822 sfs.enable("file")
823 if self.content_type is not None:
824 sfs["file"]["media-type"] = self.content_type
825 if self.name is not None:
826 sfs["file"]["name"] = self.name
827 if self.desc is not None:
828 sfs["file"]["desc"] = self.desc
829 if self.disposition is not None:
830 sfs["disposition"] = self.disposition
831 else:
832 del sfs["disposition"]
833 if self.date is not None:
834 sfs["file"]["date"] = self.date
835 if self.size is not None:
836 sfs["file"]["size"] = self.size
837 if self.width is not None:
838 sfs["file"]["width"] = self.width
839 if self.height is not None:
840 sfs["file"]["height"] = self.height
841 if self.thumbhash is not None:
842 sfs["file"].append(self._thumbnail())
843 if self.sha256 is not None:
844 sfs["file"].append(self._hash())
846 return sfs
848 @property
849 def local_path(self) -> Path:
850 assert config.NO_UPLOAD_PATH is not None
851 assert config.NO_UPLOAD_URL_PREFIX is not None
852 normalized_prefix = config.NO_UPLOAD_URL_PREFIX.rstrip("/")
853 relative = self.url.removeprefix(normalized_prefix).lstrip("/")
854 return Path(config.NO_UPLOAD_PATH) / relative
857class Participant(_EnsureLoadedMixin, _Presence, _BatchUpdateMixin, Base):
858 __tablename__ = "participant"
859 __table_args__ = (
860 UniqueConstraint(
861 "room_id", "_resource", name="uq_participant_room_id__resource"
862 ),
863 UniqueConstraint(
864 "room_id", "contact_id", name="uq_participant_room_id_contact_id"
865 ),
866 UniqueConstraint(
867 "room_id", "occupant_id", name="uq_participant_room_id_occupant_id"
868 ),
869 )
871 id: Mapped[int] = mapped_column(primary_key=True)
873 room_id: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
874 room: Mapped[Room] = relationship(
875 back_populates="participants", primaryjoin=Room.id == room_id
876 )
878 contact_id: Mapped[int | None] = mapped_column(
879 ForeignKey("contact.id"), nullable=True
880 )
881 contact: Mapped[Contact | None] = relationship(
882 lazy=True, back_populates="participants"
883 )
885 occupant_id: Mapped[str | None] = mapped_column(nullable=True)
887 is_user: Mapped[bool] = mapped_column(default=False)
889 _affiliation: Mapped[MucAffiliation | None] = mapped_column(nullable=True)
890 _role: Mapped[MucRole | None] = mapped_column(nullable=True)
892 _resource: Mapped[str | None] = mapped_column(nullable=True)
893 nickname: Mapped[str] = mapped_column(nullable=False, default=None)
895 hats: Mapped[list[Hat]] = mapped_column(JSON, default=list)
897 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(default=None)
899 @property
900 def resource(self) -> str:
901 return self._resource or self.nickname
903 @resource.setter
904 def resource(self, resource: str) -> None:
905 self._resource = None if resource == self.nickname else resource
907 @property
908 def affiliation(self) -> MucAffiliation:
909 return self._affiliation or "member"
911 @affiliation.setter
912 def affiliation(self, affiliation: MucAffiliation) -> None:
913 self._affiliation = None if affiliation == "member" else affiliation
915 @property
916 def role(self) -> MucRole:
917 return self._role or "participant"
919 @role.setter
920 def role(self, role: MucRole) -> None:
921 self._role = None if role == "participant" else role
923 def _normalize_stored_updates(self, values: dict[str, object]) -> dict[str, object]:
924 values = dict(values)
926 if resource := values.pop("resource", None):
927 values["_resource"] = None if resource == self.nickname else resource
929 if affiliation := values.pop("affiliation", None):
930 values["_affiliation"] = None if affiliation == "member" else affiliation
932 if role := values.pop("role", None):
933 values["_role"] = None if role == "participant" else role
935 return values
938class Bob(Base):
939 __tablename__ = "bob"
941 id: Mapped[int] = mapped_column(primary_key=True)
942 file_name: Mapped[str] = mapped_column(nullable=False)
944 sha_1: Mapped[str] = mapped_column(nullable=False, unique=True)
945 sha_256: Mapped[str] = mapped_column(nullable=False, unique=True)
946 sha_512: Mapped[str] = mapped_column(nullable=False, unique=True)
948 content_type: Mapped[str] = mapped_column(nullable=False)
951class Space(_EnsureLoadedMixin, Base, _BatchUpdateMixin):
952 __tablename__ = "space"
953 __table_args__ = (
954 UniqueConstraint(
955 "user_account_id", "legacy_id", name="uq_space_user_account_id_legacy_id"
956 ),
957 )
959 id: Mapped[int] = mapped_column(primary_key=True)
961 user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
962 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="spaces")
964 legacy_id: Mapped[str] = mapped_column(nullable=False)
965 name: Mapped[str | None] = mapped_column(nullable=True)
966 description: Mapped[str | None] = mapped_column(nullable=True)
967 member_count: Mapped[int | None] = mapped_column(nullable=True)
969 creator_id: Mapped[int | None] = mapped_column(
970 ForeignKey("contact.id"), nullable=True
971 )
972 creator: Mapped[Contact | None] = relationship(back_populates="spaces_created")
974 owners: Mapped[list[Contact]] = relationship(
975 back_populates="spaces_owned",
976 secondary=space_owner_association,
977 )
979 rooms: Mapped[list[Room]] = relationship()
981 avatar_id: Mapped[int | None] = mapped_column(
982 ForeignKey("avatar.id"), nullable=True
983 )
984 avatar: Mapped[Avatar | None] = relationship(Avatar, foreign_keys=avatar_id)
986 banner_id: Mapped[int | None] = mapped_column(
987 ForeignKey("avatar.id"), nullable=True
988 )
989 banner: Mapped[Avatar | None] = relationship(Avatar, foreign_keys=banner_id)
991 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(default=None)
993 def load_rooms(
994 self,
995 session_maker: sessionmaker[Any] | Callable[[], sa.orm.Session],
996 filt: list[str] | None = None,
997 ) -> None:
998 with session_maker() as session:
999 stmt = sa.select(Room).where(Room.space_id == self.id)
1000 if filt is not None:
1001 stmt = stmt.where(Room.jid_localpart.in_(filt))
1002 rooms = list(session.scalars(stmt).all())
1004 self._set_committed_value("rooms", rooms)
1007log = logging.getLogger(__name__)