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

453 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-02-15 09:02 +0000

1""" 

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

3""" 

4 

5import asyncio 

6import logging 

7import re 

8import tempfile 

9from collections.abc import Callable, Sequence 

10from copy import copy 

11from datetime import datetime 

12from pathlib import Path 

13from typing import TYPE_CHECKING, Any, cast 

14 

15import aiohttp 

16import qrcode 

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

18from slixmpp.exceptions import IqError, IqTimeout, XMPPError 

19from slixmpp.plugins.xep_0004 import Form 

20from slixmpp.plugins.xep_0060.stanza import OwnerAffiliation 

21from slixmpp.plugins.xep_0356.permissions import IqPermission 

22from slixmpp.plugins.xep_0356.privilege import PrivilegedIqError 

23from slixmpp.types import MessageTypes 

24from slixmpp.xmlstream.xmlstream import NotConnectedError 

25 

26from slidge import LegacyContact, command # noqa: F401 

27from slidge.command.adhoc import AdhocProvider 

28from slidge.command.admin import Exec 

29from slidge.command.base import Command, FormField 

30from slidge.command.chat_command import ChatCommandProvider 

31from slidge.command.register import RegistrationType 

32from slidge.core import config 

33from slidge.core.dispatcher.session_dispatcher import SessionDispatcher 

34from slidge.core.mixins import MessageMixin 

35from slidge.core.mixins.attachment import AttachmentMixin 

36from slidge.core.mixins.avatar import convert_avatar 

37from slidge.core.pubsub import PubSubComponent 

38from slidge.core.session import BaseSession 

39from slidge.db import GatewayUser, SlidgeStore 

40from slidge.db.avatar import CachedAvatar, avatar_cache 

41from slidge.db.meta import JSONSerializable 

42from slidge.slixfix.delivery_receipt import DeliveryReceipt 

43from slidge.slixfix.roster import RosterBackend 

44from slidge.util import ABCSubclassableOnceAtMost 

45from slidge.util.types import Avatar, MessageOrPresenceTypeVar 

46from slidge.util.util import timeit 

47 

48if TYPE_CHECKING: 

49 pass 

50 

51 

52class BaseGateway( 

53 ComponentXMPP, 

54 MessageMixin, 

55 metaclass=ABCSubclassableOnceAtMost, 

56): 

57 """ 

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

59 

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

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

62 ``.xmpp`` attribute. 

63 

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

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

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

67 

68 Abstract methods related to the registration process must be overriden 

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

70 

71 - :meth:`.validate` 

72 - :meth:`.validate_two_factor_code` 

73 - :meth:`.get_qr_text` 

74 - :meth:`.confirm_qr` 

75 

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

77 :attr:`REGISTRATION_TYPE`. 

78 

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

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

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

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

83 `mto` parameter. 

84 

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

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

87 

88 .. code-block:: python 

89 

90 self.send_presence( 

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

92 pto="someonwelse@anotherexample.com", 

93 ) 

94 

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

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

97 as sending messages, or displaying a custom status. 

98 

99 """ 

100 

101 COMPONENT_NAME: str = NotImplemented 

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

103 COMPONENT_TYPE: str = "" 

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

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

106 """ 

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

108 """ 

109 

110 REGISTRATION_FIELDS: Sequence[FormField] = [ 

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

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

113 ] 

114 """ 

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

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

117 """ 

118 REGISTRATION_INSTRUCTIONS: str = "Enter your credentials" 

119 """ 

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

121 configuration. 

122 """ 

123 REGISTRATION_TYPE: RegistrationType = RegistrationType.SINGLE_STEP_FORM 

124 """ 

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

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

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

128 once per user (unless they unregister). 

129 

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

131 presented to the user. 

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

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

134 """ 

135 

136 REGISTRATION_2FA_TITLE = "Enter your 2FA code" 

137 REGISTRATION_2FA_INSTRUCTIONS = ( 

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

139 ) 

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

141 

