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

1from __future__ import annotations 

2 

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 

13 

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 

30 

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 

42 

43 

44class ArchivedMessageSource(IntEnum): 

45 """ 

46 Whether an archived message comes from ``LegacyRoom.backfill()`` or was received 

47 as a "live" message. 

48 """ 

49 

50 LIVE = 1 

51 BACKFILL = 2 

52 

53 

54class _JidMixin: 

55 jid_localpart: Mapped[str] 

56 

57 @property 

58 def jid(self) -> JID: 

59 return JID(f"{self.jid_localpart}@{config.JID}") 

60 

61 

62class _EnsureLoadedMixin: 

63 id: int | None 

64 

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 

72 

73 if sa.orm.object_session(self) is not None: 

74 return 

75 

76 state = sa.inspect(self) 

77 if state is None: 

78 return 

79 

80 group_keys = _group_keys( 

81 self.__class__, # type:ignore[arg-type] 

82 group, 

83 ) 

84 

85 unloaded = group_keys & state.unloaded 

86 

87 if not unloaded: 

88 return 

89 

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

96 

97 for key, value in zip(unloaded, row): 

98 self._set_committed_value(key, value) 

99 

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 

107 

108 if sa.orm.object_session(self) is not None: 

109 return 

110 

111 if self._is_loaded(relation): 

112 return 

113 

114 fk = getattr(self, f"{relation}_id") 

115 

116 if fk is None: 

117 self._set_committed_value(relation, None) 

118 return 

119 

120 mapper = sa.inspect(type(self)) 

121 assert mapper is not None 

122 model = mapper.relationships[relation].mapper.class_ 

123 

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) 

129 

130 def _is_unloaded(self, attr: str) -> bool: 

131 return attr in sa.orm.attributes.instance_state(self).unloaded 

132 

133 def _is_loaded(self, attr: str) -> bool: 

134 return not self._is_unloaded(attr) 

135 

136 def _set_committed_value(self, attr: str, value: object) -> None: 

137 sa.orm.attributes.set_committed_value(self, attr, value) 

138 

139 

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 

146 

147 

148class GatewayUser(Base): 

149 """ 

150 A user, registered to the gateway component. 

151 """ 

152 

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 ) 

159 

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

170 

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 ) 

181 

182 def __repr__(self) -> str: 

183 return f"User(id={self.id!r}, jid={self.jid!r})" 

184 

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) 

195 

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 

204 

205 

206class Avatar(Base): 

207 """ 

208 Avatars of contacts, rooms and participants. 

209 

210 To comply with XEPs, we convert them all to PNG before storing them. 

211 """ 

212 

213 __tablename__ = "avatar" 

214 

215 id: Mapped[int] = mapped_column(primary_key=True) 

216 

217 hash: Mapped[str] = mapped_column(unique=True, nullable=False) 

218 height: Mapped[int] = mapped_column() 

219 width: Mapped[int] = mapped_column() 

220 

221 legacy_id: Mapped[str | None] = mapped_column(unique=True, nullable=True) 

222 

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) 

229 

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) 

235 

236 contacts: Mapped[list[Contact]] = relationship(back_populates="avatar") 

237 rooms: Mapped[list[Room]] = relationship(back_populates="avatar") 

238 

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 

248 

249 def to_meta(self) -> AvatarPublicMetadata: 

250 return AvatarPublicMetadata(unique_id=self.legacy_id, url=self.url) 

251 

252 

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) 

259 

260 

261class _BatchUpdateMixin: 

262 def _normalize_stored_updates(self, values: dict[str, object]) -> dict[str, object]: 

263 return values 

264 

265 

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 ) 

283 

284 

285class Contact(Base, _Presence, _EnsureLoadedMixin, _JidMixin, _BatchUpdateMixin): 

286 """ 

287 Legacy contacts 

288 """ 

289 

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 ) 

301 

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) 

308 

309 jid_localpart: Mapped[str] = mapped_column(nullable=False) 

310 

311 avatar_id: Mapped[int | None] = mapped_column( 

312 ForeignKey("avatar.id"), nullable=True 

313 ) 

314 avatar: Mapped[Avatar | None] = relationship(back_populates="contacts") 

315 

316 nick: Mapped[str | None] = mapped_column(nullable=True) 

317 

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 ) 

323 

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 ) 

356 

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 ) 

364 

365 client_type: Mapped[ClientType] = mapped_column(nullable=False, default="pc") 

