Coverage for slidge/core/gateway.py: 64%

423 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-04 08:17 +0000

1""" 

2This module extends slixmpp.ComponentXMPP to make writing new LegacyClients easier 

3""" 

4 

5import asyncio 

6import logging 

7import re 

8import tempfile 

9from copy import copy 

10from datetime import datetime 

11from pathlib import Path 

12from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence, Type, Union, cast 

13 

14import aiohttp 

15import qrcode 

16from slixmpp import JID, ComponentXMPP, Iq, Message, Presence 

17from slixmpp.exceptions import IqError, IqTimeout, XMPPError 

18from slixmpp.plugins.xep_0004 import Form 

19from slixmpp.plugins.xep_0060.stanza import OwnerAffiliation 

20from slixmpp.types import MessageTypes 

21from slixmpp.xmlstream.xmlstream import NotConnectedError 

22 

23from slidge import LegacyContact, command # noqa: F401 

24from slidge.command.adhoc import AdhocProvider 

25from slidge.command.admin import Exec 

26from slidge.command.base import Command, FormField 

27from slidge.command.chat_command import ChatCommandProvider 

28from slidge.command.register import RegistrationType 

29from slidge.core import config 

30from slidge.core.dispatcher.session_dispatcher import SessionDispatcher 

31from slidge.core.mixins import MessageMixin 

32from slidge.core.mixins.avatar import convert_avatar 

33from slidge.core.pubsub import PubSubComponent 

34from slidge.core.session import BaseSession 

35from slidge.db import GatewayUser, SlidgeStore 

36from slidge.db.avatar import CachedAvatar, avatar_cache 

37from slidge.db.meta import JSONSerializable 

38from slidge.slixfix import PrivilegedIqError 

39from slidge.slixfix.delivery_receipt import DeliveryReceipt 

40from slidge.slixfix.roster import RosterBackend 

41from slidge.util import ABCSubclassableOnceAtMost 

42from slidge.util.types import Avatar, MessageOrPresenceTypeVar 

43from slidge.util.util import timeit 

44 

45if TYPE_CHECKING: 

46 pass 

47 

48 

49class BaseGateway( 

50 ComponentXMPP, 

51 MessageMixin, 

52 metaclass=ABCSubclassableOnceAtMost, 

53): 

54 """ 

55 The gateway component, handling registrations and un-registrations. 

56 

57 On slidge launch, a singleton is instantiated, and it will be made available 

58 to public classes such :class:`.LegacyContact` or :class:`.BaseSession` as the 

59 ``.xmpp`` attribute. 

60 

61 Must be subclassed by a legacy module to set up various aspects of the XMPP 

62 component behaviour, such as its display name or welcome message, via 

63 class attributes :attr:`.COMPONENT_NAME` :attr:`.WELCOME_MESSAGE`. 

64 

65 Abstract methods related to the registration process must be overriden 

66 for a functional :term:`Legacy Module`: 

67 

68 - :meth:`.validate` 

69 - :meth:`.validate_two_factor_code` 

70 - :meth:`.get_qr_text` 

71 - :meth:`.confirm_qr` 

72 

73 NB: Not all of these must be overridden, it depends on the 

74 :attr:`REGISTRATION_TYPE`. 

75 

76 The other methods, such as :meth:`.send_text` or :meth:`.react` are the same 

77 as those of :class:`.LegacyContact` and :class:`.LegacyParticipant`, because 

78 the component itself is also a "messaging actor", ie, an :term:`XMPP Entity`. 

79 For these methods, you need to specify the JID of the recipient with the 

80 `mto` parameter. 

81 

82 Since it inherits from :class:`slixmpp.componentxmpp.ComponentXMPP`,you also 

83 have a hand on low-level XMPP interactions via slixmpp methods, e.g.: 

84 

85 .. code-block:: python 

86 

87 self.send_presence( 

88 pfrom="somebody@component.example.com", 

89 pto="someonwelse@anotherexample.com", 

90 ) 

91 

92 However, you should not need to do so often since the classes of the plugin 

93 API provides higher level abstractions around most commonly needed use-cases, such 

94 as sending messages, or displaying a custom status. 

95 

96 """ 

97 

98 COMPONENT_NAME: str = NotImplemented 

99 """Name of the component, as seen in service discovery by XMPP clients""" 

100 COMPONENT_TYPE: str = "" 

101 """Type of the gateway, should follow https://xmpp.org/registrar/disco-categories.html""" 

102 COMPONENT_AVATAR: Optional[Avatar | Path | str] = None 

103 """ 

104 Path, bytes or URL used by the component as an avatar. 

105 """ 

106 

107 REGISTRATION_FIELDS: Sequence[FormField] = [ 

108 FormField(var="username", label="User name", required=True), 

109 FormField(var="password", label="Password", required=True, private=True), 

110 ] 

111 """ 

112 Iterable of fields presented to the gateway user when registering using :xep:`0077` 

113 `extended <https://xmpp.org/extensions/xep-0077.html#extensibility>`_ by :xep:`0004`. 

114 """ 

115 REGISTRATION_INSTRUCTIONS: str = "Enter your credentials" 

116 """ 

117 The text presented to a user that wants to register (or modify) their legacy account 

118 configuration. 

119 """ 

120 REGISTRATION_TYPE: RegistrationType = RegistrationType.SINGLE_STEP_FORM 

