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

222 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-22 03:37 +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.lock import NamedLockMixin 

17from ..util.types import ( 

18 AnyMUC, 

19 AnySession, 

20 Avatar, 

21 LegacyMUCType, 

22 SpaceMetadata, 

23 Unset, 

24) 

25from ..util.util import derive_wired_class 

26from .room import LegacyMUC 

27 

28 

29class LegacyBookmarks[LegacyMUCType: AnyMUC](NamedLockMixin): 

30 """ 

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

32 """ 

33 

34 muc_cls: type[LegacyMUCType] 

35 """ 

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

37 

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

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

40 """ 

41 

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

43 super().__init_subclass__(**kwargs) 

44 derive_wired_class(cls, LegacyBookmarks, "muc_cls") 

45 

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

47 self.session = session 

48 self.xmpp = session.xmpp 

49 self.user_jid = session.user_jid 

50 

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

52 

53 super().__init__() 

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

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

56 if not self.xmpp.GROUPS: 

57 self.ready.set_result(True) 

58 

59 @property 

60 def user_nick(self) -> str: 

61 return self._user_nick 

62 

63 @user_nick.setter 

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

65 self._user_nick = nick 

66 

67 def orm( 

68 self, 

69 **kwargs: Any, # noqa:ANN401 

70 ) -> OrmSession: 

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

72 

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

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

75 

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

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

78 rooms = ( 

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

80 ) 

81 for stored in rooms: 

82 yield self.from_store(stored) 

83 

84 def __repr__(self) -> str: 

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

86 

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

88 jid = JID(JID(jid).bare) 

89 localpart = jid.local 

90 if not localpart: 

91 raise XMPPError("bad-request", "Groups must have a local part in their JID") 

92 type_, legacy_id = self.session.xmpp.localpart_to_legacy_id(localpart) 

93 if type_ != "group": 

94 raise XMPPError("bad-request", "This is a contact JID, not a group JID") 

95 return await self.by_legacy_id( 

96 legacy_id, *update_info_args, localpart=localpart 

97 ) 

98 

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

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

101 stored = ( 

102 orm.query(Room) 

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

104 .one_or_none() 

105 ) 

106 if stored is not None and stored.updated: 

107 return self.from_store(stored) 

108 return None 

109 

110 @overload 

111 async def by_legacy_id(self, /, legacy_id: str) -> "LegacyMUCType": ... 

112 

113 @overload 

114 async def by_legacy_id( 

115 self, /, legacy_id: str, *update_info_args: object, localpart: str | None 

116 ) -> "LegacyMUCType": ... 

117 

118 @overload 

119 async def by_legacy_id( 

120 self, 

121 /, 

122 legacy_id: str, 

123 *update_info_args: object, 

124 create: Literal[False], 

125 localpart: str | None, 

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

127 

128 @overload 

129 async def by_legacy_id( 

130 self, 

131 /, 

132 legacy_id: str, 

133 *update_info_args: object, 

134 create: Literal[True], 

135 localpart: str | None, 

136 ) -> "LegacyMUCType": ... 

137 

138 async def by_legacy_id( 

139 self, 

140 /, 

141 legacy_id: str, 

142 *update_info_args: object, 

143 create: bool = True, 

144 localpart: str | None = None, 

145 ) -> LegacyMUCType | None: 

146 if localpart is None: 

147 localpart = self.session.xmpp.legacy_id_to_localpart("group", legacy_id) 

148 async with self.lock(legacy_id): 

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

150 stored = ( 

151 orm.query(Room) 

152 .filter_by( 

153 user_account_id=self.session.user_pk, 

154 legacy_id=str(legacy_id), 

155 ) 

156 .one_or_none() 

157 ) 

158 if stored is None: 

159 if not create: 

160 if update_info_args: 

161 self.log.warning( 

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

163 "`update_info_args` will not be used." 

164 ) 

165 return None 

166 stored = Room( 

167 user_account_id=self.session.user_pk, 

168 jid_localpart=localpart, 

169 legacy_id=str(legacy_id), 

170 ) 

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

172 

173 async def __update_if_needed( 

174 self, stored: Room, *update_info_args: object 

175 ) -> LegacyMUCType: 

176 muc = self.from_store(stored) 

177 if muc.stored.updated and not update_info_args: 

178 return muc 

179 

180 with muc.updating_info(): 

181 try: 

182 await muc.update_info(*update_info_args) 

183 except NotImplementedError: 

184 pass 

185 except XMPPError: 

186 raise 

187 except Exception as e: # noqa: BLE001 

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

189 muc.archive.room = muc.stored 

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

191 with self.orm() as orm, orm.no_autoflush: 

192 orm.add(muc.stored) 

193 assert muc.stored.space is not None 

194 self.xmpp.pubsub.broadcast_space( 

195 self.session, 

196 muc.stored.space, 

197 self.space_legacy_id_to_node(muc.stored.space.legacy_id), 

198 [str(muc.jid)], 

199 ) 

200 return muc 

201 

202 async def fill(self) -> None: 

203 """ 

204 Establish a user's known groups. 

205 

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

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

208 the groups a user is part of. 

209 

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

211 

212 """ 

213 return 

214 

215 async def remove( 

216 self, 

217 muc: AnyMUC, 

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

219 kick: bool = True, 

220 ) -> None: 

221 """ 

222 Delete everything about a specific group. 

223 

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

225 app. 

226 

227 :param muc: The MUC to remove. 

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

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

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

231 receiving the confirmation that the group was deleted. 

232 """ 

233 if kick: 

234 user_participant = await muc.get_user_participant() 

235 user_participant.kick(reason) 

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

237 orm.add(muc.stored) 

238 orm.refresh(muc.stored) 

239 orm.delete(muc.stored) 

240 orm.commit() 

241 

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

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

244 with self.orm() as orm: 

245 orm.add(space) 

246 orm.refresh(space) 

247 if space.updated: 

248 return space 

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

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

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

252 

253 async def get_updated_space( 

254 self, 

255 legacy_id: str, 

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

257 ) -> Space | None: 

258 with self.orm() as orm: 

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

260 orm, 

261 self.session.user_pk, 

262 legacy_id, 

263 images=True, 

264 room_legacy_id_filter=room_legacy_id_filter, 

265 ) 

