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

171 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-28 18:29 +0000

1import abc 

2import logging 

3from collections.abc import Iterator 

4from typing import Any, Generic, 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 

11 

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

13from ..util import SubclassableOnce 

14from ..util.jid_escaping import EscapeMixin 

15from ..util.lock import NamedLockMixin 

16from ..util.types import ( 

17 AnyMUC, 

18 AnySession, 

19 LegacyMUCType, 

20 SpaceMetadata, 

21) 

22 

23 

24class LegacyBookmarks( 

25 EscapeMixin, 

26 NamedLockMixin, 

27 SubclassableOnce, 

28 Generic[LegacyMUCType], 

29): 

30 """ 

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

32 """ 

33 

34 _muc_cls: type[LegacyMUCType] 

35 

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

37 self.session = session 

38 self.xmpp = session.xmpp 

39 self.user_jid = session.user_jid 

40 

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

42 

43 super().__init__() 

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

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

46 if not self.xmpp.GROUPS: 

47 self.ready.set_result(True) 

48 

49 @property 

50 def user_nick(self) -> str: 

51 return self._user_nick 

52 

53 @user_nick.setter 

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

55 self._user_nick = nick 

56 

57 def orm( 

58 self, 

59 **kwargs: Any, # noqa:ANN401 

60 ) -> OrmSession: 

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

62 

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

64 return self._muc_cls(self.session, stored) 

65 

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

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

68 rooms = ( 

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

70 ) 

71 for stored in rooms: 

72 yield self.from_store(stored) 

73 

74 def __repr__(self) -> str: 

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

76 

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

78 return await self.legacy_id_to_jid_username(legacy_id) 

79 

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

81 return await self.jid_username_to_legacy_id(local_part) 

82 

83 async def by_jid(self, jid: JID) -> LegacyMUCType: 

84 if jid.resource: 

85 jid = JID(jid.bare) 

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

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

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

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

90 return await self.by_legacy_id(legacy_id) 

91 

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

93 stored = ( 

94 orm.query(Room) 

95 .filter_by( 

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

97 ) 

98 .one_or_none() 

99 ) 

100 if stored is None: 

101 stored = Room( 

102 user_account_id=self.session.user_pk, 

103 jid_localpart=jid.local, 

104 legacy_id=legacy_id, 

105 ) 

106 return await self.__update_if_needed(stored) 

107 

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

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

110 stored = ( 

111 orm.query(Room) 

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

113 .one_or_none() 

114 ) 

115 if stored is not None and stored.updated: 

116 return self.from_store(stored) 

117 return None 

118 

119 @overload 

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

121 

122 @overload 

