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

455 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-03-13 22:59 +0000

1""" 

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

3""" 

4 

5import abc 

6import asyncio 

7import logging 

8import re 

9import tempfile 

10from collections.abc import Callable, Sequence 

11from copy import copy 

12from datetime import datetime 

13from pathlib import Path 

14from typing import TYPE_CHECKING, Any, cast 

15 

16import aiohttp 

17import qrcode 

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

19from slixmpp.exceptions import IqError, IqTimeout, XMPPError 

20from slixmpp.plugins.xep_0004 import Form 

21from slixmpp.plugins.xep_0060.stanza import OwnerAffiliation 

22from slixmpp.plugins.xep_0356.permissions import IqPermission 

23from slixmpp.plugins.xep_0356.privilege import PrivilegedIqError 

24from slixmpp.types import MessageTypes 

25from slixmpp.xmlstream.xmlstream import NotConnectedError 

26 

27from slidge import LegacyContact, command # noqa: F401 

28from slidge.command.adhoc import AdhocProvider 

29from slidge.command.admin import Exec 

30from slidge.command.base import Command, FormField 

31from slidge.command.chat_command import ChatCommandProvider 

32from slidge.command.register import RegistrationType 

33from slidge.core import config 

34from slidge.core.dispatcher.session_dispatcher import SessionDispatcher 

35from slidge.core.mixins import MessageMixin 

36from slidge.core.mixins.attachment import AttachmentMixin 

37from slidge.core.mixins.avatar import convert_avatar 

38from slidge.core.pubsub import PubSubComponent 

39from slidge.core.session import BaseSession 

40from slidge.db import GatewayUser, SlidgeStore 

41from slidge.db.avatar import CachedAvatar, avatar_cache 

42from slidge.db.meta import JSONSerializable 

43from slidge.slixfix.delivery_receipt import DeliveryReceipt 

44from slidge.slixfix.roster import RosterBackend 

45from slidge.util import SubclassableOnce 

46from slidge.util.types import Avatar, MessageOrPresenceTypeVar 

47from slidge.util.util import timeit 

48 

49if TYPE_CHECKING: 

50 pass 

51 

52 

53class BaseGateway( 

54 ComponentXMPP, 

55 MessageMixin, 

56 SubclassableOnce, 

57 abc.ABC, 

58): 

59 """ 

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

61 

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

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

64 ``.xmpp`` attribute. 

65 

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

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

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

69 

70 Abstract methods related to the registration process must be overriden 

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

72 

73 - :meth:`.validate` 

74 - :meth:`.validate_two_factor_code` 

75 - :meth:`.get_qr_text` 

76 - :meth:`.confirm_qr` 

77 

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

79 :attr:`REGISTRATION_TYPE`. 

80 

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

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

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

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

85 `mto` parameter. 

86 

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

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

89 

90 .. code-block:: python 

91 

92 self.send_presence( 

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

94 pto="someonwelse@anotherexample.com", 

95 ) 

96 

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

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

99 as sending messages, or displaying a custom status. 

100 

101 """ 

102 

103 COMPONENT_NAME: str = NotImplemented 

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

105 COMPONENT_TYPE: str = "" 

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

107 COMPONENT_AVATAR: Avatar | Path | str | None = None 

108 """ 

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

110 """ 

111 

112 REGISTRATION_FIELDS: Sequence[FormField] = [ 

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

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

115 ] 

116 """ 

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

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

119 """ 

120 REGISTRATION_INSTRUCTIONS: str = "Enter your credentials" 

121 """ 

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

123 configuration. 

124 """ 

125 REGISTRATION_TYPE: RegistrationType = RegistrationType.SINGLE_STEP_FORM 

126 """ 

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

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

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

130 once per user (unless they unregister). 

131 

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

133 presented to the user. 

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

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

136 """ 

137 

138 REGISTRATION_2FA_TITLE = "Enter your 2FA code" 

139 REGISTRATION_2FA_INSTRUCTIONS = ( 

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

141 ) 

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

143 

