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

1from __future__ import annotations 

2 

3import hashlib 

4import string 

5import uuid 

6import warnings 

7from collections.abc import Iterator 

8from typing import TYPE_CHECKING, Unpack, overload 

9 

10import sqlalchemy as sa 

11import sqlalchemy.orm 

12from slixmpp.jid import JID, InvalidJID, unescape_node 

13 

14from slidge.util.types import MucAffiliation 

15 

16from ..db.models import Contact, GatewayUser, Participant 

17from ..util.util import strip_illegal_chars 

18 

19if TYPE_CHECKING: 

20 from ..contact import LegacyContact 

21 from ..util.types import AnyParticipant 

22 from .participant import _ParticipantProps 

23 from .room import LegacyRoom 

24 

25 

26class RoomRoster[LegacyParticipantT: AnyParticipant]: 

27 _participant_cls: type[LegacyParticipantT] 

28 

29 @overload 

30 def _from_store( 

31 self, stored: Participant, contact: LegacyContact | None = ... 

32 ) -> LegacyParticipantT: ... 

33 

34 @overload 

35 def _from_store( 

36 self, stored: None, contact: LegacyContact | None = ... 

37 ) -> None: ... 

38 

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 ) 

50 

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) 

56 

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 ) 

64 

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

71 

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) 

78 

79 def __get(self, filt: sa.ColumnElement[bool]) -> LegacyParticipantT | None: 

80 return self._from_store(self.__get_stored(filt)) 

81 

82 def __get_stored(self, filt: sa.ColumnElement[bool]) -> Participant | None: 

83 with self.__orm() as orm: 

84 return orm.scalar(self.__query(filt)) 

85 

86 def __query(self, filt: sa.ColumnElement[bool]) -> sa.Select[Participant]: 

87 return sa.select(Participant).where(Participant.room == self._muc._stored, filt) 

88 

89 @overload 

90 def get_or_create( 

91 self, *, nick: str, **kwargs: Unpack[_ParticipantProps] 

92 ) -> LegacyParticipantT: ... 

93 

94 @overload 

95 def get_or_create( 

96 self, *, contact: LegacyContact, **kwargs: Unpack[_ParticipantProps] 

97 ) -> LegacyParticipantT: ... 

98 

99 @overload 

100 def get_or_create( 

101 self, *, nick: str, contact: LegacyContact, **kwargs: Unpack[_ParticipantProps] 

102 ) -> LegacyParticipantT: ... 

103 

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. 

115 

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 

131 

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`. 

141 

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 ) 

163 

164 if affiliation := kwargs.pop("affiliation", None): 

165 kwargs["_affiliation"] = ( # type:ignore[typeddict-unknown-key] 

166 None if affiliation == "member" else affiliation 

167 ) 

168 

169 if role := kwargs.pop("role", None): 

170 kwargs["_role"] = None if role == "participant" else role # type:ignore[typeddict-unknown-key] 

171 

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 

194 

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

204 

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

209 

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) 

220 

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) 

240 

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 

253 

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 

258 

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) 

281 

282 self._muc.log.debug("Filling participants: done") 

283 

284 def __init__(self, muc: LegacyRoom[LegacyParticipantT]) -> None: 

285 self._muc = muc 

286 self._participant_cls = muc._participant_cls 

287 

288 def __repr__(self) -> str: 

289 return f"<Participants of {self._muc}>" 

290 

291 @property 

292 def __user(self) -> GatewayUser: 

293 return self._muc._session.user 

294 

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) 

297 

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) 

310 

311 

312def get_resource(nickname: str) -> str: 

313 no_illegal = strip_illegal_chars(nickname).replace("\n", " | ") 

314 jid = JID("x@x.com") 

315 

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 

328 

329 

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