142 PREFERENCES = [ 

143 FormField( 

144 var="sync_presence", 

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

146 value="true", 

147 required=True, 

148 type="boolean", 

149 ), 

150 FormField( 

151 var="sync_avatar", 

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

153 value="true", 

154 required=True, 

155 type="boolean", 

156 ), 

157 FormField( 

158 var="always_invite_when_adding_bookmarks", 

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

160 value="true", 

161 required=True, 

162 type="boolean", 

163 ), 

164 FormField( 

165 var="last_seen_fallback", 

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

167 value="true", 

168 required=True, 

169 type="boolean", 

170 ), 

171 FormField( 

172 var="roster_push", 

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

174 value="true", 

175 required=True, 

176 type="boolean", 

177 ), 

178 FormField( 

179 var="reaction_fallback", 

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

181 value="false", 

182 required=True, 

183 type="boolean", 

184 ), 

185 ] 

186 

187 ROSTER_GROUP: str = "slidge" 

188 """ 

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

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

191 """ 

192 WELCOME_MESSAGE = ( 

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

194 "or just start messaging away!" 

195 ) 

196 """ 

197 A welcome message displayed to users on registration. 

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

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

200 incoming messages from components. 

201 """ 

202 

203 SEARCH_FIELDS: Sequence[FormField] = [ 

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

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

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

207 ] 

208 """ 

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

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

211 their usernames, eg their phone number. 

212 

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

214 (restricted to registered users). 

215 

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

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

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

219 """ 

220 SEARCH_TITLE: str = "Search for legacy contacts" 

221 """ 

222 Title of the search form. 

223 """ 

224 SEARCH_INSTRUCTIONS: str = "" 

225 """ 

226 Instructions of the search form. 

227 """ 

228 

229 MARK_ALL_MESSAGES = False 

230 """ 

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

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

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

234 """ 

235 

236 PROPER_RECEIPTS = False 

237 """ 

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

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

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

241 """ 

242 

243 GROUPS = False 

244 

245 mtype: MessageTypes = "chat" 

246 is_group = False 

247 _can_send_carbon = False 

248 store: SlidgeStore 

249 

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

251 """ 

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

253 

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

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

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

257 Common example: ``int``. 

258 """ 

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

260 # (maybe we do) 

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

262 """ 

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

264 

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

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

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

268 Common example: ``int``. 

269 """ 

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

271 """ 

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

273 

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

275 The callable specified here is responsible for converting the 

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

277 Common example: ``int``. 

278 """ 

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

280 """ 

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

282 

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

284 The callable specified here is responsible for converting the 

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

286 Common example: ``int``. 

287 """ 

288 

289 DB_POOL_SIZE: int = 5 

290 """ 

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

292 libraries, this does not need to be changed. 

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

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

295 """ 

296 

297 http: aiohttp.ClientSession 

298 avatar: CachedAvatar | None = None 

299 

300 def __init__(self) -> None: 

301 if config.COMPONENT_NAME: 

302 self.COMPONENT_NAME = config.COMPONENT_NAME 

303 if config.WELCOME_MESSAGE: 

304 self.WELCOME_MESSAGE = config.WELCOME_MESSAGE 

305 self.log = log 

306 self.datetime_started = datetime.now() 

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

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

309 super().__init__( 

310 config.JID, 

311 config.SECRET, 

312 config.SERVER, 

313 config.PORT, 

314 plugin_whitelist=SLIXMPP_PLUGINS, 

315 plugin_config={ 

316 "xep_0077": { 

317 "form_fields": None, 

318 "form_instructions": self.REGISTRATION_INSTRUCTIONS, 

319 "enable_subscription": self.REGISTRATION_TYPE 

320 == RegistrationType.SINGLE_STEP_FORM, 

321 }, 

322 "xep_0100": { 

323 "component_name": self.COMPONENT_NAME, 

324 "type": self.COMPONENT_TYPE, 

325 }, 

326 "xep_0184": { 

327 "auto_ack": False, 

328 "auto_request": False, 

329 }, 

330 "xep_0363": { 

331 "upload_service": config.UPLOAD_SERVICE, 

332 }, 

333 }, 

334 fix_error_ns=True, 

335 ) 

336 self.loop.set_exception_handler(self.__exception_handler) 

337 self.loop.create_task(self.__set_http()) 

338 self.has_crashed: bool = False 

339 self.use_origin_id = False 

340 

341 if config.USER_JID_VALIDATOR is None: 

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

343 log.info( 

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

345 config.USER_JID_VALIDATOR, 

346 ) 

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

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

349 

350 self.register_plugins() 

351 self.__setup_legacy_module_subclasses() 

352 