366 

367 messages: Mapped[list[DirectMessages]] = relationship(cascade="all, delete-orphan") 

368 threads: Mapped[list[DirectThreads]] = relationship(cascade="all, delete-orphan") 

369 

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 ) 

378 

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 ) 

385 

386 def vcard(self, domain: str) -> VCard4: 

387 vcard = VCard4() 

388 vcard.add_impp(f"xmpp:{self.jid_localpart}@{domain}") 

389 

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 

396 

397 if self.birthday: 

398 vcard["birthday"] = self.birthday 

399 

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) 

406 

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 

414 

415 return vcard 

416 

417 def load_participants( 

418 self, orm: sessionmaker[Any] | Callable[[], sa.orm.Session] 

419 ) -> None: 

420 if self._is_loaded("participants"): 

421 return 

422 

423 stmt = ( 

424 sa.select(Participant) 

425 .options(sa.orm.selectinload(Participant.room)) 

426 .where(Participant.contact_id == self.id) 

427 ) 

428 

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) 

434 

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 ) 

448 

449 

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. 

454 

455 (XMPP displayed markers convey a "read up to here" semantic.) 

456 """ 

457 

458 __tablename__ = "contact_sent" 

459 __table_args__ = ( 

460 UniqueConstraint( 

461 "contact_id", "msg_id", name="uq_contact_sent_contact_id_msg_id" 

462 ), 

463 ) 

464 

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

469 

470 

471class Room(Base, _EnsureLoadedMixin, _JidMixin, _BatchUpdateMixin): 

472 """ 

473 Legacy room 

474 """ 

475 

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 ) 

486 

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) 

494 

495 jid_localpart: Mapped[str] = mapped_column(nullable=False) 

496 

497 avatar_id: Mapped[int | None] = mapped_column( 

498 ForeignKey("avatar.id"), nullable=True 

499 ) 

500 avatar: Mapped[Avatar | None] = relationship(back_populates="rooms") 

501 

502 name: Mapped[str | None] = mapped_column(nullable=True) 

503 

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 ) 

510 

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 ) 

520 

521 type: Mapped[RoomType] = mapped_column(default=RoomType.CHANNEL) 

522 

523 user_nick: Mapped[str | None] = mapped_column() 

524 user_resources: Mapped[str | None] = mapped_column(nullable=True) 

525 

526 participants_filled: Mapped[bool] = mapped_column(default=False) 

527 history_filled: Mapped[bool] = mapped_column(default=False) 

528 

529 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(default=None) 

530 

531 participants: Mapped[list[Participant]] = relationship( 

532 back_populates="room", 

533 primaryjoin="Participant.room_id == Room.id", 

534 cascade="all, delete-orphan", 

535 ) 

536 

537 archive: Mapped[list[ArchivedMessage]] = relationship(cascade="all, delete-orphan") 

538 

539 space_id: Mapped[int | None] = mapped_column(ForeignKey("space.id"), nullable=True) 

540 space: Mapped[Space | None] = relationship(back_populates="rooms") 

541 

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 ) 

548 

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 

570 

571 log.debug("Loading participants for real") 

572 

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 ) 

583 

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) 

589 

590 def update_participant(self, part: Participant) -> None: 

591 if self._is_unloaded("participants"): 

592 return 

593 

594 self._set_committed_value( 

595 "participants", [p for p in self.participants if p.id != part.id] + [part] 

596 ) 

597 

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 ) 

604 

605 

606class ArchivedMessage(Base): 

607 """ 

608 Messages of rooms, that we store to act as a MAM server 

609 """ 

610 

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 ) 

617 

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

621 

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) 

625 

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

630 

631 from_user: Mapped[bool] = mapped_column() 

632 

633 occupant_id: Mapped[str | None] = mapped_column() 

634 

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) 

638 

639 # payload is for XML we don't have dedicated columns for 

640 payload: Mapped[str | None] = mapped_column(nullable=True) 

641 

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) 

647 

648 displayed_by_user: Mapped[bool] = mapped_column(default=False, nullable=True) 

649 

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 

662 

663 msg["stanza_id"]["id"] = self.stanza_id 

664 msg["stanza_id"]["by"] = self.room.jid 

665 

666 if self.origin_id: 

667 msg["id"] = msg["origin_id"]["id"] = self.origin_id 

668 else: 

669 msg["id"] = self.stanza_id 

670 

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

677 

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}") 

686 

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 

698 

699 return msg 

700 

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 

706 

707 

708class _LegacyToXmppIdsBase: 

709 """ 