144 PREFERENCES = [ 

145 FormField( 

146 var="sync_presence", 

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

148 value="true", 

149 required=True, 

150 type="boolean", 

151 ), 

152 FormField( 

153 var="sync_avatar", 

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

155 value="true", 

156 required=True, 

157 type="boolean", 

158 ), 

159 FormField( 

160 var="always_invite_when_adding_bookmarks", 

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

162 value="true", 

163 required=True, 

164 type="boolean", 

165 ), 

166 FormField( 

167 var="last_seen_fallback", 

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

169 value="true", 

170 required=True, 

171 type="boolean", 

172 ), 

173 FormField( 

174 var="roster_push", 

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

176 value="true", 

177 required=True, 

178 type="boolean", 

179 ), 

180 FormField( 

181 var="reaction_fallback", 

182 label="Receive fallback messages for reactions (for legacy XMPP clients)", 

183 value="false", 

184 required=True, 

185 type="boolean", 

186 ), 

187 ] 

188 

189 ROSTER_GROUP: str = "slidge" 

190 """ 

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

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

193 """ 

194 WELCOME_MESSAGE = ( 

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

196 "or just start messaging away!" 

197 ) 

198 """ 

199 A welcome message displayed to users on registration. 

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

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

202 incoming messages from components. 

203 """ 

204 

205 SEARCH_FIELDS: Sequence[FormField] = [ 

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

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

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

209 ] 

210 """ 

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

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

213 their usernames, eg their phone number. 

214 

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

216 (restricted to registered users). 

217 

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

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

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

221 """ 

222 SEARCH_TITLE: str = "Search for legacy contacts" 

223 """ 

224 Title of the search form. 

225 """ 

226 SEARCH_INSTRUCTIONS: str = "" 

227 """ 

228 Instructions of the search form. 

229 """ 

230 

231 MARK_ALL_MESSAGES = False 

232 """ 

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

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

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

236 """ 

237 

238 PROPER_RECEIPTS = False 

239 """ 

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

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

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

243 """ 

244 

245 GROUPS = False 

246 

247 mtype: MessageTypes = "chat" 

248 is_group = False 

249 _can_send_carbon = False 

250 store: SlidgeStore 

251 

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

253 """ 

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

255 

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

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

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

259 Common example: ``int``. 

260 """ 

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

262 # (maybe we do) 

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

264 """ 

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

266 

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

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

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

270 Common example: ``int``. 

271 """ 

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

273 """ 

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

275 

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

277 The callable specified here is responsible for converting the 

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

279 Common example: ``int``. 

280 """ 

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

282 """ 

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

284 

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

286 The callable specified here is responsible for converting the 

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

288 Common example: ``int``. 

289 """ 

290 

291 DB_POOL_SIZE: int = 5 

292 """ 

293 Size of the queue pool for sqlalchemy engine. Typically, when using python async 

294 libraries, this does not need to be changed. 

295 Change that if your gateway use separate threads to call into slidge. The value of 

296 this parameter should be equal or greater than the potential number of threads. 

297 """ 

298 

299 http: aiohttp.ClientSession 

300 avatar: CachedAvatar | None = None 

301 

302 def __init__(self) -> None: 

303 if config.COMPONENT_NAME: 

304 self.COMPONENT_NAME = config.COMPONENT_NAME 

305 if config.WELCOME_MESSAGE: 

306 self.WELCOME_MESSAGE = config.WELCOME_MESSAGE 

307 self.log = log 

308 self.datetime_started = datetime.now() 

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

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

311 super().__init__( 

312 config.JID, 

313 config.SECRET, 

314 config.SERVER, 

315 config.PORT, 

316 plugin_whitelist=SLIXMPP_PLUGINS, 

317 plugin_config={ 

318 "xep_0077": { 

319 "form_fields": None, 

320 "form_instructions": self.REGISTRATION_INSTRUCTIONS, 

321 "enable_subscription": self.REGISTRATION_TYPE 

322 == RegistrationType.SINGLE_STEP_FORM, 

323 }, 

324 "xep_0100": { 

325 "component_name": self.COMPONENT_NAME, 

326 "type": self.COMPONENT_TYPE, 

327 }, 

328 "xep_0184": { 

329 "auto_ack": False, 

330 "auto_request": False, 

331 }, 

332 "xep_0363": { 

333 "upload_service": config.UPLOAD_SERVICE, 

334 }, 

335 }, 

336 fix_error_ns=True, 

337 ) 

