Coverage for slidge/group/bookmarks.py: 90%

231 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-10 04:45 +0000

1import logging 

2import warnings 

3from collections.abc import Iterable, Iterator 

4from typing import Any, Literal, overload 

5 

6from slixmpp import JID 

7from slixmpp.exceptions import XMPPError 

8from sqlalchemy.orm import Session as OrmSession 

9 

10from slidge.contact import LegacyContact 

11from slidge.db.avatar import avatar_cache 

12from slidge.db.meta import modified_attributes 

13 

14from ..db.models import Avatar as AvatarModel 

15from ..db.models import Contact, Room, Space 

16from ..util.jid_escaping import EscapeMixin 

17from ..util.lock import NamedLockMixin 

18from ..util.types import ( 

19 AnyMUC, 

20 AnySession, 

21 Avatar, 

22 LegacyMUCType, 

23 SpaceMetadata, 

24 Unset, 

25) 

26from ..util.util import derive_wired_class 

27from .room import LegacyMUC 

28 

29 

30class LegacyBookmarks[LegacyMUCType: AnyMUC]( 

31 EscapeMixin, 

32 NamedLockMixin, 

33): 

34 """ 

35 This is instantiated once per :class:`~slidge.BaseSession` 

36 """ 

37 

38 muc_cls: type[LegacyMUCType] 

39 """ 

40 The concrete :class:`.LegacyMUC` subclass these bookmarks produce. 

41 

42 Derived automatically from the generic parameter, e.g., 

43 ``class Bookmarks(LegacyBookmarks[MUC])`` produces ``MUC`` instances. 

44 """ 

45 

46 def __init_subclass__(cls, **kwargs: object) -> None: 

47 super().__init_subclass__(**kwargs) 

48 derive_wired_class(cls, LegacyBookmarks, "muc_cls") 

49 

50 def __init__(self, session: AnySession) -> None: 

51 self.session = session 

52 self.xmpp = session.xmpp 

53 self.user_jid = session.user_jid 

54 

55 self._user_nick: str = self.session.user_jid.node 

56 

57 super().__init__() 

58 self.log = logging.getLogger(f"{self.user_jid.bare}:bookmarks") 

59 self.ready = self.session.xmpp.loop.create_future() 

60 if not self.xmpp.GROUPS: 

61 self.ready.set_result(True) 

62 

63 @property 

64 def user_nick(self) -> str: 

65 return self._user_nick 

66 

67 @user_nick.setter 

68 def user_nick(self, nick: str) -> None: 

69 self._user_nick = nick 

70 

71 def orm( 

72 self, 

73 **kwargs: Any, # noqa:ANN401 

74 ) -> OrmSession: 

75 return self.session.xmpp.store.session(**kwargs) 

76 

77 def from_store(self, stored: Room) -> LegacyMUCType: 

78 return self.muc_cls(self.session, stored) 

79 

80 def __iter__(self) -> Iterator[LegacyMUCType]: 

81 with self.xmpp.store.session() as orm: 

82 rooms = ( 

83 orm.query(Room).filter_by(user=self.session.user, updated=True).all() 

84 ) 

85 for stored in rooms: 

86 yield self.from_store(stored) 

87 

88 def __repr__(self) -> str: 

89 return f"<Bookmarks of {self.user_jid}>" 

90 

91 async def legacy_id_to_jid_local_part(self, legacy_id: str) -> str: 

92 return await self.legacy_id_to_jid_username(legacy_id) 

93 

94 async def jid_local_part_to_legacy_id(self, local_part: str) -> str: 

95 return await self.jid_username_to_legacy_id(local_part) 

96 

97 async def by_jid(self, jid: JID, *update_info_args: object) -> LegacyMUCType: 

98 if jid.resource: 

99 jid = JID(jid.bare) 

100 async with self.lock(("bare", jid.bare)): 

101 legacy_id = await self.jid_local_part_to_legacy_id(jid.node) 

102 if self.get_lock(("legacy_id", legacy_id)): 

103 self.session.log.debug("Already updating %s via by_legacy_id()", jid) 

104 return await self.by_legacy_id(legacy_id) 

105 

106 with self.session.xmpp.store.session() as orm: 