121 """ 

122 This attribute determines how users register to the gateway, ie, how they 

123 login to the :term:`legacy service <Legacy Service>`. 

124 The credentials are then stored persistently, so this process should happen 

125 once per user (unless they unregister). 

126 

127 The registration process always start with a basic data form (:xep:`0004`) 

128 presented to the user. 

129 But the legacy login flow might require something more sophisticated, see 

130 :class:`.RegistrationType` for more details. 

131 """ 

132 

133 REGISTRATION_2FA_TITLE = "Enter your 2FA code" 

134 REGISTRATION_2FA_INSTRUCTIONS = ( 

135 "You should have received something via email or SMS, or something" 

136 ) 

137 REGISTRATION_QR_INSTRUCTIONS = "Flash this code or follow this link" 

138 

139 PREFERENCES = [ 

140 FormField( 

141 var="sync_presence", 

142 label="Propagate your XMPP presence to the legacy network.", 

143 value="true", 

144 required=True, 

145 type="boolean", 

146 ), 

147 FormField( 

148 var="sync_avatar", 

149 label="Propagate your XMPP avatar to the legacy network.", 

150 value="true", 

151 required=True, 

152 type="boolean", 

153 ), 

154 FormField( 

155 var="always_invite_when_adding_bookmarks", 

156 label="Send an invitation to join MUCs after adding them to the bookmarks.", 

157 value="true", 

158 required=True, 

159 type="boolean", 

160 ), 

161 FormField( 

162 var="last_seen_fallback", 

163 label="Use contact presence status message to show when they were last seen.", 

164 value="true", 

165 required=True, 

166 type="boolean", 

167 ), 

168 FormField( 

169 var="roster_push", 

170 label="Add contacts to your roster.", 

171 value="true", 

172 required=True, 

173 type="boolean", 

174 ), 

175 ] 

176 

177 ROSTER_GROUP: str = "slidge" 

178 """ 

179 Name of the group assigned to a :class:`.LegacyContact` automagically 

180 added to the :term:`User`'s roster with :meth:`.LegacyContact.add_to_roster`. 

181 """ 

182 WELCOME_MESSAGE = ( 

183 "Thank you for registering. Type 'help' to list the available commands, " 

184 "or just start messaging away!" 

185 ) 

186 """ 

187 A welcome message displayed to users on registration. 

188 This is useful notably for clients that don't consider component JIDs as a 

189 valid recipient in their UI, yet still open a functional chat window on 

190 incoming messages from components. 

191 """ 

192 

193 SEARCH_FIELDS: Sequence[FormField] = [ 

194 FormField(var="first", label="First name", required=True), 

195 FormField(var="last", label="Last name", required=True), 

196 FormField(var="phone", label="Phone number", required=False), 

197 ] 

198 """ 

199 Fields used for searching items via the component, through :xep:`0055` (jabber search). 

200 A common use case is to allow users to search for legacy contacts by something else than 

201 their usernames, eg their phone number. 

202 

203 Plugins should implement search by overriding :meth:`.BaseSession.search` 

204 (restricted to registered users). 

205 

206 If there is only one field, it can also be used via the ``jabber:iq:gateway`` protocol 

207 described in :xep:`0100`. Limitation: this only works if the search request returns 

208 one result item, and if this item has a 'jid' var. 

209 """ 

210 SEARCH_TITLE: str = "Search for legacy contacts" 

211 """ 

212 Title of the search form. 

213 """ 

214 SEARCH_INSTRUCTIONS: str = "" 

215 """ 

216 Instructions of the search form. 

217 """ 

218 

219 MARK_ALL_MESSAGES = False 

220 """ 

221 Set this to True for :term:`legacy networks <Legacy Network>` that expects 

222 read marks for *all* messages and not just the latest one that was read 

223 (as most XMPP clients will only send a read mark for the latest msg). 

224 """ 

225 

226 PROPER_RECEIPTS = False 

227 """ 

228 Set this to True if the legacy service provides a real equivalent of message delivery receipts 

229 (:xep:`0184`), meaning that there is an event thrown when the actual device of a contact receives 

230 a message. Make sure to call Contact.received() adequately if this is set to True. 

231 """ 

232 

233 GROUPS = False 

234 

235 mtype: MessageTypes = "chat" 

236 is_group = False 

237 _can_send_carbon = False 

238 store: SlidgeStore 

239 

240 AVATAR_ID_TYPE: Callable[[str], Any] = str 

241 """ 

242 Modify this if the legacy network uses unique avatar IDs that are not strings. 

243 

244 This is required because we store those IDs as TEXT in the persistent SQL DB. 

245 The callable specified here will receive is responsible for converting the 

246 serialised-as-text version of the avatar unique ID back to the proper type. 

247 Common example: ``int``. 

248 """ 

249 # FIXME: do we really need this since we have session.xmpp_to_legacy_msg_id? 

250 # (maybe we do) 

251 LEGACY_MSG_ID_TYPE: Callable[[str], Any] = str 

252 """ 

253 Modify this if the legacy network uses unique message IDs that are not strings. 

254 

255 This is required because we store those IDs as TEXT in the persistent SQL DB. 

256 The callable specified here will receive is responsible for converting the 

257 serialised-as-text version of the message unique ID back to the proper type. 

258 Common example: ``int``. 

259 """ 

260 LEGACY_CONTACT_ID_TYPE: Callable[[str], Any] = str 