338 self.loop.set_exception_handler(self.__exception_handler) 

339 self.loop.create_task(self.__set_http()) 

340 self.has_crashed: bool = False 

341 self.use_origin_id = False 

342 

343 if config.USER_JID_VALIDATOR is None: 

344 config.USER_JID_VALIDATOR = f".*@{self.infer_real_domain()}" 

345 log.info( 

346 "No USER_JID_VALIDATOR was set, using '%s'.", 

347 config.USER_JID_VALIDATOR, 

348 ) 

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

350 self.qr_pending_registrations = dict[str, asyncio.Future[dict | None]]() 

351 

352 self.register_plugins() 

353 self.__setup_legacy_module_subclasses() 

354 

355 self.get_session_from_stanza: Callable[ 

356 [Message | Presence | Iq], BaseSession 

357 ] = self._session_cls.from_stanza 

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

359 self._session_cls.from_user 

360 ) 

361 

362 self.__register_slixmpp_events() 

363 self.__register_slixmpp_api() 

364 self.roster.set_backend(RosterBackend(self)) 

365 

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

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

368 self.delivery_receipt: DeliveryReceipt = DeliveryReceipt(self) 

369 

370 # with this we receive user avatar updates 

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

372 

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

374 

375 if self.GROUPS: 

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

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

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

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

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

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

382 category="conference", 

383 name=self.COMPONENT_NAME, 

384 itype="text", 

385 jid=self.boundjid, 

386 ) 

387 

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

389 self.__adhoc_handler: AdhocProvider = AdhocProvider(self) 

390 self.__chat_commands_handler: ChatCommandProvider = ChatCommandProvider(self) 

391 

392 self.__dispatcher = SessionDispatcher(self) 

393 

394 self.__register_commands() 

395 

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

397 

398 def __setup_legacy_module_subclasses(self) -> None: 

399 from ..contact.roster import LegacyRoster 

400 from ..group.bookmarks import LegacyBookmarks 

401 from ..group.room import LegacyMUC, LegacyParticipant 

402 

403 session_cls: type[BaseSession] = cast( 

404 type[BaseSession], BaseSession.get_unique_subclass() 

405 ) 

406 contact_cls = LegacyContact.get_self_or_unique_subclass() 

407 muc_cls = LegacyMUC.get_self_or_unique_subclass() 

408 participant_cls = LegacyParticipant.get_self_or_unique_subclass() 

409 bookmarks_cls = LegacyBookmarks.get_self_or_unique_subclass() 

410 roster_cls = LegacyRoster.get_self_or_unique_subclass() 

411 

412 if contact_cls.REACTIONS_SINGLE_EMOJI: # type:ignore[attr-defined] 

413 form = Form() 

414 form["type"] = "result" 

415 form.add_field( 

416 "FORM_TYPE", "hidden", value="urn:xmpp:reactions:0:restrictions" 

417 ) 

418 form.add_field("max_reactions_per_user", value="1", type="number") 

419 form.add_field("scope", value="domain") 

420 self.plugin["xep_0128"].add_extended_info(data=form) 

421 

422 session_cls.xmpp = self 

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

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

425 

426 self._session_cls = session_cls 

427 session_cls._bookmarks_cls = bookmarks_cls # type:ignore[assignment] 

428 session_cls._roster_cls = roster_cls # type:ignore[assignment] 

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

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

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

432 

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

434 await self._session_cls.kill_by_jid(jid) 

435 

436 async def __set_http(self) -> None: 

437 self.http = aiohttp.ClientSession() 

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

439 return 

440 avatar_cache.http = self.http 

441 

442 def __register_commands(self) -> None: 

443 for cls in Command.subclasses: 

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

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

446 continue 

447 if cls is Exec: 

448 if config.DEV_MODE: 

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

450 else: 

451 continue 

452 c = cls(self) 

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