710 XMPP-client generated IDs, and mapping to the corresponding legacy IDs. 

711 

712 A single legacy ID can map to several XMPP ids. 

713 """ 

714 

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) 

718 

719 

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) 

724 

725 

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) 

730 

731 

732class Attachment(Base): 

733 """ 

734 Legacy attachments 

735 """ 

736 

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 ) 

745 

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

751 

752 legacy_file_id: Mapped[str | None] = mapped_column(index=True, nullable=True) 

753 url: Mapped[str] = mapped_column(index=True, nullable=False) 

754 

755 size: Mapped[int | None] = mapped_column(nullable=True) 

756 sha256: Mapped[str | None] = mapped_column(nullable=True) 

757 

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 ) 

768 

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 

776 

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 

786 

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 

791 

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

809 

810 wrapper = slixmpp.plugins.xep_0372.stanza.Reference() 

811 wrapper["type"] = "data" 

812 wrapper.append(sims) 

813 

814 return wrapper 

815 

816 def sfs(self) -> slixmpp.plugins.xep_0447.stanza.StatelessFileSharing: 

817 ref = slixmpp.plugins.xep_0447.stanza.UrlData() 

818 ref["target"] = self.url 

819 

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

845 

846 return sfs 

847 

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 

855 

856 

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 ) 

870 

871 id: Mapped[int] = mapped_column(primary_key=True) 

872 

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 ) 

877 

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 ) 

884 

885 occupant_id: Mapped[str | None] = mapped_column(nullable=True) 

886 

887 is_user: Mapped[bool] = mapped_column(default=False) 

888 

889 _affiliation: Mapped[MucAffiliation | None] = mapped_column(nullable=True) 

890 _role: Mapped[MucRole | None] = mapped_column(nullable=True) 

891 

892 _resource: Mapped[str | None] = mapped_column(nullable=True) 

893 nickname: Mapped[str] = mapped_column(nullable=False, default=None) 

894 

895 hats: Mapped[list[Hat]] = mapped_column(JSON, default=list) 

896 

897 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(default=None) 

898 

899 @property 

900 def resource(self) -> str: 

901 return self._resource or self.nickname 

902 

903 @resource.setter 

904 def resource(self, resource: str) -> None: 

905 self._resource = None if resource == self.nickname else resource 

906 

907 @property 

908 def affiliation(self) -> MucAffiliation: 

909 return self._affiliation or "member" 

910 

911 @affiliation.setter 

912 def affiliation(self, affiliation: MucAffiliation) -> None: 

913 self._affiliation = None if affiliation == "member" else affiliation 

914 

915 @property 

916 def role(self) -> MucRole: 

917 return self._role or "participant" 

918 

919 @role.setter 

920 def role(self, role: MucRole) -> None: 

921 self._role = None if role == "participant" else role 

922 

923 def _normalize_stored_updates(self, values: dict[str, object]) -> dict[str, object]: 

924 values = dict(values) 

925 

926 if resource := values.pop("resource", None): 

927 values["_resource"] = None if resource == self.nickname else resource 

928 

929 if affiliation := values.pop("affiliation", None): 

930 values["_affiliation"] = None if affiliation == "member" else affiliation 

931 

932 if role := values.pop("role", None): 

933 values["_role"] = None if role == "participant" else role 

934 

935 return values 

936 

937 

938class Bob(Base): 

939 __tablename__ = "bob" 

940 

941 id: Mapped[int] = mapped_column(primary_key=True) 

942 file_name: Mapped[str] = mapped_column(nullable=False) 

943 

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) 

947 

948 content_type: Mapped[str] = mapped_column(nullable=False) 

949 

950 

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 ) 

958 

959 id: Mapped[int] = mapped_column(primary_key=True) 

960 

961 user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id")) 

962 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="spaces") 

963 

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) 

968 

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

973 

974 owners: Mapped[list[Contact]] = relationship( 

975 back_populates="spaces_owned", 

976 secondary=space_owner_association, 

977 ) 

978 

979 rooms: Mapped[list[Room]] = relationship() 

980 

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) 

985 

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) 

990 

991 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(default=None) 

992 

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

1003 

1004 self._set_committed_value("rooms", rooms) 

1005 

1006 

1007log = logging.getLogger(__name__)