261 """ 

262 Modify this if the legacy network uses unique contact IDs that are not strings. 

263 

264 This is required because we store those IDs as TEXT in the persistent SQL DB. 

265 The callable specified here is responsible for converting the 

266 serialised-as-text version of the contact unique ID back to the proper type. 

267 Common example: ``int``. 

268 """ 

269 LEGACY_ROOM_ID_TYPE: Callable[[str], Any] = str 

270 """ 

271 Modify this if the legacy network uses unique room IDs that are not strings. 

272 

273 This is required because we store those IDs as TEXT in the persistent SQL DB. 

274 The callable specified here is responsible for converting the 

275 serialised-as-text version of the room unique ID back to the proper type. 

276 Common example: ``int``. 

277 """ 

278 

279 http: aiohttp.ClientSession 

280 avatar: CachedAvatar | None = None 

281 

282 def __init__(self) -> None: 

283 if config.COMPONENT_NAME: 

284 self.COMPONENT_NAME = config.COMPONENT_NAME 

285 if config.WELCOME_MESSAGE: 

286 self.WELCOME_MESSAGE = config.WELCOME_MESSAGE 

287 self.log = log 

288 self.datetime_started = datetime.now() 

289 self.xmpp = self # ugly hack to work with the BaseSender mixin :/ 

290 self.default_ns = "jabber:component:accept" 

291 super().__init__( 

292 config.JID, 

293 config.SECRET, 

294 config.SERVER, 

295 config.PORT, 

296 plugin_whitelist=SLIXMPP_PLUGINS, 

297 plugin_config={ 

298 "xep_0077": { 

299 "form_fields": None, 

300 "form_instructions": self.REGISTRATION_INSTRUCTIONS, 

301 "enable_subscription": self.REGISTRATION_TYPE 

302 == RegistrationType.SINGLE_STEP_FORM, 

303 }, 

304 "xep_0100": { 

305 "component_name": self.COMPONENT_NAME, 

306 "type": self.COMPONENT_TYPE, 

307 }, 

308 "xep_0184": { 

309 "auto_ack": False, 

310 "auto_request": False, 

311 }, 

312 "xep_0363": { 

313 "upload_service": config.UPLOAD_SERVICE, 

314 }, 

315 }, 

316 fix_error_ns=True, 

317 ) 

318 self.loop.set_exception_handler(self.__exception_handler) 

319 self.loop.create_task(self.__set_http()) 

320 self.has_crashed: bool = False 

321 self.use_origin_id = False 

322 

323 self.jid_validator: re.Pattern = re.compile(config.USER_JID_VALIDATOR) 

324 self.qr_pending_registrations = dict[str, asyncio.Future[Optional[dict]]]() 

325 

326 self.__setup_legacy_module_subclasses() 

327 

328 self.get_session_from_stanza: Callable[ 

329 [Union[Message, Presence, Iq]], BaseSession 

330 ] = self._session_cls.from_stanza 

331 self.get_session_from_user: Callable[[GatewayUser], BaseSession] = ( 

332 self._session_cls.from_user 

333 ) 

334 

335 self.register_plugins() 

336 self.__register_slixmpp_events() 

337 self.__register_slixmpp_api() 

338 self.roster.set_backend(RosterBackend(self)) 

339 

340 self.register_plugin("pubsub", {"component_name": self.COMPONENT_NAME}) 

341 self.pubsub: PubSubComponent = self["pubsub"] 

342 self.delivery_receipt: DeliveryReceipt = DeliveryReceipt(self) 

343 

344 # with this we receive user avatar updates 

345 self.plugin["xep_0030"].add_feature("urn:xmpp:avatar:metadata+notify") 

346 

347 self.plugin["xep_0030"].add_feature("urn:xmpp:chat-markers:0") 

348 

349 if self.GROUPS: 

350 self.plugin["xep_0030"].add_feature("http://jabber.org/protocol/muc") 

351 self.plugin["xep_0030"].add_feature("urn:xmpp:mam:2") 

352 self.plugin["xep_0030"].add_feature("urn:xmpp:mam:2#extended") 

353 self.plugin["xep_0030"].add_feature(self.plugin["xep_0421"].namespace) 

354 self.plugin["xep_0030"].add_feature(self["xep_0317"].stanza.NS) 

355 self.plugin["xep_0030"].add_identity( 

356 category="conference", 

357 name=self.COMPONENT_NAME, 

358 itype="text", 

359 jid=self.boundjid, 

360 ) 

361 

362 # why does mypy need these type annotations? no idea 

363 self.__adhoc_handler: AdhocProvider = AdhocProvider(self) 

364 self.__chat_commands_handler: ChatCommandProvider = ChatCommandProvider(self) 

365 

366 self.__dispatcher = SessionDispatcher(self) 

367 

368 self.__register_commands() 

369 

370 MessageMixin.__init__(self) # ComponentXMPP does not call super().__init__() 

371 

372 def __setup_legacy_module_subclasses(self): 

373 from ..contact.roster import LegacyRoster 

374 from ..group.bookmarks import LegacyBookmarks 

375 from ..group.room import LegacyMUC, LegacyParticipant 

376 

377 session_cls: Type[BaseSession] = cast( 

378 Type[BaseSession], BaseSession.get_unique_subclass() 

379 ) 

380 contact_cls = LegacyContact.get_self_or_unique_subclass() 