123 async def by_legacy_id( 

124 self, legacy_id: str, create: Literal[False] 

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

126 

127 @overload 

128 async def by_legacy_id( 

129 self, legacy_id: str, create: Literal[True] 

130 ) -> "LegacyMUCType": ... 

131 

132 async def by_legacy_id( 

133 self, legacy_id: str, create: bool = False 

134 ) -> LegacyMUCType | None: 

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

136 local = await self.legacy_id_to_jid_local_part(legacy_id) 

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

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

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

140 if create: 

141 return await self.by_jid(jid) 

142 else: 

143 return self.by_jid_only_if_exists(jid) 

144 

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

146 stored = ( 

147 orm.query(Room) 

148 .filter_by( 

149 user_account_id=self.session.user_pk, 

150 legacy_id=str(legacy_id), 

151 ) 

152 .one_or_none() 

153 ) 

154 if stored is None: 

155 stored = Room( 

156 user_account_id=self.session.user_pk, 

157 jid_localpart=local, 

158 legacy_id=str(legacy_id), 

159 ) 

160 return await self.__update_if_needed(stored) 

161 

162 async def __update_if_needed(self, stored: Room) -> LegacyMUCType: 

163 muc = self.from_store(stored) 

164 if muc.stored.updated: 

165 return muc 

166 

167 with muc.updating_info(): 

168 try: 

169 await muc.update_info() 

170 except NotImplementedError: 

171 pass 

172 except XMPPError: 

173 raise 

174 except Exception as e: # noqa: BLE001 

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

176 muc.archive.room = muc.stored 

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

178 with self.orm() as orm: 

179 orm.add(muc.stored) 

180 self.xmpp.pubsub.broadcast_space(self.session, muc.stored.space) 

181 return muc 

182 

183 async def fill(self) -> None: 

184 """ 

185 Establish a user's known groups. 

186 

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

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

189 the groups a user is part of. 

190 

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

192 

193 """ 

194 if self.xmpp.GROUPS: 

195 raise NotImplementedError( 

196 "The plugin advertised support for groups but" 

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

198 ) 

199 

200 async def remove( 

201 self, 

202 muc: AnyMUC, 

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

204 kick: bool = True, 

205 ) -> None: 

206 """ 

207 Delete everything about a specific group. 

208 

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

210 app. 

211 

212 :param muc: The MUC to remove. 

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

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

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

216 receiving the confirmation that the group was deleted. 

217 """ 

218 if kick: 

219 user_participant = await muc.get_user_participant() 

220 user_participant.kick(reason) 

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

222 orm.add(muc.stored) 

223 orm.refresh(muc.stored) 

224 orm.delete(muc.stored) 

225 orm.commit() 

226 

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

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

229 with self.orm() as orm: 

230 orm.add(space) 

231 orm.refresh(space) 

232 if space.updated: 

233 return space 

234 meta: SpaceMetadata = await self.fetch_space_metadata(space.legacy_id) 

235 return await self.__update_space_metadata(space, meta) 

236 

237 async def __update_space_metadata(self, space: Space, meta: SpaceMetadata) -> Space: 

238 creator = ( 

239 await self.__get_stored_contact(meta.creator_legacy_id) 

240 if meta.creator_legacy_id 

241 else None 

242 ) 

243 owners: list[Contact] = [] 

244 for legacy_id in set(meta.owner_legacy_ids): 

245 if legacy_id == meta.creator_legacy_id: 

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

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

248 continue 

249 owner = await self.__get_stored_contact(legacy_id) 

250 if owner is not None: 

251 owners.append(owner) 

252 if ( 

253 creator is not None 

254 and meta.creator_legacy_id 

255 and meta.creator_legacy_id in meta.owner_legacy_ids 

256 ): 

257 owners.append(creator) 

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

259 if creator is not None: 

260 creator = orm.merge(creator) 

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

262 space = orm.merge(space) 

263 space.name = meta.name or space.legacy_id 

264 space.creator = creator 

265 space.owners = owners 

266 space.description = meta.description 

267 space.member_count = meta.member_count 

268 space.updated = True 

269 orm.commit() 

270 return space 

271 

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

273 try: 

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

275 legacy_id_str 

276 ) 

277 except Exception as e: # noqa: BLE001 

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

279 return None 

280 return contact.stored 

281 

282 @abc.abstractmethod 

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

284 """ 

285 Fetch metadata associated to a space. 

286 

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

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

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

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

291 

292 :param legacy_id: Identifier of the space. 

293 

294 :return: Metadata associated to the space. 

295 """ 

296 raise NotImplementedError 

297 

298 async def update_spaces_info(self) -> None: 

299 with self.orm() as orm: 

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

301 orm, self.session.user_pk 

302 ) 

303 for space in spaces: 

304 await self.update_space_if_needed(space) 

305 

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

307 return str(legacy_id) 

308 

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

310 return node 

311 

312 async def update_space_metadata( 

313 self, 

314 legacy_id: str, 

315 metadata: SpaceMetadata, 

316 ) -> None: 

317 """ 

318 Updates metadata associated to a space. 

319 

320 :param legacy_id: Identifier of the space. 

321 :param name: Metadata associated to this space. 

322 """ 

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

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

325 orm, 

326 self.session.user_pk, 

327 str(legacy_id), 

328 ) 

329 space = await self.__update_space_metadata(space, metadata) 

330 self.xmpp.pubsub.broadcast_space_metadata( 

331 self.session, space, await self.space_legacy_id_to_node(legacy_id) 

332 )