107 stored = ( 

108 orm.query(Room) 

109 .filter_by( 

110 user_account_id=self.session.user_pk, jid_localpart=jid.local 

111 ) 

112 .one_or_none() 

113 ) 

114 if stored is None: 

115 stored = Room( 

116 user_account_id=self.session.user_pk, 

117 jid_localpart=jid.local, 

118 legacy_id=legacy_id, 

119 ) 

120 return await self.__update_if_needed(stored, *update_info_args) 

121 

122 def by_jid_only_if_exists(self, jid: JID) -> LegacyMUCType | None: 

123 with self.xmpp.store.session(expire_on_commit=False) as orm: 

124 stored = ( 

125 orm.query(Room) 

126 .filter_by(user=self.session.user, jid_localpart=jid.local) 

127 .one_or_none() 

128 ) 

129 if stored is not None and stored.updated: 

130 return self.from_store(stored) 

131 return None 

132 

133 @overload 

134 async def by_legacy_id( 

135 self, /, legacy_id: str, *update_info_args: object 

136 ) -> "LegacyMUCType": ... 

137 

138 @overload 

139 async def by_legacy_id( 

140 self, /, legacy_id: str, *update_info_args: object, create: Literal[False] 

141 ) -> "LegacyMUCType | None": ... 

142 

143 @overload 

144 async def by_legacy_id( 

145 self, /, legacy_id: str, *update_info_args: object, create: Literal[True] 

146 ) -> "LegacyMUCType": ... 

147 

148 async def by_legacy_id( 

149 self, /, legacy_id: str, *update_info_args: object, create: bool = True 

150 ) -> LegacyMUCType | None: 

151 async with self.lock(("legacy_id", legacy_id)): 

152 local = await self.legacy_id_to_jid_local_part(legacy_id) 

153 jid = JID(f"{local}@{self.xmpp.boundjid}") 

154 if self.get_lock(("bare", jid.bare)): 

155 self.session.log.debug("Already updating %s via by_jid()", jid) 

156 if create: 

157 return await self.by_jid(jid, *update_info_args) 

158 else: 

159 if update_info_args: 

160 self.log.warning( 

161 "By legacy ID called with `create=False`, " 

162 "`update_info_args` will not be used." 

163 ) 

164 return self.by_jid_only_if_exists(jid) 

165 

166 with self.xmpp.store.session() as orm: 

167 stored = ( 

168 orm.query(Room) 

169 .filter_by( 

170 user_account_id=self.session.user_pk, 

171 legacy_id=str(legacy_id), 

172 ) 

173 .one_or_none() 

174 ) 

175 if stored is None: 

176 if not create: 

177 return None 

178 stored = Room( 

179 user_account_id=self.session.user_pk, 

180 jid_localpart=local, 

181 legacy_id=str(legacy_id), 

182 ) 

183 return await self.__update_if_needed(stored, *update_info_args) 

184 

185 async def __update_if_needed( 

186 self, stored: Room, *update_info_args: object 

187 ) -> LegacyMUCType: 

188 muc = self.from_store(stored) 

189 if muc.stored.updated and not update_info_args: 

190 return muc 

191 

192 with muc.updating_info(): 

193 try: 

194 await muc.update_info(*update_info_args) 

195 except NotImplementedError: 

196 pass 

197 except XMPPError: 

198 raise 

199 except Exception as e: # noqa: BLE001 

200 raise XMPPError("internal-server-error", str(e)) 

201 muc.archive.room = muc.stored 

202 if self.ready.done() and muc.stored.space_id: 

203 node = await self.space_legacy_id_to_node(muc.stored.space.legacy_id) 

204 with self.orm() as orm: 

205 orm.add(muc.stored) 

206 self.xmpp.pubsub.broadcast_space( 

207 self.session, muc.stored.space, node, [str(muc.jid)] 

208 ) 

209 return muc 

210 

211 async def fill(self) -> None: 

212 """ 

213 Establish a user's known groups. 

214 

215 This has to be overridden in plugins with group support and at the 

216 minimum, this should ``await self.by_legacy_id(group_id)`` for all 

217 the groups a user is part of. 

218 

219 Slidge internals will call this on successful :meth:`BaseSession.login` 

220 

221 """ 

222 if self.xmpp.GROUPS: 

223 raise NotImplementedError( 

224 "The plugin advertised support for groups but" 

225 " LegacyBookmarks.fill() was not overridden." 

226 ) 