353 self.get_session_from_stanza: Callable[ 

354 [Message | Presence | Iq], BaseSession 

355 ] = self._session_cls.from_stanza 

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

357 self._session_cls.from_user 

358 ) 

359 

360 self.__register_slixmpp_events() 

361 self.__register_slixmpp_api() 

362 self.roster.set_backend(RosterBackend(self)) 

363 

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

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

366 self.delivery_receipt: DeliveryReceipt = DeliveryReceipt(self) 

367 

368 # with this we receive user avatar updates 

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

370 

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

372 

373 if self.GROUPS: 

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

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

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

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

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

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

380 category="conference", 

381 name=self.COMPONENT_NAME, 

382 itype="text", 

383 jid=self.boundjid, 

384 ) 

385 

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

387 self.__adhoc_handler: AdhocProvider = AdhocProvider(self) 

388 self.__chat_commands_handler: ChatCommandProvider = ChatCommandProvider(self) 

389 

390 self.__dispatcher = SessionDispatcher(self) 

391 

392 self.__register_commands() 

393 

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

395 

396 def __setup_legacy_module_subclasses(self): 

397 from ..contact.roster import LegacyRoster 

398 from ..group.bookmarks import LegacyBookmarks 

399 from ..group.room import LegacyMUC, LegacyParticipant 

400 

401 session_cls: type[BaseSession] = cast( 

402 type[BaseSession], BaseSession.get_unique_subclass() 

403 ) 

404 contact_cls = LegacyContact.get_self_or_unique_subclass() 

405 muc_cls = LegacyMUC.get_self_or_unique_subclass() 

406 participant_cls = LegacyParticipant.get_self_or_unique_subclass() 

407 bookmarks_cls = LegacyBookmarks.get_self_or_unique_subclass() 

408 roster_cls = LegacyRoster.get_self_or_unique_subclass() 

409 

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

411 form = Form() 

412 form["type"] = "result" 

413 form.add_field( 

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

415 ) 

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

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

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

419 

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

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

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

423 

424 self._session_cls = session_cls 

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

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

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

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

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

430 

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

432 await self._session_cls.kill_by_jid(jid) 

433 

434 async def __set_http(self) -> None: 

435 self.http = aiohttp.ClientSession() 

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

437 return 

438 avatar_cache.http = self.http 

439 

440 def __register_commands(self) -> None: 

441 for cls in Command.subclasses: 

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

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

444 continue 

445 if cls is Exec: 

446 if config.DEV_MODE: 

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

448 else: 

449 continue 

450 c = cls(self) 

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

452 self.__adhoc_handler.register(c) 

453 self.__chat_commands_handler.register(c) 

454 

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

456 """ 

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

458 

459 :param loop: 

460 :param context: 

461 :return: 

462 """ 

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

464 exc = context.get("exception") 

465 if exc is None: 

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

467 elif isinstance(exc, SystemExit): 

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

469 else: 

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

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

472 self.has_crashed = True 

473 loop.stop() 

474 

475 def __register_slixmpp_events(self) -> None: 

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

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

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

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

480 self.del_event_handler( 

481 "roster_subscription_request", self._handle_new_subscription 

482 ) 

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

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

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

486 self.__upload_service_found = asyncio.Event() 

487 

488 def __register_slixmpp_api(self) -> None: 

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

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

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

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

493 if commit: 

494 orm.commit() 

495 return res 

496 

497 return wrapped 

498 

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

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

501 ) 

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

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

504 ) 

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

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

507 ) 

508 

509 @property # type: ignore 

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

511 # Override to avoid slixmpp deprecation warnings. 

512 return self.boundjid 

513 

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

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

516 

517 await self.__setup_attachments() 

518 

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

520 disco = self.plugin["xep_0030"] 

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

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

523 

524 if self.COMPONENT_AVATAR is not None: 

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

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

527 assert avatar is not None 

528 try: 

529 cached_avatar = await avatar_cache.convert_or_get(avatar) 

530 except Exception as e: 

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

532 cached_avatar = None 

533 else: 

534 assert cached_avatar is not None 

535 self.avatar = cached_avatar 

536 else: 

537 cached_avatar = None 

538 

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

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

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

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

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

544 # as last resort. 

545 try: 

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

547 await self.__add_component_to_mds_whitelist(user.jid) 