454 self.__adhoc_handler.register(c) 

455 self.__chat_commands_handler.register(c) 

456 

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

458 """ 

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

460 

461 :param loop: 

462 :param context: 

463 :return: 

464 """ 

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

466 exc = context.get("exception") 

467 if exc is None: 

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

469 elif isinstance(exc, SystemExit): 

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

471 else: 

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

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

474 self.has_crashed = True 

475 loop.stop() 

476 

477 def __register_slixmpp_events(self) -> None: 

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

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

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

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

482 self.del_event_handler( 

483 "roster_subscription_request", self._handle_new_subscription 

484 ) 

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

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

487 self.add_event_handler("privileges_advertised", self.__on_privileges_advertised) 

488 self.__upload_service_found = asyncio.Event() 

489 

490 def __register_slixmpp_api(self) -> None: 

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

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

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

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

495 if commit: 

496 orm.commit() 

497 return res 

498 

499 return wrapped 

500 

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

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

503 ) 

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

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

506 ) 

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

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

509 ) 

510 

511 @property # type: ignore 

512 def jid(self): # type:ignore[override] 

513 # Override to avoid slixmpp deprecation warnings. 

514 return self.boundjid 

515 

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

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

518 

519 await self.__setup_attachments() 

520 

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

522 disco = self.plugin["xep_0030"] 

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

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

525 

526 if self.COMPONENT_AVATAR is not None: 

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

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

529 assert avatar is not None 

530 try: 

531 cached_avatar = await avatar_cache.convert_or_get(avatar) 

532 except Exception as e: 

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

534 cached_avatar = None 

535 else: 

536 assert cached_avatar is not None 

537 self.avatar = cached_avatar 

538 else: 

539 cached_avatar = None 

540 

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

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

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

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

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

546 # as last resort. 

547 try: 

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

549 await self.__add_component_to_mds_whitelist(user.jid) 

550 except (IqError, IqTimeout) as e: 

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

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

553 log.warning( 

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

555 user, 

556 exc_info=e, 

557 ) 

558 continue 

559 session = self._session_cls.from_user(user) 

560 session.create_task(self.login_wrap(session)) 

561 if cached_avatar is not None: 

562 await self.pubsub.broadcast_avatar( 

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

564 ) 

565 

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

567 

568 async def __on_privileges_advertised(self, _) -> None: 

569 await self.__upload_service_found.wait() 

570 if config.UPLOAD_SERVICE and self.xmpp["xep_0356"].granted_privileges[ 

571 config.SERVER 

572 ].iq.get(self.plugin["xep_0363"].stanza.Request.namespace) in ( 

573 IqPermission.SET, 

574 IqPermission.BOTH, 

575 IqPermission.GET, 

576 ): 

577 AttachmentMixin.PRIVILEGED_UPLOAD = True 

578 

579 async def __setup_attachments(self) -> None: 

580 if config.NO_UPLOAD_PATH: 

581 if config.NO_UPLOAD_URL_PREFIX is None: 

582 raise RuntimeError( 

583 "If you set NO_UPLOAD_PATH you must set NO_UPLOAD_URL_PREFIX too." 

584 ) 

585 elif not config.UPLOAD_SERVICE: 

586 info_iq = await self.xmpp.plugin["xep_0363"].find_upload_service( 

587 self.infer_real_domain() 

588 ) 

589 if info_iq is None: 

590 if self.REGISTRATION_TYPE == RegistrationType.QRCODE: 

591 log.warning( 

592 "No method was configured for attachment and slidge " 

593 "could not automatically determine the JID of a usable upload service. " 

594 "Users likely won't be able to register since this network uses a " 

595 "QR-code based registration flow." 

596 ) 

597 if not config.USE_ATTACHMENT_ORIGINAL_URLS: 

598 log.warning( 

599 "Setting USE_ATTACHMENT_ORIGINAL_URLS to True since no method was configured " 

600 "for attachments and no upload service was found. NB: this does not work for all " 

601 "networks, especially for the E2EE attachments." 

602 ) 

603 config.USE_ATTACHMENT_ORIGINAL_URLS = True 