266 if space is None: 

267 return None 

268 if space.updated: 

269 return space 

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

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

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

273 

274 async def __update_space_metadata( 

275 self, space: Space, meta: SpaceMetadata 

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

277 creator = ( 

278 await self.__get_stored_contact(meta.creator_legacy_id) 

279 if meta.creator_legacy_id 

280 else None 

281 ) 

282 owners: list[Contact] = [] 

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

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

285 if legacy_id == meta.creator_legacy_id: 

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

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

288 continue 

289 owner = await self.__get_stored_contact(legacy_id) 

290 if owner is not None: 

291 owners.append(owner) 

292 if ( 

293 creator is not None 

294 and meta.creator_legacy_id 

295 and ( 

296 isinstance(meta.owner_legacy_ids, Unset) 

297 or meta.creator_legacy_id in meta.owner_legacy_ids 

298 ) 

299 ): 

300 owners.append(creator) 

301 

302 changed = set() 

303 

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

305 try: 

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

307 changed.add(attr) 

308 except Exception: 

309 self.log.exception("Can't set %s for %s", attr, space) 

310 

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

312 if creator is not None: 

313 creator = orm.merge(creator) 

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

315 space = orm.merge(space) 

316 if isinstance(meta.name, Unset): 

317 if not space.name: 

318 space.name = space.legacy_id 

319 else: 

320 space.name = meta.name or space.name or space.legacy_id 

321 

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

323 space.creator = creator 

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

325 space.owners = owners 

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

327 space.description = meta.description 

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

329 space.member_count = meta.member_count 

330 

331 changed |= modified_attributes(space) 

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

333 

334 if not space.updated: 

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

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

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

338 space.updated = True 

339 

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

341 orm.commit() 

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

343 

344 async def __update_space_image( 

345 self, 

346 space: Space, 

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

348 new: Avatar | Unset | None, 

349 ) -> bool: 

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

351 

352 if isinstance(new, Unset): 

353 return False 

354 

355 if new is None: 

356 setattr(space, attr, None) 

357 return stored is not None 

358 

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

360 

361 if cached_avatar.stored == stored: 

362 return False 

363 

364 if cached_avatar.stored.http_url: 

365 setattr(space, attr, cached_avatar.stored) 

366 return True 

367 else: 

368 warnings.warn( 

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

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

371 UserWarning, 

372 ) 

373 return False 

374 

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

376 try: 

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

378 legacy_id_str 

379 ) 

380 except Exception as e: # noqa: BLE001 

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

382 return None 

383 return contact.stored 

384 

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

386 """ 

387 Fetch metadata associated to a space. 

388 

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

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

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

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

393 

394 :param legacy_id: Identifier of the space. 

395 

396 :return: Metadata associated to the space. 

397 """ 

398 raise NotImplementedError 

399 

400 async def update_spaces_info(self) -> None: 

401 with self.orm() as orm: 

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

403 orm, self.session.user_pk 

404 ) 

405 for space in spaces: 

406 await self.update_space_if_needed(space) 

407 

408 def space_legacy_id_to_node(self, legacy_id: str) -> str: 

409 return legacy_id 

410 

411 def space_node_to_legacy_id(self, node: str) -> str: 

412 return node 

413 

414 async def update_space_metadata( 

415 self, 

416 legacy_id: str, 

417 metadata: SpaceMetadata, 

418 ) -> None: 

419 """ 

420 Updates metadata associated to a space. 

421 

422 :param legacy_id: Identifier of the space. 

423 :param name: Metadata associated to this space. 

424 """ 

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

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

427 orm, 

428 self.session.user_pk, 

429 str(legacy_id), 

430 ) 

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

432 node = self.space_legacy_id_to_node(legacy_id) 

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

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

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

436 if item_changes: 

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

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

439 # different protocol 

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

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

442 

443 

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