381 muc_cls = LegacyMUC.get_self_or_unique_subclass() 

382 participant_cls = LegacyParticipant.get_self_or_unique_subclass() 

383 bookmarks_cls = LegacyBookmarks.get_self_or_unique_subclass() 

384 roster_cls = LegacyRoster.get_self_or_unique_subclass() 

385 

386 session_cls.xmpp = self # type:ignore[attr-defined] 

387 contact_cls.xmpp = self # type:ignore[attr-defined] 

388 muc_cls.xmpp = self # type:ignore[attr-defined] 

389 

390 self._session_cls = session_cls 

391 session_cls._bookmarks_cls = bookmarks_cls # type:ignore[misc,assignment] 

392 session_cls._roster_cls = roster_cls # type:ignore[misc,assignment] 

393 LegacyRoster._contact_cls = contact_cls # type:ignore[misc] 

394 LegacyBookmarks._muc_cls = muc_cls # type:ignore[misc] 

395 LegacyMUC._participant_cls = participant_cls # type:ignore[misc] 

396 

397 async def kill_session(self, jid: JID) -> None: 

398 await self._session_cls.kill_by_jid(jid) 

399 

400 async def __set_http(self) -> None: 

401 self.http = aiohttp.ClientSession() 

402 if getattr(self, "_test_mode", False): 

403 return 

404 avatar_cache.http = self.http 

405 

406 def __register_commands(self) -> None: 

407 for cls in Command.subclasses: 

408 if any(x is NotImplemented for x in [cls.CHAT_COMMAND, cls.NODE, cls.NAME]): 

409 log.debug("Not adding command '%s' because it looks abstract", cls) 

410 continue 

411 if cls is Exec: 

412 if config.DEV_MODE: 

413 log.warning(r"/!\ DEV MODE ENABLED /!\\") 

414 else: 

415 continue 

416 c = cls(self) 

417 log.debug("Registering %s", cls) 

418 self.__adhoc_handler.register(c) 

419 self.__chat_commands_handler.register(c) 

420 

421 def __exception_handler(self, loop: asyncio.AbstractEventLoop, context) -> None: 

422 """ 

423 Called when a task created by loop.create_task() raises an Exception 

424 

425 :param loop: 

426 :param context: 

427 :return: 

428 """ 

429 log.debug("Context in the exception handler: %s", context) 

430 exc = context.get("exception") 

431 if exc is None: 

432 log.debug("No exception in this context: %s", context) 

433 elif isinstance(exc, SystemExit): 

434 log.debug("SystemExit called in an asyncio task") 

435 else: 

436 log.error("Crash in an asyncio task: %s", context) 

437 log.exception("Crash in task", exc_info=exc) 

438 self.has_crashed = True 

439 loop.stop() 

440 

441 def __register_slixmpp_events(self) -> None: 

442 self.del_event_handler("presence_subscribe", self._handle_subscribe) 

443 self.del_event_handler("presence_unsubscribe", self._handle_unsubscribe) 

444 self.del_event_handler("presence_subscribed", self._handle_subscribed) 

445 self.del_event_handler("presence_unsubscribed", self._handle_unsubscribed) 

446 self.del_event_handler( 

447 "roster_subscription_request", self._handle_new_subscription 

448 ) 

449 self.del_event_handler("presence_probe", self._handle_probe) 

450 self.add_event_handler("session_start", self.__on_session_start) 

451 self.add_event_handler("disconnected", self.connect) 

452 

453 def __register_slixmpp_api(self) -> None: 

454 def with_session(func, commit: bool = True): 

455 def wrapped(*a, **kw): 

456 with self.store.session() as orm: 

457 res = func(orm, *a, **kw) 

458 if commit: 

459 orm.commit() 

460 return res 

461 

462 return wrapped 

463 

464 self.plugin["xep_0231"].api.register( 

465 with_session(self.store.bob.get_bob, False), "get_bob" 

466 ) 

467 self.plugin["xep_0231"].api.register( 

468 with_session(self.store.bob.set_bob), "set_bob" 

469 ) 

470 self.plugin["xep_0231"].api.register( 

471 with_session(self.store.bob.del_bob), "del_bob" 

472 ) 

473 

474 @property # type: ignore 

475 def jid(self): 

476 # Override to avoid slixmpp deprecation warnings. 

477 return self.boundjid 

478 

479 async def __on_session_start(self, event) -> None: 

480 log.debug("Gateway session start: %s", event) 

481 

482 # prevents XMPP clients from considering the gateway as an HTTP upload 

483 disco = self.plugin["xep_0030"] 

484 await disco.del_feature(feature="urn:xmpp:http:upload:0", jid=self.boundjid) 

485 await self.plugin["xep_0115"].update_caps(jid=self.boundjid) 

486 

487 if self.COMPONENT_AVATAR is not None: 

488 log.debug("Setting gateway avatar…") 

489 avatar = convert_avatar(self.COMPONENT_AVATAR, "!!---slidge---special---") 

490 assert avatar is not None 

491 try: 

492 cached_avatar = await avatar_cache.convert_or_get(avatar) 

493 except Exception as e: 

494 log.exception("Could not set the component avatar.", exc_info=e) 

495 cached_avatar = None 

496 else: 

497 assert cached_avatar is not None 

498 self.avatar = cached_avatar 

499 else: 

500 cached_avatar = None 

501 

502 with self.store.session() as orm: 