227 

228 async def remove( 

229 self, 

230 muc: AnyMUC, 

231 reason: str = "You left this group from the official client.", 

232 kick: bool = True, 

233 ) -> None: 

234 """ 

235 Delete everything about a specific group. 

236 

237 This should be called when the user leaves the group from the official 

238 app. 

239 

240 :param muc: The MUC to remove. 

241 :param reason: Optionally, a reason why this group was removed. 

242 :param kick: Whether the user should be kicked from this group. Set this 

243 to False in case you do this somewhere else in your code, eg, on 

244 receiving the confirmation that the group was deleted. 

245 """ 

246 if kick: 

247 user_participant = await muc.get_user_participant() 

248 user_participant.kick(reason) 

249 with self.xmpp.store.session() as orm: 

250 orm.add(muc.stored) 

251 orm.refresh(muc.stored) 

252 orm.delete(muc.stored) 

253 orm.commit() 

254 

255 async def update_space_if_needed(self, space: Space) -> Space: 

256 async with self.lock(("space", space.legacy_id)): 

257 with self.orm() as orm: 

258 orm.add(space) 

259 orm.refresh(space) 

260 if space.updated: 

261 return space 

262 orm.refresh(space, ["avatar", "banner"]) 

263 meta = await self.fetch_space_metadata(space.legacy_id) 

264 return (await self.__update_space_metadata(space, meta))[0] 

265 

266 async def get_updated_space( 

267 self, 

268 legacy_id: str, 

269 room_legacy_id_filter: Iterable[str] | None = None, 

270 ) -> Space | None: 

271 with self.orm() as orm: 

272 space = self.xmpp.store.spaces.get_by_legacy_id( 

273 orm, 

274 self.session.user_pk, 

275 legacy_id, 

276 images=True, 

277 room_legacy_id_filter=room_legacy_id_filter, 

278 ) 

279 if space is None: 

280 return None 

281 if space.updated: 

282 return space 

283 async with self.lock(("space", space.legacy_id)): 

284 meta = await self.fetch_space_metadata(space.legacy_id) 

285 return (await self.__update_space_metadata(space, meta))[0] 

286 

287 async def __update_space_metadata( 

288 self, space: Space, meta: SpaceMetadata 

289 ) -> tuple[Space, set[str]]: 

290 creator = ( 

291 await self.__get_stored_contact(meta.creator_legacy_id) 

292 if meta.creator_legacy_id 

293 else None 

294 ) 

295 owners: list[Contact] = [] 

296 if not isinstance(meta.owner_legacy_ids, Unset): 

297 for legacy_id in set(meta.owner_legacy_ids or ()): 

298 if legacy_id == meta.creator_legacy_id: 

299 # We don't want to fetch any contact twice here to avoid: 

300 # Can't attach instance <Contact at xxx>; another instance [...] is already present in this session. 

301 continue 

302 owner = await self.__get_stored_contact(legacy_id) 

303 if owner is not None: 

304 owners.append(owner) 

305 if ( 

306 creator is not None 

307 and meta.creator_legacy_id 

308 and ( 

309 isinstance(meta.owner_legacy_ids, Unset) 

310 or meta.creator_legacy_id in meta.owner_legacy_ids 

311 ) 

312 ): 

313 owners.append(creator) 

314 

315 changed = set() 

316 for attr in "avatar", "banner": 

317 if await self.__update_space_image(space, attr, getattr(meta, attr)): 

318 changed.add(attr) 

319 with self.orm(expire_on_commit=False) as orm: 

320 if creator is not None: 

321 creator = orm.merge(creator) 

322 owners = [orm.merge(owner) for owner in owners] 

323 space = orm.merge(space) 

324 if isinstance(meta.name, Unset): 

325 if not space.name: 

326 space.name = space.legacy_id 

327 else: 

328 space.name = meta.name or space.name or space.legacy_id 

329 

330 if not isinstance(meta.creator_legacy_id, Unset): 

331 space.creator = creator 

332 if not isinstance(meta.owner_legacy_ids, Unset): 

333 space.owners = owners 

334 if not isinstance(meta.description, Unset): 

335 space.description = meta.description 

336 if not isinstance(meta.member_count, Unset): 