548 except (IqError, IqTimeout) as e: 

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

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

551 log.warning( 

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

553 user, 

554 exc_info=e, 

555 ) 

556 continue 

557 session = self._session_cls.from_user(user) 

558 session.create_task(self.login_wrap(session)) 

559 if cached_avatar is not None: 

560 await self.pubsub.broadcast_avatar( 

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

562 ) 

563 

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

565 

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

567 await self.__upload_service_found.wait() 

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

569 config.SERVER 

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

571 IqPermission.SET, 

572 IqPermission.BOTH, 

573 IqPermission.GET, 

574 ): 

575 AttachmentMixin.PRIVILEGED_UPLOAD = True 

576 

577 async def __setup_attachments(self) -> None: 

578 if config.NO_UPLOAD_PATH: 

579 if config.NO_UPLOAD_URL_PREFIX is None: 

580 raise RuntimeError( 

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

582 ) 

583 elif not config.UPLOAD_SERVICE: 

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

585 self.infer_real_domain() 

586 ) 

587 if info_iq is None: 

588 if self.REGISTRATION_TYPE == RegistrationType.QRCODE: 

589 log.warning( 

590 "No method was configured for attachment and slidge " 

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

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

593 "QR-code based registration flow." 

594 ) 

595 if not config.USE_ATTACHMENT_ORIGINAL_URLS: 

596 log.warning( 

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

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

599 "networks, especially for the E2EE attachments." 

600 ) 

601 config.USE_ATTACHMENT_ORIGINAL_URLS = True 

602 else: 

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

604 config.UPLOAD_SERVICE = info_iq["from"] 

605 self.__upload_service_found.set() 

606 

607 def infer_real_domain(self) -> JID: 

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

609 

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

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

612 # MDS node so we receive MDS events 

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

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

615 

616 try: 

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

618 except PermissionError: 

619 log.warning( 

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

621 "create the MDS node of %s", 

622 user_jid, 

623 ) 

624 except PrivilegedIqError as exc: 

625 nested = exc.nested_error() 

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

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

628 log.exception( 

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

630 ) 

631 except Exception as e: 

632 log.exception( 

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

634 user_jid, 

635 exc_info=e, 

636 ) 

637 

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

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

640 "xep_0490" 

641 ].stanza.NS 

642 

643 aff = OwnerAffiliation() 

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

645 aff["affiliation"] = "member" 

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

647 

648 try: 

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

650 except PermissionError: 

651 log.warning( 

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

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

654 user_jid, 

655 ) 

656 except Exception as e: 

657 log.exception( 

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

659 user_jid, 

660 exc_info=e, 

661 ) 

662 

663 @timeit 

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

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

666 session.is_logging_in = True 

667 try: 

668 status = await session.login() 

669 except Exception as e: 

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

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

672 msg = ( 

673 "You are not connected to this gateway! " 

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

675 ) 

676 session.send_gateway_message(msg) 

677 session.logged = False 

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

679 return msg 

680 

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

682 session.logged = True 

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

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

685 await session.contacts._fill(orm) 

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

687 r.set_result(True) 

688 if self.GROUPS: 

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

690 await session.bookmarks.fill() 

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

692 r.set_result(True) 

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

694 if status is None: 

695 status = "Logged in" 

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

697 

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

699 session.create_task(self.fetch_user_avatar(session)) 

700 else: 

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

702 session.user.avatar_hash = None 

703 orm.add(session.user) 

704 orm.commit() 

705 return status 

706 

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

708 try: 

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

710 session.user_jid.bare, 

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

712 ifrom=self.boundjid.bare, 

713 ) 

714 except IqTimeout: 

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

716 return 

717 except IqError as e: 

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

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

720 try: 

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

722 except NotImplementedError: 

723 pass 

724 else: 

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

726 session.user.avatar_hash = None 

727 orm.add(session.user) 

728 orm.commit() 

729 return 

730 await self.__dispatcher.on_avatar_metadata_info( 

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

732 ) 

733 

734 def _send( 

735 self, stanza: MessageOrPresenceTypeVar, **send_kwargs 

736 ) -> MessageOrPresenceTypeVar: 

737 stanza.set_from(self.boundjid.bare) 

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

739 stanza.set_to(mto) 

740 stanza.send() 

741 return stanza 

742 