604 else: 

605 log.info("Auto-discovered upload service: %s", info_iq["from"]) 

606 config.UPLOAD_SERVICE = info_iq["from"] 

607 self.__upload_service_found.set() 

608 

609 def infer_real_domain(self) -> JID: 

610 return JID(re.sub(r"^.*?\.", "", self.xmpp.boundjid.bare)) 

611 

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

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

614 # MDS node so we receive MDS events 

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

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

617 

618 try: 

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

620 except PermissionError: 

621 log.warning( 

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

623 "create the MDS node of %s", 

624 user_jid, 

625 ) 

626 except PrivilegedIqError as exc: 

627 nested = exc.nested_error() 

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

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

630 log.exception( 

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

632 ) 

633 except Exception as e: 

634 log.exception( 

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

636 user_jid, 

637 exc_info=e, 

638 ) 

639 

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

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

642 "xep_0490" 

643 ].stanza.NS 

644 

645 aff = OwnerAffiliation() 

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

647 aff["affiliation"] = "member" 

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

649 

650 try: 

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

652 except PermissionError: 

653 log.warning( 

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

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

656 user_jid, 

657 ) 

658 except Exception as e: 

659 log.exception( 

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

661 user_jid, 

662 exc_info=e, 

663 ) 

664 

665 @timeit 

666 async def login_wrap(self, session: "BaseSession") -> str: 

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

668 session.is_logging_in = True 

669 try: 

670 status = await session.login() 

671 except Exception as e: 

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

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

674 msg = ( 

675 "You are not connected to this gateway! " 

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

677 ) 

678 session.send_gateway_message(msg) 

679 session.logged = False 

680 session.send_gateway_status("Login failed", show="dnd") 

681 return msg 

682 

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

684 session.logged = True 

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

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

687 await session.contacts._fill(orm) 

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

689 r.set_result(True) 

690 if self.GROUPS: 

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

692 await session.bookmarks.fill() 

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

694 r.set_result(True) 

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

696 if status is None: 

697 status = "Logged in" 

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

699 

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

701 session.create_task(self.fetch_user_avatar(session)) 

702 else: 

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

704 session.user.avatar_hash = None 

705 orm.add(session.user) 

706 orm.commit() 

707 return status 

708 

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

710 try: 

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

712 session.user_jid.bare, 

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

714 ifrom=self.boundjid.bare, 

715 ) 

716 except IqTimeout: 

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

718 return 

719 except IqError as e: 

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

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

722 try: 

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

724 except NotImplementedError: 

725 pass 

726 else: 

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

728 session.user.avatar_hash = None 

729 orm.add(session.user) 

730 orm.commit() 

731 return 

732 await self.__dispatcher.on_avatar_metadata_info( 

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

734 ) 

735 

736 def _send( 

737 self, stanza: MessageOrPresenceTypeVar, **send_kwargs 

738 ) -> MessageOrPresenceTypeVar: 

739 stanza.set_from(self.boundjid.bare) 

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

741 stanza.set_to(mto) 

742 stanza.send() 

743 return stanza 

744 

745 def raise_if_not_allowed_jid(self, jid: JID) -> None: 

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

747 raise XMPPError( 

748 condition="not-allowed", 

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

750 "The admin controls that with the USER_JID_VALIDATOR option.", 

751 ) 

752 

753 def send_raw(self, data: str | bytes) -> None: 

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

755 # to make them more readable. 

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

757 if isinstance(data, str): 

758 stripped = copy(data) 

759 else: 

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

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

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

763 # does not matter much 

764 for el in LOG_STRIP_ELEMENTS: 

765 stripped = re.sub( 

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

767 "\1[STRIPPED]\3", 

768 stripped, 

769 flags=re.DOTALL | re.IGNORECASE, 

770 ) 

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

772 if not self.transport: 

773 raise NotConnectedError() 

774 if isinstance(data, str): 

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

776 self.transport.write(data) 

777 

778 def get_session_from_jid(self, j: JID): 

779 try: 

780 return self._session_cls.from_jid(j) 

781 except XMPPError: 

782 pass 

