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

171 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-09 03:59 +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 Generic[LegacyMUCType], 

26 EscapeMixin, 

27 NamedLockMixin, 

28 SubclassableOnce, 

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(user_account_id=self.session.user_pk, jid=jid) 

96 .one_or_none() 

97 ) 

98 if stored is None: 

99 stored = Room( 

100 user_account_id=self.session.user_pk, 

101 jid=jid, 

102 legacy_id=legacy_id, 

103 ) 

104 return await self.__update_if_needed(stored) 

105 

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

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

108 stored = ( 

109 orm.query(Room).filter_by(user=self.session.user, jid=jid).one_or_none() 

110 ) 

111 if stored is not None and stored.updated: 

112 return self.from_store(stored) 

113 return None 

114 

115 @overload 

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

117 

118 @overload 

119 async def by_legacy_id( 

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

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

122 

123 @overload 

124 async def by_legacy_id( 

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

126 ) -> "LegacyMUCType": ... 

127 

128 async def by_legacy_id( 

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

130 ) -> LegacyMUCType | None: 

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

132 local = await self.legacy_id_to_jid_local_part(legacy_id) 

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

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

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

136 if create: 

137 return await self.by_jid(jid) 

138 else: 

139 return self.by_jid_only_if_exists(jid) 

140 

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

142 stored = ( 

143 orm.query(Room) 

144 .filter_by( 

145 user_account_id=self.session.user_pk, 

146 legacy_id=str(legacy_id), 

147 ) 

148 .one_or_none() 

149 ) 

150 if stored is None: 

151 stored = Room( 

152 user_account_id=self.session.user_pk, 

153 jid=jid, 

154 legacy_id=str(legacy_id), 

155 ) 

156 return await self.__update_if_needed(stored) 

157 

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

159 muc = self.from_store(stored) 

160 if muc.stored.updated: 

161 return muc 

162 

163 with muc.updating_info(): 

164 try: 

165 await muc.update_info() 

166 except NotImplementedError: 

167 pass 

168 except XMPPError: 

169 raise 

170 except Exception as e: 

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

172 muc.archive.room = muc.stored 

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

174 with self.orm() as orm: 

175 orm.add(muc.stored) 

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

177 return muc 

178 

179 async def fill(self) -> None: 

180 """ 

181 Establish a user's known groups. 

182 

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

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

185 the groups a user is part of. 

186 

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

188 

189 """ 

190 if self.xmpp.GROUPS: 

191 raise NotImplementedError( 

192 "The plugin advertised support for groups but" 

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

194 ) 

195 

196 async def remove( 

197 self, 

198 muc: AnyMUC, 

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

200 kick: bool = True, 

201 ) -> None: 

202 """ 

203 Delete everything about a specific group. 

204 

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

206 app. 

207 

208 :param muc: The MUC to remove. 

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

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

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

212 receiving the confirmation that the group was deleted. 

213 """ 

214 if kick: 

215 user_participant = await muc.get_user_participant() 

216 user_participant.kick(reason) 

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

218 orm.add(muc.stored) 

219 orm.refresh(muc.stored) 

220 orm.delete(muc.stored) 

221 orm.commit() 

222 

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

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

225 with self.orm() as orm: 

226 orm.add(space) 

227 orm.refresh(space) 

228 if space.updated: 

229 return space 

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

231 return await self.__update_space_metadata(space, meta) 

232 

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

234 creator = ( 

235 await self.__get_stored_contact(meta.creator_legacy_id) 

236 if meta.creator_legacy_id 

237 else None 

238 ) 

239 owners: list[Contact] = [] 

240 for legacy_id in set(meta.owner_legacy_ids): 

241 if legacy_id == meta.creator_legacy_id: 

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

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

244 continue 

245 owner = await self.__get_stored_contact(legacy_id) 

246 if owner is not None: 

247 owners.append(owner) 

248 if ( 

249 creator is not None 

250 and meta.creator_legacy_id 

251 and meta.creator_legacy_id in meta.owner_legacy_ids 

252 ): 

253 owners.append(creator) 

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

255 if creator is not None: 

256 creator = orm.merge(creator) 

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

258 space = orm.merge(space) 

259 space.name = meta.name or space.legacy_id 

260 space.creator = creator 

261 space.owners = owners 

262 space.description = meta.description 

263 space.member_count = meta.member_count 

264 space.updated = True 

265 orm.commit() 

266 return space 

267 

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

269 try: 

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

271 legacy_id_str 

272 ) 

273 except Exception as e: 

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

275 return None 

276 return contact.stored 

277 

278 @abc.abstractmethod 

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

280 """ 

281 Fetch metadata associated to a space. 

282 

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

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

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

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

287 

288 :param legacy_id: Identifier of the space. 

289 

290 :return: Metadata associated to the space. 

291 """ 

292 raise NotImplementedError 

293 

294 async def update_spaces_info(self) -> None: 

295 with self.orm() as orm: 

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

297 orm, self.session.user_pk 

298 ) 

299 for space in spaces: 

300 await self.update_space_if_needed(space) 

301 

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

303 return str(legacy_id) 

304 

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

306 return node 

307 

308 async def update_space_metadata( 

309 self, 

310 legacy_id: str, 

311 metadata: SpaceMetadata, 

312 ) -> None: 

313 """ 

314 Updates metadata associated to a space. 

315 

316 :param legacy_id: Identifier of the space. 

317 :param name: Metadata associated to this space. 

318 """ 

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

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

321 orm, 

322 self.session.user_pk, 

323 str(legacy_id), 

324 ) 

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

326 self.xmpp.pubsub.broadcast_space_metadata( 

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

328 )