743 def raise_if_not_allowed_jid(self, jid: JID): 

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

745 raise XMPPError( 

746 condition="not-allowed", 

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

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

749 ) 

750 

751 def send_raw(self, data: str | bytes): 

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

753 # to make them more readable. 

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

755 if isinstance(data, str): 

756 stripped = copy(data) 

757 else: 

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

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

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

761 # does not matter much 

762 for el in LOG_STRIP_ELEMENTS: 

763 stripped = re.sub( 

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

765 "\1[STRIPPED]\3", 

766 stripped, 

767 flags=re.DOTALL | re.IGNORECASE, 

768 ) 

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

770 if not self.transport: 

771 raise NotConnectedError() 

772 if isinstance(data, str): 

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

774 self.transport.write(data) 

775 

776 def get_session_from_jid(self, j: JID): 

777 try: 

778 return self._session_cls.from_jid(j) 

779 except XMPPError: 

780 pass 

781 

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

783 # """ 

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

785 # 

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

787 # 

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

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

790 # 

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

792 # """ 

793 if isinstance(exception, IqError): 

794 iq = exception.iq 

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

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

797 elif isinstance(exception, IqTimeout): 

798 iq = exception.iq 

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

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

801 elif isinstance(exception, SyntaxError): 

802 # Hide stream parsing errors that occur when the 

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

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

805 pass 

806 else: 

807 if exception: 

808 log.exception(exception) 

809 self.loop.stop() 

810 exit(1) 

811 

812 async def make_registration_form( 

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

814 ) -> Form: 

815 self.raise_if_not_allowed_jid(iq.get_from()) 

816 reg = iq["register"] 

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

818 user = ( 

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

820 ) 

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

822 

823 form = reg["form"] 

824 form.add_field( 

825 "FORM_TYPE", 

826 ftype="hidden", 

827 value="jabber:iq:register", 

828 ) 

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

830 form["instructions"] = self.REGISTRATION_INSTRUCTIONS 

831 

832 if user is not None: 

833 reg["registered"] = False 

834 form.add_field( 

835 "remove", 

836 label="Remove my registration", 

837 required=True, 

838 ftype="boolean", 

839 value=False, 

840 ) 

841 

842 for field in self.REGISTRATION_FIELDS: 

843 if field.var in reg.interfaces: 

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

845 if val is None: 

846 reg.add_field(field.var) 

847 else: 

848 reg[field.var] = val 

849 

850 reg["instructions"] = self.REGISTRATION_INSTRUCTIONS 

851 

852 for field in self.REGISTRATION_FIELDS: 

853 form.add_field( 

854 field.var, 

855 label=field.label, 

856 required=field.required, 

857 ftype=field.type, 

858 options=field.options, 

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

860 ) 

861 

862 reply = iq.reply() 

863 reply.set_payload(reg) 

864 return reply 

865 

866 async def user_prevalidate( 

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

868 ) -> JSONSerializable | None: 

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

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

871 for field in self.REGISTRATION_FIELDS: 

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

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

874 

875 return await self.validate(ifrom, form_dict) 

876 

877 async def validate( 

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

879 ) -> JSONSerializable | None: 

880 """ 

881 Validate a user's initial registration form. 

882 

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

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

885 

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

887 :attr:`.RegistrationType.SINGLE_STEP_FORM`, 

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

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

890 

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

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

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

894 effectively a confirmation dialog displaying 

895 :attr:`.REGISTRATION_INSTRUCTIONS`. 

896 

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

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

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

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

901 of the 

902 

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

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

905 content will be stored. 

906 """ 

907 raise NotImplementedError 

908 

909 async def validate_two_factor_code( 

910 self, user: GatewayUser, code: str 

911 ) -> JSONSerializable | None: 

912 """ 

913 Called when the user enters their 2FA code. 

914 

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

916 if the login fails, and return successfully otherwise. 

917 

918 Only used when :attr:`REGISTRATION_TYPE` is 

919 :attr:`.RegistrationType.TWO_FACTOR_CODE`. 

920 

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

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

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

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

925 adhoc command 

926 

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

928 for this user. 

929 """ 

930 raise NotImplementedError 

931 

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

933 """ 

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

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

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

937 

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

939 :attr:`.RegistrationType.QRCODE`. 

940 

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

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

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

944 """ 

945 raise NotImplementedError 