783 

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

785 # """ 

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

787 # 

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

789 # 

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

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

792 # 

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

794 # """ 

795 if isinstance(exception, IqError): 

796 iq = exception.iq 

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

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

799 elif isinstance(exception, IqTimeout): 

800 iq = exception.iq 

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

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

803 elif isinstance(exception, SyntaxError): 

804 # Hide stream parsing errors that occur when the 

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

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

807 pass 

808 else: 

809 if exception: 

810 log.exception(exception) 

811 self.loop.stop() 

812 exit(1) 

813 

814 async def make_registration_form( 

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

816 ) -> Form: 

817 self.raise_if_not_allowed_jid(iq.get_from()) 

818 reg = iq["register"] 

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

820 user = ( 

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

822 ) 

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

824 

825 form = reg["form"] 

826 form.add_field( 

827 "FORM_TYPE", 

828 ftype="hidden", 

829 value="jabber:iq:register", 

830 ) 

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

832 form["instructions"] = self.REGISTRATION_INSTRUCTIONS 

833 

834 if user is not None: 

835 reg["registered"] = False 

836 form.add_field( 

837 "remove", 

838 label="Remove my registration", 

839 required=True, 

840 ftype="boolean", 

841 value=False, 

842 ) 

843 

844 for field in self.REGISTRATION_FIELDS: 

845 if field.var in reg.interfaces: 

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

847 if val is None: 

848 reg.add_field(field.var) 

849 else: 

850 reg[field.var] = val 

851 

852 reg["instructions"] = self.REGISTRATION_INSTRUCTIONS 

853 

854 for field in self.REGISTRATION_FIELDS: 

855 form.add_field( 

856 field.var, 

857 label=field.label, 

858 required=field.required, 

859 ftype=field.type, 

860 options=field.options, 

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

862 ) 

863 

864 reply = iq.reply() 

865 reply.set_payload(reg) 

866 return reply 

867 

868 async def user_prevalidate( 

869 self, ifrom: JID, form_dict: dict[str, str | None] 

870 ) -> JSONSerializable | None: 

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

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

873 for field in self.REGISTRATION_FIELDS: 

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

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

876 

877 return await self.validate(ifrom, form_dict) 

878 

879 @abc.abstractmethod 

880 async def validate( 

881 self, user_jid: JID, registration_form: dict[str, str | None] 

882 ) -> JSONSerializable | None: 

883 """ 

884 Validate a user's initial registration form. 

885 

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

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

888 

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

890 :attr:`.RegistrationType.SINGLE_STEP_FORM`, 

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

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

893 

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

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

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

897 effectively a confirmation dialog displaying 

898 :attr:`.REGISTRATION_INSTRUCTIONS`. 

899 

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

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

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

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

904 of the 

905 

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

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

908 content will be stored. 

909 """ 

910 raise NotImplementedError 

911 

912 async def validate_two_factor_code( 

913 self, user: GatewayUser, code: str 

914 ) -> JSONSerializable | None: 

915 """ 

916 Called when the user enters their 2FA code. 

917 

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

919 if the login fails, and return successfully otherwise. 

920 

921 Only used when :attr:`REGISTRATION_TYPE` is 

922 :attr:`.RegistrationType.TWO_FACTOR_CODE`. 

923 

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

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

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

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

928 adhoc command 

929 

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

931 for this user. 

932 """ 

933 raise NotImplementedError 

934 

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

936 """ 

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

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

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

940 

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

942 :attr:`.RegistrationType.QRCODE`. 

943 

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

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

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

947 """ 

948 raise NotImplementedError 

949 

950 async def confirm_qr( 

951 self, 

952 user_bare_jid: str, 

953 exception: Exception | None = None, 

954 legacy_data: JSONSerializable | None = None, 

955 ) -> None: 

956 """ 

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

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

959 

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

961 :attr:`.RegistrationType.QRCODE`. 

962 

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

964 :class:`GatewayUser` instance 

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

966 QR code flashing. 

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

968 "legacy_module_data" for this user. 

969 """ 

970 fut = self.qr_pending_registrations[user_bare_jid] 