503 for user in orm.query(GatewayUser).all(): 

504 # TODO: before this, we should check if the user has removed us from their roster 

505 # while we were offline and trigger unregister from there. Presence probe does not seem 

506 # to work in this case, there must be another way. privileged entity could be used 

507 # as last resort. 

508 try: 

509 await self["xep_0100"].add_component_to_roster(user.jid) 

510 await self.__add_component_to_mds_whitelist(user.jid) 

511 except (IqError, IqTimeout) as e: 

512 # TODO: remove the user when this happens? or at least 

513 # this can happen when the user has unsubscribed from the XMPP server 

514 log.warning( 

515 "Error with user %s, not logging them automatically", 

516 user, 

517 exc_info=e, 

518 ) 

519 continue 

520 session = self._session_cls.from_user(user) 

521 session.create_task(self.login_wrap(session)) 

522 if cached_avatar is not None: 

523 await self.pubsub.broadcast_avatar( 

524 self.boundjid.bare, session.user_jid, cached_avatar 

525 ) 

526 

527 log.info("Slidge has successfully started") 

528 

529 async def __add_component_to_mds_whitelist(self, user_jid: JID) -> None: 

530 # Uses privileged entity to add ourselves to the whitelist of the PEP 

531 # MDS node so we receive MDS events 

532 iq_creation = Iq(sto=user_jid.bare, sfrom=user_jid, stype="set") 

533 iq_creation["pubsub"]["create"]["node"] = self["xep_0490"].stanza.NS 

534 

535 try: 

536 await self["xep_0356"].send_privileged_iq(iq_creation) 

537 except PermissionError: 

538 log.warning( 

539 "IQ privileges not granted for pubsub namespace, we cannot " 

540 "create the MDS node of %s", 

541 user_jid, 

542 ) 

543 except PrivilegedIqError as exc: 

544 nested = exc.nested_error() 

545 # conflict this means the node already exists, we can ignore that 

546 if nested is not None and nested.condition != "conflict": 

547 log.exception( 

548 "Could not create the MDS node of %s", user_jid, exc_info=exc 

549 ) 

550 except Exception as e: 

551 log.exception( 

552 "Error while trying to create to the MDS node of %s", 

553 user_jid, 

554 exc_info=e, 

555 ) 

556 

557 iq_affiliation = Iq(sto=user_jid.bare, sfrom=user_jid, stype="set") 

558 iq_affiliation["pubsub_owner"]["affiliations"]["node"] = self[ 

559 "xep_0490" 

560 ].stanza.NS 

561 

562 aff = OwnerAffiliation() 

563 aff["jid"] = self.boundjid.bare 

564 aff["affiliation"] = "member" 

565 iq_affiliation["pubsub_owner"]["affiliations"].append(aff) 

566 

567 try: 

568 await self["xep_0356"].send_privileged_iq(iq_affiliation) 

569 except PermissionError: 

570 log.warning( 

571 "IQ privileges not granted for pubsub#owner namespace, we cannot " 

572 "listen to the MDS events of %s", 

573 user_jid, 

574 ) 

575 except Exception as e: 

576 log.exception( 

577 "Error while trying to subscribe to the MDS node of %s", 

578 user_jid, 

579 exc_info=e, 

580 ) 

581 

582 @timeit 

583 async def login_wrap(self, session: "BaseSession") -> None: 

584 session.send_gateway_status("Logging in…", show="dnd") 

585 session.is_logging_in = True 

586 try: 

587 status = await session.login() 

588 except Exception as e: 

589 log.warning("Login problem for %s", session.user_jid, exc_info=e) 

590 log.exception(e) 

591 session.send_gateway_status(f"Could not login: {e}", show="busy") 

592 session.send_gateway_message( 

593 "You are not connected to this gateway! " 

594 f"Maybe this message will tell you why: {e}" 

595 ) 

596 session.logged = False 

597 return 

598 

599 log.info("Login success for %s", session.user_jid) 

600 session.logged = True 

601 session.send_gateway_status("Syncing contacts…", show="dnd") 

602 with self.store.session() as orm: 

603 await session.contacts._fill(orm) 

604 if not (r := session.contacts.ready).done(): 

605 r.set_result(True) 

606 if self.GROUPS: 

607 session.send_gateway_status("Syncing groups…", show="dnd") 

608 await session.bookmarks.fill() 

609 if not (r := session.bookmarks.ready).done(): 

610 r.set_result(True) 

611 self.send_presence(pto=session.user.jid.bare, ptype="probe") 

612 if status is None: 

613 session.send_gateway_status("Logged in", show="chat") 

614 else: 

615 session.send_gateway_status(status, show="chat") 

616 if session.user.preferences.get("sync_avatar", False): 

617 session.create_task(self.fetch_user_avatar(session)) 

618 else: 

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

620 session.user.avatar_hash = None 

621 orm.add(session.user) 

622 orm.commit() 

623 

624 async def fetch_user_avatar(self, session: BaseSession) -> None: 

625 try: 

626 iq = await self.xmpp.plugin["xep_0060"].get_items( 

627 session.user_jid.bare, 

628 self.xmpp.plugin["xep_0084"].stanza.MetaData.namespace, 

629 ifrom=self.boundjid.bare, 

630 ) 

631 except IqTimeout: 

632 self.log.warning("Iq timeout trying to fetch user avatar") 

633 return 

634 except IqError as e: 

