Coverage for slidge/contact/roster.py: 76%

130 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-18 04:30 +0000

1import asyncio 

2import logging 

3import warnings 

4from collections.abc import AsyncIterator, Iterator 

5 

6from slixmpp import JID 

7from slixmpp.exceptions import IqError, IqTimeout, XMPPError 

8from sqlalchemy.orm import Session 

9from sqlalchemy.orm import Session as OrmSession 

10 

11from ..db.models import Contact, GatewayUser 

12from ..util.jid_escaping import EscapeMixin 

13from ..util.lock import NamedLockMixin 

14from ..util.types import AnySession 

15from ..util.util import derive_wired_class, timeit 

16from .contact import LegacyContact 

17 

18 

19class ContactIsUser(Exception): 

20 pass 

21 

22 

23class LegacyRoster[LegacyContactType: LegacyContact]( 

24 NamedLockMixin, 

25 EscapeMixin, 

26): 

27 """ 

28 Virtual roster of a gateway user that allows to represent all 

29 of their contacts as singleton instances (if used properly and not too bugged). 

30 

31 Every :class:`.BaseSession` instance will have its own :class:`.LegacyRoster` instance 

32 accessible via the :attr:`.BaseSession.contacts` attribute. 

33 

34 Typically, you will mostly use the :meth:`.LegacyRoster.by_legacy_id` function to 

35 retrieve a contact instance. 

36 

37 You might need to override :meth:`.LegacyRoster.legacy_id_to_jid_username` and/or 

38 :meth:`.LegacyRoster.jid_username_to_legacy_id` to incorporate some custom logic 

39 if you need some characters when translation JID user parts and legacy IDs. 

40 """ 

41 

42 contact_cls: type[LegacyContactType] 

43 """ 

44 The concrete :class:`.LegacyContact` subclass this roster produces. 

45 

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

47 ``class Roster(LegacyRoster[Contact])`` produces ``Contact`` instances. 

48 """ 

49 

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

51 super().__init_subclass__(**kwargs) 

52 derive_wired_class(cls, LegacyRoster, "contact_cls") 

53 

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

55 super().__init__() 

56 

57 self.log = logging.getLogger(f"{session.user_jid.bare}:roster") 

58 self.user_legacy_id: str | None = None 

59 self.ready: asyncio.Future[bool] = session.xmpp.loop.create_future() 

60 

61 self.session = session 

62 self.__filling = False 

63 

64 @property 

65 def user(self) -> GatewayUser: 

66 return self.session.user 

67 

68 def orm(self) -> Session: 

69 return self.session.xmpp.store.session() 

70 

71 def from_store(self, stored: Contact) -> LegacyContactType: 

72 return self.contact_cls(self.session, stored=stored) 

73 

74 def __repr__(self) -> str: 

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

76 

77 def __iter__(self) -> Iterator[LegacyContactType]: 

78 with self.orm() as orm: 

79 contacts = orm.query(Contact).filter_by(user=self.user, updated=True).all() 

80 for stored in contacts: 

81 yield self.from_store(stored) 

82 

83 def known_contacts(self, only_friends: bool = True) -> dict[str, LegacyContactType]: 

84 if only_friends: 

85 return {c.jid.bare: c for c in self if c.is_friend} 

86 return {c.jid.bare: c for c in self} 

87 

88 async def by_jid(self, contact_jid: JID) -> LegacyContactType: 

89 # """ 

90 # Retrieve a contact by their JID 

91 # 

92 # If the contact was not instantiated before, it will be created 

93 # using :meth:`slidge.LegacyRoster.jid_username_to_legacy_id` to infer their 

94 # legacy user ID. 

95 # 

96 # :param contact_jid: 

97 # :return: 

98 # """ 

99 username = contact_jid.node 

100 if not username: 

101 raise XMPPError( 

102 "bad-request", "Contacts must have a local part in their JID" 

103 ) 

104 contact_jid = JID(contact_jid.bare) 

105 async with self.lock(("username", username)): 

106 legacy_id = await self.jid_username_to_legacy_id(username) 

107 if legacy_id == self.user_legacy_id: 

108 raise ContactIsUser 

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

110 self.log.debug("Already updating %s via by_legacy_id()", contact_jid) 

111 return await self.by_legacy_id(legacy_id) 

112 

113 with self.orm() as orm: 

114 stored = ( 

115 orm.query(Contact) 

116 .filter_by(user=self.user, jid_localpart=username) 

117 .one_or_none() 

118 ) 

119 if stored is None: 

120 stored = Contact( 

121 user_account_id=self.session.user_pk, 

122 legacy_id=legacy_id, 

123 jid_localpart=username, 

124 ) 

125 return await self.__update_if_needed(stored) 

126 

127 async def __update_if_needed(self, stored: Contact) -> LegacyContactType: 

128 contact = self.from_store(stored) 

129 if contact.stored.updated: 

130 return contact 

131 

132 with contact.updating_info(): 

