Coverage for slidge/room/participants.py: 93%
169 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 hashlib
4import string
5import uuid
6import warnings
7from collections.abc import Iterator
8from typing import TYPE_CHECKING, Unpack, overload
10import sqlalchemy as sa
11import sqlalchemy.orm
12from slixmpp.jid import JID, InvalidJID, unescape_node
14from slidge.util.types import MucAffiliation
16from ..db.models import Contact, GatewayUser, Participant
17from ..util.util import strip_illegal_chars
19if TYPE_CHECKING:
20 from ..contact import LegacyContact
21 from ..util.types import AnyParticipant
22 from .participant import _ParticipantProps
23 from .room import LegacyRoom
26class RoomRoster[LegacyParticipantT: AnyParticipant]:
27 _participant_cls: type[LegacyParticipantT]
29 @overload
30 def _from_store(
31 self, stored: Participant, contact: LegacyContact | None = ...
32 ) -> LegacyParticipantT: ...
34 @overload
35 def _from_store(
36 self, stored: None, contact: LegacyContact | None = ...
37 ) -> None: ...
39 def _from_store(
40 self,
41 stored: Participant | None,
42 contact: LegacyContact | None = None,
43 for_join: bool = False,
44 ) -> LegacyParticipantT | None:
45 return (
46 None
47 if stored is None
48 else self._participant_cls(muc=self._muc, stored=stored, contact=contact)
49 )
51 def get_by_nick(self, nick: str) -> LegacyParticipantT | None:
52 """
53 Get a participant by their nickname, if they were created before.
54 """
55 return self.__get(Participant.nickname == nick)
57 def get_by_resource(self, resource: str) -> LegacyParticipantT | None:
58 """
59 Get a participant by their resource part, if they were created before.
60 """
61 return self.__get(
62 (Participant.nickname == resource) | (Participant._resource == resource)
63 )
65 def get_by_legacy_id(self, legacy_id: str) -> LegacyParticipantT | None:
66 """
67 Get a non-anymous participant by their contact legacy ID, if they were
68 created before.
69 """
70 return self.__get(Participant.contact.has(Contact.legacy_id == legacy_id))
72 def get_by_contact(self, contact: LegacyContact) -> LegacyParticipantT | None:
73 """
74 Get a non-anymous participant by their :class:`~slidge.LegacyContact`
75 instance, if they were created before
76 """
77 return self.__get(Participant.contact == contact._stored)
79 def __get(self, filt: sa.ColumnElement[bool]) -> LegacyParticipantT | None:
80 return self._from_store(self.__get_stored(filt))
82 def __get_stored(self, filt: sa.ColumnElement[bool]) -> Participant | None:
83 with self.__orm() as orm:
84 return orm.scalar(self.__query(filt))
86 def __query(self, filt: sa.ColumnElement[bool]) -> sa.Select[Participant]:
87 return sa.select(Participant).where(Participant.room == self._muc._stored, filt)
89 @overload
90 def get_or_create(
91 self, *, nick: str, **kwargs: Unpack[_ParticipantProps]
92 ) -> LegacyParticipantT: ...
94 @overload
95 def get_or_create(
96 self, *, contact: LegacyContact, **kwargs: Unpack[_ParticipantProps]
97 ) -> LegacyParticipantT: ...
99 @overload
100 def get_or_create(
101 self, *, nick: str, contact: LegacyContact, **kwargs: Unpack[_ParticipantProps]
102 ) -> LegacyParticipantT: ...
104 def get_or_create(
105 self,
106 *,
107 nick: str | None = None,
108 contact: LegacyContact | None = None,
109 **kwargs: Unpack[_ParticipantProps],
110 ) -> LegacyParticipantT:
111 """
112 Get or create a participant, either by nick or
113 :class:`~slidge.LegacyContact`. One of the two arguments MUST
114 be provided.
116 This is guaranteed to return a participant.
117 """
118 if not (nick or contact):
119 raise TypeError("Either pass a nick or a contact here")
120 if contact is not None:
121 stored = self.__get_stored(Participant.contact == contact._stored)
122 elif nick is not None:
123 stored = self.__get_stored(Participant.nickname == nick)
124 part = self._from_store(stored, contact)
125 if part is None:
126 part = self.create(nick=nick, contact=contact, **kwargs)
127 else:
128 if kwargs:
129 part.batch_set_attributes(**kwargs)
130 return part
132 def create(
133 self,
134 nick: str | None,
135 contact: LegacyContact | None = None,
136 temp: bool = False,
137 **kwargs: Unpack[_ParticipantProps],
138 ) -> LegacyParticipantT:
139 """
140 Create a participant, either by nick or :class:`~slidge.LegacyContact`.
142 :param nick: A nickname for this participant. If only a nick is passed,
143 the participant will be anonymous.
144 :param contact: A :class:`~slidge.LegacyContact` instance, for a
145 non-anonymous partipant.
146 :param temp: If set to True, do not store the participant persistently.
147 This can be used when filling the history, if the participant has
148 now left the room.
149 """
150 if not nick:
151 if not contact:
152 raise RuntimeError(
153 "Either pass a nick or a contact to create a participant"
154 )
155 nick = contact.name or contact.jid.local
156 occupant_id = str(contact.jid) if contact else str(uuid.uuid4())
157 with self.__orm() as orm:
158 resource = self.__disambiguate(
159 orm,
160 get_resource(nick),
161 unescape_node(contact.jid.local) if contact else occupant_id,
162 )
164 if affiliation := kwargs.pop("affiliation", None):
165 kwargs["_affiliation"] = ( # type:ignore[typeddict-unknown-key]
166 None if affiliation == "member" else affiliation
167 )
169 if role := kwargs.pop("role", None):
170 kwargs["_role"] = None if role == "participant" else role # type:ignore[typeddict-unknown-key]
172 stored = Participant(
173 contact_id=contact._stored.id if contact else None,
174 nickname=nick,
175 room_id=self._muc._stored.id,
176 occupant_id=occupant_id,
177 resource=resource,
178 **kwargs,
179 )
180 if not temp:
181 stored = orm.merge(stored)
182 orm.commit()
183 p = self._from_store(stored, contact)
184 if (
185 not temp
186 and self._muc.participants_filled
187 and not self._muc.get_lock("fill participants")
188 and not self._muc.get_lock("fill history")
189 ):
190 if p.contact:
191 self._muc.send_affiliation_change(p)
192 p.send_last_presence(force=True)
193 return p
195 def _rename(self, stored: Participant, nick: str, disamb: str) -> None:
196 resource = get_resource(nick)
197 with self.__orm() as orm:
198 orm.add(stored)
199 stored.nickname = nick
200 stored.resource = self.__disambiguate(
201 orm, resource, disamb, exclude_pk=stored.id
202 )
203 orm.commit()
205 def _delete(self, pk: int) -> None:
206 with self.__orm() as orm:
207 orm.execute(sa.delete(Participant).where(Participant.id == pk))
208 orm.commit()
210 async def get_or_create_by_legacy_id(
211 self, contact_legacy_id: str, **kwargs: Unpack[_ParticipantProps]
212 ) -> LegacyParticipantT:
213 """
214 Get or create a participant by a contact legacy_id
215 """
216 if contact_legacy_id == self._muc._session.contacts.user_legacy_id:
217 return self.get_user()
218 contact = await self._muc._session.contacts.get_or_create(contact_legacy_id)
219 return self.get_or_create(contact=contact, **kwargs)
221 def get_user(self) -> LegacyParticipantT:
222 """
223 Get the :term:`user <User>` participant.
224 """
225 with self.__orm() as orm:
226 stored = orm.scalar(self.__query(Participant.is_user.is_(True)))
227 if stored is None:
228 stored = Participant(
229 nickname=self._muc.user_nick,
230 room_id=self._muc._stored.id,
231 is_user=True,
232 occupant_id="slidge-user",
233 resource=self.__disambiguate(
234 orm, get_resource(self._muc.user_nick), "slidge-user"
235 ),
236 )
237 orm.add(stored)
238 orm.commit()
239 return self._from_store(stored)
241 def __disambiguate(
242 self,
243 orm: sa.orm.Session,
244 resource: str,
245 discriminator: str,
246 exclude_pk: int | None = None,
247 ) -> str:
248 if not self._muc.xmpp.store.rooms.resource_available(
249 orm, self._muc._stored.id, resource, exclude_pk
250 ):
251 resource = f"{resource} ({discriminator})"
252 return resource
254 async def ensure_filled(self, with_presence: bool = False) -> None:
255 if self._muc.participants_filled:
256 self._muc._stored.load_participants(self.__orm, with_presence=with_presence)
257 return
259 async with self._muc.lock("fill participants"):
260 with self._muc.xmpp.store.session(expire_on_commit=False) as orm:
261 orm.add(self._muc._stored)
262 with orm.no_autoflush:
263 orm.refresh(self._muc._stored, ["participants_filled"])
264 if self._muc.participants_filled:
265 return
266 self._muc.log.debug("Filling participants")
267 updates = []
268 participants = []
269 with self._participant_cls._batch_set_attributes():
270 async for participant in self._muc.fill_participants():
271 update = participant._pending_updates
272 participants.append(participant._stored)
273 if update:
274 updates.append({"id": participant._stored.id, **update})
275 if not any(p.is_user for p in participants):
276 participants.append(self.get_user()._stored)
277 with self.__orm() as orm:
278 orm.execute(sa.update(Participant), updates)
279 orm.commit()
280 self._muc._update_stored_attribute(participants_filled=True)
282 self._muc.log.debug("Filling participants: done")
284 def __init__(self, muc: LegacyRoom[LegacyParticipantT]) -> None:
285 self._muc = muc
286 self._participant_cls = muc._participant_cls
288 def __repr__(self) -> str:
289 return f"<Participants of {self._muc}>"
291 @property
292 def __user(self) -> GatewayUser:
293 return self._muc._session.user
295 def __orm(self, expire_on_commit: bool = False) -> sa.orm.Session:
296 return self._muc.xmpp.store.session(expire_on_commit=expire_on_commit)
298 def __iter__(
299 self, affiliation: MucAffiliation | None = None
300 ) -> Iterator[LegacyParticipantT]:
301 q = sa.select(Participant).where(Participant.room == self._muc._stored)
302 if affiliation == "member":
303 q = q.where(Participant._affiliation.is_(None))
304 elif affiliation is not None:
305 q = q.where(Participant._affiliation == affiliation)
306 with self.__orm() as orm:
307 rows = list(orm.scalars(q))
308 for stored in rows:
309 yield self._from_store(stored)
312def get_resource(nickname: str) -> str:
313 no_illegal = strip_illegal_chars(nickname).replace("\n", " | ")
314 jid = JID("x@x.com")
316 try:
317 jid.resource = no_illegal
318 except InvalidJID:
319 punycoded = nickname.encode("punycode").decode()
320 try:
321 jid.resource = punycoded
322 except InvalidJID:
323 return _strip_non_printable(nickname)
324 else:
325 return punycoded
326 else:
327 return no_illegal
330def _strip_non_printable(nickname: str) -> str:
331 new = (
332 "".join(x for x in nickname if x in string.printable)
333 + f"-slidge-{hashlib.md5(nickname.encode()).hexdigest()[:8]}"
334 )
335 warnings.warn(f"Could not use {nickname} as a nickname, using {new}")
336 return new