337 space.member_count = meta.member_count 

338 

339 changed |= modified_attributes(space) 

340 self.log.debug("Changed space attributes: %s", changed) 

341 

342 if not space.updated: 

343 # setting .updated to True means that fetch_space_metadata() 

344 # should not be called for this specific space, but does not 

345 # mean that we have to broadcast a pubsub#metadata change 

346 space.updated = True 

347 

348 if orm.is_modified(space, include_collections=True): 

349 orm.commit() 

350 return space, changed # update_img, bool(changed) 

351 

352 async def __update_space_image( 

353 self, 

354 space: Space, 

355 attr: Literal["avatar", "banner"], 

356 new: Avatar | Unset | None, 

357 ) -> bool: 

358 stored: AvatarModel | None = getattr(space, attr) 

359 

360 if isinstance(new, Unset): 

361 return False 

362 

363 if new is None: 

364 setattr(space, attr, None) 

365 return stored is not None 

366 

367 cached_avatar = await avatar_cache.get(new, session=self.session, convert=False) 

368 

369 if cached_avatar.stored == stored: 

370 return False 

371 

372 if cached_avatar.stored.http_url: 

373 setattr(space, attr, cached_avatar.stored) 

374 return True 

375 else: 

376 warnings.warn( 

377 "Space avatar can only be served via HTTP. " 

378 "Consider using 'no-upload' or 'use-attachment-original-urls'.", 

379 UserWarning, 

380 ) 

381 return False 

382 

383 async def __get_stored_contact(self, legacy_id_str: str) -> Contact | None: 

384 try: 

385 contact: LegacyContact = await self.session.contacts.by_legacy_id( 

386 legacy_id_str 

387 ) 

388 except Exception as e: # noqa: BLE001 

389 self.log.warning("Could not get contact: %r", e) 

390 return None 

391 return contact.stored 

392 

393 async def fetch_space_metadata(self, legacy_id: str) -> SpaceMetadata: 

394 """ 

395 Fetch metadata associated to a space. 

396 

397 This is called once per slidge runtime. It should return metadata 

398 associated to the space identified by its ``legacy_id``. 

399 If there are updates to this metata, they should be communicated to 

400 slidge by calling :func:`LegacyBookmarks.update_space_metadata`. 

401 

402 :param legacy_id: Identifier of the space. 

403 

404 :return: Metadata associated to the space. 

405 """ 

406 raise NotImplementedError 

407 

408 async def update_spaces_info(self) -> None: 

409 with self.orm() as orm: 

410 spaces = self.session.xmpp.store.spaces.get_unupdated( 

411 orm, self.session.user_pk 

412 ) 

413 for space in spaces: 

414 await self.update_space_if_needed(space) 

415 

416 async def space_legacy_id_to_node(self, legacy_id: str) -> str: 

417 return legacy_id 

418 

419 async def space_node_to_legacy_id(self, node: str) -> str: 

420 return node 

421 

422 async def update_space_metadata( 

423 self, 

424 legacy_id: str, 

425 metadata: SpaceMetadata, 

426 ) -> None: 

427 """ 

428 Updates metadata associated to a space. 

429 

430 :param legacy_id: Identifier of the space. 

431 :param name: Metadata associated to this space. 

432 """ 

433 with self.orm(expire_on_commit=False) as orm: 

434 space = self.session.xmpp.store.spaces.add_or_get( 

435 orm, 

436 self.session.user_pk, 

437 str(legacy_id), 

438 ) 

439 space, changes = await self.__update_space_metadata(space, metadata) 

440 node = await self.space_legacy_id_to_node(legacy_id) 

441 if changes - {"avatar", "banner"}: 

442 self.xmpp.pubsub.broadcast_space_metadata(self.session, space, node) 

443 item_changes = changes & {"avatar", "banner"} 

444 if item_changes: 

445 # avatar and banner are pubsub *items*, not part of the 

446 # pubsub#metadata form; broadcasting their update has a 

447 # different protocol 

448 items_ids = [f"urn:xmpp:spaces:{attr}:metadata:0" for attr in item_changes] 

449 self.xmpp.pubsub.broadcast_space(self.session, space, node, items_ids) 

450 

451 

452LegacyBookmarks.muc_cls = LegacyMUC # type:ignore[misc]