635 self.log.debug("Iq error when trying to fetch user avatar: %s", e) 

636 if e.condition == "item-not-found": 

637 try: 

638 await session.on_avatar(None, None, None, None, None) 

639 except NotImplementedError: 

640 pass 

641 else: 

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

643 session.user.avatar_hash = None 

644 orm.add(session.user) 

645 orm.commit() 

646 return 

647 await self.__dispatcher.on_avatar_metadata_info( 

648 session, iq["pubsub"]["items"]["item"]["avatar_metadata"]["info"] 

649 ) 

650 

651 def _send( 

652 self, stanza: MessageOrPresenceTypeVar, **send_kwargs 

653 ) -> MessageOrPresenceTypeVar: 

654 stanza.set_from(self.boundjid.bare) 

655 if mto := send_kwargs.get("mto"): 

656 stanza.set_to(mto) 

657 stanza.send() 

658 return stanza 

659 

660 def raise_if_not_allowed_jid(self, jid: JID): 

661 if not self.jid_validator.match(jid.bare): 

662 raise XMPPError( 

663 condition="not-allowed", 

664 text="Your account is not allowed to use this gateway.", 

665 ) 

666 

667 def send_raw(self, data: Union[str, bytes]): 

668 # overridden from XMLStream to strip base64-encoded data from the logs 

669 # to make them more readable. 

670 if log.isEnabledFor(level=logging.DEBUG): 

671 if isinstance(data, str): 

672 stripped = copy(data) 

673 else: 

674 stripped = data.decode("utf-8") 

675 # there is probably a way to do that in a single RE, 

676 # but since it's only for debugging, the perf penalty 

677 # does not matter much 

678 for el in LOG_STRIP_ELEMENTS: 

679 stripped = re.sub( 

680 f"(<{el}.*?>)(.*)(</{el}>)", 

681 "\1[STRIPPED]\3", 

682 stripped, 

683 flags=re.DOTALL | re.IGNORECASE, 

684 ) 

685 log.debug("SEND: %s", stripped) 

686 if not self.transport: 

687 raise NotConnectedError() 

688 if isinstance(data, str): 

689 data = data.encode("utf-8") 

690 self.transport.write(data) 

691 

692 def get_session_from_jid(self, j: JID): 

693 try: 

694 return self._session_cls.from_jid(j) 

695 except XMPPError: 

696 pass 

697 

698 def exception(self, exception: Exception) -> None: 

699 # """ 

700 # Called when a task created by slixmpp's internal (eg, on slix events) raises an Exception. 

701 # 

702 # Stop the event loop and exit on unhandled exception. 

703 # 

704 # The default :class:`slixmpp.basexmpp.BaseXMPP` behaviour is just to 

705 # log the exception, but we want to avoid undefined behaviour. 

706 # 

707 # :param exception: An unhandled :class:`Exception` object. 

708 # """ 

709 if isinstance(exception, IqError): 

710 iq = exception.iq 

711 log.error("%s: %s", iq["error"]["condition"], iq["error"]["text"]) 

712 log.warning("You should catch IqError exceptions") 

713 elif isinstance(exception, IqTimeout): 

714 iq = exception.iq 

715 log.error("Request timed out: %s", iq) 

716 log.warning("You should catch IqTimeout exceptions") 

717 elif isinstance(exception, SyntaxError): 

718 # Hide stream parsing errors that occur when the 

719 # stream is disconnected (they've been handled, we 

720 # don't need to make a mess in the logs). 

721 pass 

722 else: 

723 if exception: 

724 log.exception(exception) 

725 self.loop.stop() 

726 exit(1) 

727 

728 def re_login(self, session: "BaseSession") -> None: 

729 async def w() -> None: 

730 session.cancel_all_tasks() 

731 try: 

732 await session.logout() 

733 except NotImplementedError: 

734 pass 

735 await self.login_wrap(session) 

736 

737 session.create_task(w()) 

738 

739 async def make_registration_form( 

740 self, _jid: JID, _node: str, _ifrom: JID, iq: Iq 

741 ) -> Form: 

742 self.raise_if_not_allowed_jid(iq.get_from()) 

743 reg = iq["register"] 

744 with self.store.session() as orm: 

745 user = ( 

746 orm.query(GatewayUser).filter_by(jid=iq.get_from().bare).one_or_none() 

747 ) 

748 log.debug("User found: %s", user) 

749 

750 form = reg["form"] 

751 form.add_field( 

752 "FORM_TYPE", 

753 ftype="hidden", 

754 value="jabber:iq:register", 

755 ) 

756 form["title"] = f"Registration to '{self.COMPONENT_NAME}'" 

757 form["instructions"] = self.REGISTRATION_INSTRUCTIONS 

758 

759 if user is not None: 

760 reg["registered"] = False 

761 form.add_field( 

762 "remove", 

763 label="Remove my registration", 

764 required=True, 

765 ftype="boolean", 

766 value=False, 

767 ) 

768 

769 for field in self.REGISTRATION_FIELDS: 

770 if field.var in reg.interfaces: 

771 val = None if user is None else user.get(field.var) 

772 if val is None: 

773 reg.add_field(field.var) 

774 else: 

775 reg[field.var] = val 

776 

777 reg["instructions"] = self.REGISTRATION_INSTRUCTIONS 

778 

779 for field in self.REGISTRATION_FIELDS: 