946 

947 async def confirm_qr( 

948 self, 

949 user_bare_jid: str, 

950 exception: Exception | None = None, 

951 legacy_data: JSONSerializable | None = None, 

952 ) -> None: 

953 """ 

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

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

956 

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

958 :attr:`.RegistrationType.QRCODE`. 

959 

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

961 :class:`GatewayUser` instance 

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

963 QR code flashing. 

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

965 "legacy_module_data" for this user. 

966 """ 

967 fut = self.qr_pending_registrations[user_bare_jid] 

968 if exception is None: 

969 fut.set_result(legacy_data) 

970 else: 

971 fut.set_exception(exception) 

972 

973 async def unregister_user( 

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

975 ) -> None: 

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

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

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

979 

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

981 """ 

982 Optionally override this if you need to clean additional 

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

984 

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

986 

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

988 """ 

989 try: 

990 await session.logout() 

991 except NotImplementedError: 

992 pass 

993 

994 async def input( 

995 self, 

996 jid: JID, 

997 text: str | None = None, 

998 mtype: MessageTypes = "chat", 

999 **input_kwargs: Any, 

1000 ) -> str: 

1001 """ 

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

1003 

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

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

1006 

1007 :param jid: The JID we want input from 

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

1009 :param mtype: Message type 

1010 :return: The user's reply 

1011 """ 

1012 return await self.__chat_commands_handler.input( 

1013 jid, text, mtype=mtype, **input_kwargs 

1014 ) 

1015 

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

1017 """ 

1018 Sends a QR Code to a JID 

1019 

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

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

1022 

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

1024 :param msg_kwargs: Optional additional arguments to pass to 

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

1026 code 

1027 """ 

1028 qr = qrcode.make(text) 

1029 with tempfile.NamedTemporaryFile( 

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

1031 ) as f: 

1032 qr.save(f.name) 

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

1034 

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

1036 # """ 

1037 # Called by the slidge entrypoint on normal exit. 

1038 # 

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

1040 # the gateway component itself. 

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

1042 # """ 

1043 log.debug("Shutting down") 

1044 tasks = [] 

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

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

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

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

1049 return tasks 

1050 

1051 

1052SLIXMPP_PLUGINS = [ 

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

1054 "xep_0030", # Service discovery 

1055 "xep_0045", # Multi-User Chat 

1056 "xep_0050", # Adhoc commands 

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

1058 "xep_0055", # Jabber search 

1059 "xep_0059", # Result Set Management 

1060 "xep_0066", # Out of Band Data 

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

1062 "xep_0077", # In-band registration 

1063 "xep_0084", # User Avatar 

1064 "xep_0085", # Chat state notifications 

1065 "xep_0100", # Gateway interaction 

1066 "xep_0106", # JID Escaping 

1067 "xep_0115", # Entity capabilities 

1068 "xep_0122", # Data Forms Validation 

1069 "xep_0128", # Service Discovery Extensions 

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

1071 "xep_0172", # User nickname 

1072 "xep_0184", # Message Delivery Receipts 

1073 "xep_0199", # XMPP Ping 

1074 "xep_0221", # Data Forms Media Element 

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

1076 "xep_0249", # Direct MUC Invitations 

1077 "xep_0264", # Jingle Content Thumbnails 

1078 "xep_0280", # Carbons 

1079 "xep_0292_provider", # VCard4 

1080 "xep_0308", # Last message correction 

1081 "xep_0313", # Message Archive Management 

1082 "xep_0317", # Hats 

1083 "xep_0319", # Last User Interaction in Presence 

1084 "xep_0333", # Chat markers 

1085 "xep_0334", # Message Processing Hints 

1086 "xep_0356", # Privileged Entity 

1087 "xep_0363", # HTTP file upload 

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

1089 "xep_0402", # PEP Native Bookmarks 

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

1091 "xep_0424", # Message retraction 

1092 "xep_0425", # Message moderation 

1093 "xep_0444", # Message reactions 

1094 "xep_0447", # Stateless File Sharing 

1095 "xep_0461", # Message replies 

1096 "xep_0469", # Bookmark Pinning 

1097 "xep_0490", # Message Displayed Synchronization 

1098 "xep_0492", # Chat Notification Settings 

1099] 

1100 

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

1102 

1103log = logging.getLogger(__name__)