133 await contact.update_info() 

134 if contact.is_friend and not self.__filling: 

135 await contact.add_to_roster() 

136 

137 if contact.cached_presence is not None: 

138 contact._store_last_presence(contact.cached_presence) 

139 return contact 

140 

141 def by_jid_only_if_exists(self, contact_jid: JID) -> LegacyContactType | None: 

142 with self.orm() as orm: 

143 stored = ( 

144 orm.query(Contact) 

145 .filter_by(user=self.user, jid_localpart=contact_jid.local) 

146 .one_or_none() 

147 ) 

148 if stored is not None and stored.updated: 

149 return self.from_store(stored) 

150 return None 

151 

152 @timeit 

153 async def by_legacy_id(self, /, legacy_id: str) -> LegacyContactType: 

154 """ 

155 Retrieve a contact by their legacy_id 

156 

157 If the contact was not instantiated before, it will be created 

158 using :meth:`slidge.LegacyRoster.legacy_id_to_jid_username` to infer their 

159 legacy user ID. 

160 

161 :param legacy_id: 

162 :return: 

163 """ 

164 if legacy_id == self.user_legacy_id: 

165 raise ContactIsUser 

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

167 username = await self.legacy_id_to_jid_username(legacy_id) 

168 if self.get_lock(("username", username)): 

169 self.log.debug("Already updating %s via by_jid()", username) 

170 

171 return await self.by_jid( 

172 JID(username + "@" + self.session.xmpp.boundjid.bare) 

173 ) 

174 

175 with self.orm() as orm: 

176 stored = ( 

177 orm.query(Contact) 

178 .filter_by(user=self.user, legacy_id=str(legacy_id)) 

179 .one_or_none() 

180 ) 

181 if stored is None: 

182 stored = Contact( 

183 user_account_id=self.session.user_pk, 

184 legacy_id=str(legacy_id), 

185 jid_localpart=username, 

186 ) 

187 return await self.__update_if_needed(stored) 

188 

189 @timeit 

190 async def _fill(self, orm: OrmSession) -> None: 

191 try: 

192 if hasattr(self.session.xmpp, "TEST_MODE"): 

193 # dirty hack to avoid mocking xmpp server replies to this 

194 # during tests 

195 raise PermissionError 

196 iq = await self.session.xmpp["xep_0356"].get_roster( 

197 self.session.user_jid.bare 

198 ) 

199 user_roster = iq["roster"]["items"] 

200 except (PermissionError, IqError, IqTimeout): 

201 user_roster = None 

202 

203 self.__filling = True 

204 async for contact in self.fill(): 

205 if user_roster is None: 

206 continue 

207 item = contact.get_roster_item() 

208 old = user_roster.get(contact.jid.bare) 

209 if old is not None and all( 

210 old[k] == item[contact.jid.bare].get(k) 

211 for k in ("subscription", "groups", "name") 

212 ): 

213 self.log.debug("No need to update roster") 

214 continue 

215 self.log.debug("Updating roster") 

216 if not contact.is_friend: 

217 continue 

218 if not self.session.user.preferences.get("roster_push", True): 

219 continue 

220 try: 

221 await self.session.xmpp["xep_0356"].set_roster( 

222 self.session.user_jid.bare, 

223 item, 

224 ) 

225 except (PermissionError, IqError, IqTimeout) as e: 

226 warnings.warn(f"Could not add to roster: {e}") 

227 else: 

228 contact.added_to_roster = True 

229 contact.send_last_presence(force=True) 

230 orm.commit() 

231 self.__filling = False 

232 

233 async def fill(self) -> AsyncIterator[LegacyContactType]: 

234 """ 

235 Populate slidge's "virtual roster". 

236 

237 This should yield contacts that are meant to be added to the user's 

238 roster, typically by using ``await self.by_legacy_id(contact_id)``. 

239 Setting the contact nicknames, avatar, etc. should be in 

240 :meth:`LegacyContact.update_info()` 

241 

242 It's not mandatory to override this method, but it is recommended way 

243 to populate "friends" of the user. Calling 

244 ``await (await self.by_legacy_id(contact_id)).add_to_roster()`` 

245 accomplishes the same thing, but doing it in here allows to batch 

246 DB queries and is better performance-wise. 

247 

248 """ 

249 return 

250 yield 

251 

252 async def on_create_group( 

253 self, name: str, contacts: list[LegacyContactType] 

254 ) -> str: 

255 """ 

256 Called when the user request the creation of a group via the dedicated 

257 :term:`Command`. 

258 

259 :param name: Name of the group. 

260 :param contacts: list of contacts that should be members of the group. 

261 :return: Legacy ID of the newly created group. 

262 """ 

263 raise NotImplementedError 

264 

265 

266# References to `LegacyRoster` need for this to be defined. 

267LegacyRoster.contact_cls = LegacyContact # type:ignore[misc] 

268 

269log = logging.getLogger(__name__)