780 form.add_field( 

781 field.var, 

782 label=field.label, 

783 required=field.required, 

784 ftype=field.type, 

785 options=field.options, 

786 value=field.value if user is None else user.get(field.var, field.value), 

787 ) 

788 

789 reply = iq.reply() 

790 reply.set_payload(reg) 

791 return reply 

792 

793 async def user_prevalidate( 

794 self, ifrom: JID, form_dict: dict[str, Optional[str]] 

795 ) -> JSONSerializable | None: 

796 # Pre validate a registration form using the content of self.REGISTRATION_FIELDS 

797 # before passing it to the plugin custom validation logic. 

798 for field in self.REGISTRATION_FIELDS: 

799 if field.required and not form_dict.get(field.var): 

800 raise ValueError(f"Missing field: '{field.label}'") 

801 

802 return await self.validate(ifrom, form_dict) 

803 

804 async def validate( 

805 self, user_jid: JID, registration_form: dict[str, Optional[str]] 

806 ) -> JSONSerializable | None: 

807 """ 

808 Validate a user's initial registration form. 

809 

810 Should raise the appropriate :class:`slixmpp.exceptions.XMPPError` 

811 if the registration does not allow to continue the registration process. 

812 

813 If :py:attr:`REGISTRATION_TYPE` is a 

814 :attr:`.RegistrationType.SINGLE_STEP_FORM`, 

815 this method should raise something if it wasn't possible to successfully 

816 log in to the legacy service with the registration form content. 

817 

818 It is also used for other types of :py:attr:`REGISTRATION_TYPE` too, since 

819 the first step is always a form. If :attr:`.REGISTRATION_FIELDS` is an 

820 empty list (ie, it declares no :class:`.FormField`), the "form" is 

821 effectively a confirmation dialog displaying 

822 :attr:`.REGISTRATION_INSTRUCTIONS`. 

823 

824 :param user_jid: JID of the user that has just registered 

825 :param registration_form: A dict where keys are the :attr:`.FormField.var` attributes 

826 of the :attr:`.BaseGateway.REGISTRATION_FIELDS` iterable. 

827 This dict can be modified and will be accessible as the ``legacy_module_data`` 

828 of the 

829 

830 :return : A dict that will be stored as the persistent "legacy_module_data" 

831 for this user. If you don't return anything here, the whole registration_form 

832 content will be stored. 

833 """ 

834 raise NotImplementedError 

835 

836 async def validate_two_factor_code( 

837 self, user: GatewayUser, code: str 

838 ) -> JSONSerializable | None: 

839 """ 

840 Called when the user enters their 2FA code. 

841 

842 Should raise the appropriate :class:`slixmpp.exceptions.XMPPError` 

843 if the login fails, and return successfully otherwise. 

844 

845 Only used when :attr:`REGISTRATION_TYPE` is 

846 :attr:`.RegistrationType.TWO_FACTOR_CODE`. 

847 

848 :param user: The :class:`.GatewayUser` whose registration is pending 

849 Use their :attr:`.GatewayUser.bare_jid` and/or 

850 :attr:`.registration_form` attributes to get what you need. 

851 :param code: The code they entered, either via "chatbot" message or 

852 adhoc command 

853 

854 :return : A dict which keys and values will be added to the persistent "legacy_module_data" 

855 for this user. 

856 """ 

857 raise NotImplementedError 

858 

859 async def get_qr_text(self, user: GatewayUser) -> str: 

860 """ 

861 This is where slidge gets the QR code content for the QR-based 

862 registration process. It will turn it into a QR code image and send it 

863 to the not-yet-fully-registered :class:`.GatewayUser`. 

864 

865 Only used in when :attr:`BaseGateway.REGISTRATION_TYPE` is 

866 :attr:`.RegistrationType.QRCODE`. 

867 

868 :param user: The :class:`.GatewayUser` whose registration is pending 

869 Use their :attr:`.GatewayUser.bare_jid` and/or 

870 :attr:`.registration_form` attributes to get what you need. 

871 """ 

872 raise NotImplementedError 

873 

874 async def confirm_qr( 

875 self, 

876 user_bare_jid: str, 

877 exception: Optional[Exception] = None, 

878 legacy_data: Optional[JSONSerializable] = None, 

879 ) -> None: 

880 """ 

881 This method is meant to be called to finalize QR code-based registration 

882 flows, once the legacy service confirms the QR flashing. 

883 

884 Only used in when :attr:`BaseGateway.REGISTRATION_TYPE` is 

885 :attr:`.RegistrationType.QRCODE`. 

886 

887 :param user_bare_jid: The bare JID of the almost-registered 

888 :class:`GatewayUser` instance 

889 :param exception: Optionally, an XMPPError to be raised to **not** confirm 

890 QR code flashing. 

891 :param legacy_data: dict which keys and values will be added to the persistent 

892 "legacy_module_data" for this user. 

893 """ 

894 fut = self.qr_pending_registrations[user_bare_jid] 

895 if exception is None: 

896 fut.set_result(legacy_data) 

897 else: 

898 fut.set_exception(exception) 

899 

900 async def unregister_user(self, user: GatewayUser) -> None: 

901 self.send_presence( 

902 pshow="dnd", 

903 pstatus="You unregistered from this gateway.", 

904 pto=user.jid, 

905 ) 

906 await self.xmpp.plugin["xep_0077"].api["user_remove"](None, None, user.jid) 