971 if exception is None: 

972 fut.set_result(legacy_data) 

973 else: 

974 fut.set_exception(exception) 

975 

976 async def unregister_user( 

977 self, user: GatewayUser, msg: str = "You unregistered from this gateway." 

978 ) -> None: 

979 self.send_presence(pshow="dnd", pstatus=msg, pto=user.jid) 

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

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

982 

983 async def unregister(self, session: BaseSession) -> None: 

984 """ 

985 Optionally override this if you need to clean additional 

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

987 

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

989 

990 :param session: The session of the user who just unregistered 

991 """ 

992 try: 

993 await session.logout() 

994 except NotImplementedError: 

995 pass 

996 

997 async def input( 

998 self, 

999 jid: JID, 

1000 text: str | None = None, 

1001 mtype: MessageTypes = "chat", 

1002 **input_kwargs: Any, 

1003 ) -> str: 

1004 """ 

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

1006 

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

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

1009 

1010 :param jid: The JID we want input from 

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

1012 :param mtype: Message type 

1013 :return: The user's reply 

1014 """ 

1015 return await self.__chat_commands_handler.input( 

1016 jid, text, mtype=mtype, **input_kwargs 

1017 ) 

1018 

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

1020 """ 

1021 Sends a QR Code to a JID 

1022 

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

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

1025 

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

1027 :param msg_kwargs: Optional additional arguments to pass to 

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

1029 code 

1030 """ 

1031 qr = qrcode.make(text) 

1032 with tempfile.NamedTemporaryFile( 

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

1034 ) as f: 

1035 qr.save(f.name) 

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

1037 

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

1039 # """ 

1040 # Called by the slidge entrypoint on normal exit. 

1041 # 

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

1043 # the gateway component itself. 

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

1045 # """ 

1046 log.debug("Shutting down") 

1047 tasks = [] 

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

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

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

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

1052 return tasks 

1053 

1054 

1055SLIXMPP_PLUGINS = [ 

1056 "xep_0030", # Service discovery 

1057 "xep_0045", # Multi-User Chat 

1058 "xep_0050", # Adhoc commands 

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

1060 "xep_0055", # Jabber search 

1061 "xep_0059", # Result Set Management 

1062 "xep_0066", # Out of Band Data 

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

1064 "xep_0077", # In-band registration 

1065 "xep_0084", # User Avatar 

1066 "xep_0085", # Chat state notifications 

1067 "xep_0100", # Gateway interaction 

1068 "xep_0106", # JID Escaping 

1069 "xep_0115", # Entity capabilities 

1070 "xep_0122", # Data Forms Validation 

1071 "xep_0128", # Service Discovery Extensions 

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

1073 "xep_0172", # User nickname 

1074 "xep_0184", # Message Delivery Receipts 

1075 "xep_0199", # XMPP Ping 

1076 "xep_0221", # Data Forms Media Element 

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

1078 "xep_0249", # Direct MUC Invitations 

1079 "xep_0264", # Jingle Content Thumbnails 

1080 "xep_0280", # Carbons 

1081 "xep_0292_provider", # VCard4 

1082 "xep_0308", # Last message correction 

1083 "xep_0313", # Message Archive Management 

1084 "xep_0317", # Hats 

1085 "xep_0319", # Last User Interaction in Presence 

1086 "xep_0333", # Chat markers 

1087 "xep_0334", # Message Processing Hints 

1088 "xep_0356", # Privileged Entity 

1089 "xep_0363", # HTTP file upload 

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

1091 "xep_0402", # PEP Native Bookmarks 

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

1093 "xep_0424", # Message retraction 

1094 "xep_0425", # Message moderation 

1095 "xep_0444", # Message reactions 

1096 "xep_0447", # Stateless File Sharing 

1097 "xep_0461", # Message replies 

1098 "xep_0469", # Bookmark Pinning 

1099 "xep_0490", # Message Displayed Synchronization 

1100 "xep_0492", # Chat Notification Settings 

1101 "xep_0511", # Link Metadata 

1102] 

1103 

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

1105 

1106log = logging.getLogger(__name__)