907 await self.xmpp._session_cls.kill_by_jid(user.jid) 

908 

909 async def unregister(self, user: GatewayUser) -> None: 

910 """ 

911 Optionally override this if you need to clean additional 

912 stuff after a user has been removed from the persistent user store. 

913 

914 By default, this just calls :meth:`BaseSession.logout`. 

915 

916 :param user: 

917 """ 

918 session = self.get_session_from_user(user) 

919 try: 

920 await session.logout() 

921 except NotImplementedError: 

922 pass 

923 

924 async def input( 

925 self, 

926 jid: JID, 

927 text: str | None = None, 

928 mtype: MessageTypes = "chat", 

929 **input_kwargs: Any, 

930 ) -> str: 

931 """ 

932 Request arbitrary user input using a simple chat message, and await the result. 

933 

934 You shouldn't need to call this directly bust instead use 

935 :meth:`.BaseSession.input` to directly target a user. 

936 

937 :param jid: The JID we want input from 

938 :param text: A prompt to display for the user 

939 :param mtype: Message type 

940 :return: The user's reply 

941 """ 

942 return await self.__chat_commands_handler.input( 

943 jid, text, mtype=mtype, **input_kwargs 

944 ) 

945 

946 async def send_qr(self, text: str, **msg_kwargs: Any) -> None: 

947 """ 

948 Sends a QR Code to a JID 

949 

950 You shouldn't need to call directly bust instead use 

951 :meth:`.BaseSession.send_qr` to directly target a user. 

952 

953 :param text: The text that will be converted to a QR Code 

954 :param msg_kwargs: Optional additional arguments to pass to 

955 :meth:`.BaseGateway.send_file`, such as the recipient of the QR, 

956 code 

957 """ 

958 qr = qrcode.make(text) 

959 with tempfile.NamedTemporaryFile( 

960 suffix=".png", delete=config.NO_UPLOAD_METHOD != "move" 

961 ) as f: 

962 qr.save(f.name) 

963 await self.send_file(Path(f.name), **msg_kwargs) 

964 

965 def shutdown(self) -> list[asyncio.Task[None]]: 

966 # """ 

967 # Called by the slidge entrypoint on normal exit. 

968 # 

969 # Sends offline presences from all contacts of all user sessions and from 

970 # the gateway component itself. 

971 # No need to call this manually, :func:`slidge.__main__.main` should take care of it. 

972 # """ 

973 log.debug("Shutting down") 

974 tasks = [] 

975 with self.store.session() as orm: 

976 for user in orm.query(GatewayUser).all(): 

977 tasks.append(self._session_cls.from_jid(user.jid).shutdown()) 

978 self.send_presence(ptype="unavailable", pto=user.jid) 

979 return tasks 

980 

981 

982SLIXMPP_PLUGINS = [ 

983 "link_preview", # https://wiki.soprani.ca/CheogramApp/LinkPreviews 

984 "xep_0030", # Service discovery 

985 "xep_0045", # Multi-User Chat 

986 "xep_0050", # Adhoc commands 

987 "xep_0054", # VCard-temp (for MUC avatars) 

988 "xep_0055", # Jabber search 

989 "xep_0059", # Result Set Management 

990 "xep_0066", # Out of Band Data 

991 "xep_0071", # XHTML-IM (for stickers and custom emojis maybe later) 

992 "xep_0077", # In-band registration 

993 "xep_0084", # User Avatar 

994 "xep_0085", # Chat state notifications 

995 "xep_0100", # Gateway interaction 

996 "xep_0106", # JID Escaping 

997 "xep_0115", # Entity capabilities 

998 "xep_0122", # Data Forms Validation 

999 "xep_0153", # vCard-Based Avatars (for MUC avatars) 

1000 "xep_0172", # User nickname 

1001 "xep_0184", # Message Delivery Receipts 

1002 "xep_0199", # XMPP Ping 

1003 "xep_0221", # Data Forms Media Element 

1004 "xep_0231", # Bits of Binary (for stickers and custom emojis maybe later) 

1005 "xep_0249", # Direct MUC Invitations 

1006 "xep_0264", # Jingle Content Thumbnails 

1007 "xep_0280", # Carbons 

1008 "xep_0292_provider", # VCard4 

1009 "xep_0308", # Last message correction 

1010 "xep_0313", # Message Archive Management 

1011 "xep_0317", # Hats 

1012 "xep_0319", # Last User Interaction in Presence 

1013 "xep_0333", # Chat markers 

1014 "xep_0334", # Message Processing Hints 

1015 "xep_0356", # Privileged Entity 

1016 "xep_0363", # HTTP file upload 

1017 "xep_0385", # Stateless in-line media sharing 

1018 "xep_0402", # PEP Native Bookmarks 

1019 "xep_0421", # Anonymous unique occupant identifiers for MUCs 

1020 "xep_0424", # Message retraction 

1021 "xep_0425", # Message moderation 

1022 "xep_0444", # Message reactions 

1023 "xep_0447", # Stateless File Sharing 

1024 "xep_0461", # Message replies 

1025 "xep_0469", # Bookmark Pinning 

1026 "xep_0490", # Message Displayed Synchronization 

1027 "xep_0492", # Chat Notification Settings 

1028] 

1029 

1030LOG_STRIP_ELEMENTS = ["data", "binval"] 

1031 

1032log = logging.getLogger(__name__)