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

464 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-06-13 04:38 +0000

1""" 

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

3""" 

4 

5import abc 

6import asyncio 

7import contextlib 

8import logging 

9import re 

10import tempfile 

11from collections.abc import Callable, Sequence 

12from copy import copy 

13from datetime import datetime 

14from pathlib import Path 

15from typing import Any, ClassVar, Concatenate, Generic, ParamSpec, TypeVar, cast 

16 

17import aiohttp 

18import qrcode 

19from slixmpp import JID, ComponentXMPP, Iq 

20from slixmpp.exceptions import IqError, IqTimeout, XMPPError 

21from slixmpp.plugins.xep_0004.stanza.form import Form 

22from slixmpp.plugins.xep_0060.stanza import OwnerAffiliation 

23from slixmpp.plugins.xep_0356.permissions import IqPermission 

24from slixmpp.plugins.xep_0356.privilege import PrivilegedIqError 

25from slixmpp.types import MessageTypes 

26from slixmpp.xmlstream.xmlstream import NotConnectedError 

27from sqlalchemy.orm import Session as OrmSession 

28 

29import slidge.command.categories 

30from slidge.command.adhoc import AdhocProvider 

31from slidge.command.admin import Exec 

32from slidge.command.base import Command, FormField 

33from slidge.command.chat_command import ChatCommandProvider 

34from slidge.command.register import RegistrationType 

35from slidge.contact import LegacyContact 

36from slidge.core import config 

37from slidge.core.dispatcher.session_dispatcher import SessionDispatcher 

38from slidge.core.mixins.attachment import AttachmentMixin 

39from slidge.core.mixins.avatar import convert_avatar 

40from slidge.core.mixins.message import MessageMixin 

41from slidge.core.pubsub import PubSubComponent 

42from slidge.db import GatewayUser, SlidgeStore 

43from slidge.db.avatar import CachedAvatar, avatar_cache 

44from slidge.db.meta import JSONSerializable 

45from slidge.slixfix.delivery_receipt import DeliveryReceipt 

46from slidge.slixfix.roster import RosterBackend 

47from slidge.util import SubclassableOnce 

48from slidge.util.types import AnyGateway, Avatar, MessageOrPresenceTypeVar, SessionType 

49 

50T = TypeVar("T") 

51P = ParamSpec("P") 

52 

53 

54class BaseGateway( 

55 ComponentXMPP, 

56 MessageMixin, 

57 SubclassableOnce, 

58 Generic[SessionType], 

59 abc.ABC, 

60): 

61 """ 

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

63 

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

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

66 ``.xmpp`` attribute. 

67 

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

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

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

71 

72 Abstract methods related to the registration process must be overriden 

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

74 

75 - :meth:`.validate` 

76 - :meth:`.validate_two_factor_code` 

77 - :meth:`.get_qr_text` 

78 - :meth:`.confirm_qr` 

79 

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

81 :attr:`REGISTRATION_TYPE`. 

82 

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

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

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

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

87 `mto` parameter. 

88 

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

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

91 

92 .. code-block:: python 

93 

94 self.send_presence( 

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

96 pto="someonwelse@anotherexample.com", 

97 ) 

98 

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

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

101 as sending messages, or displaying a custom status. 

102 

103 """ 

104 

105 COMPONENT_NAME: str = NotImplemented 

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

107 COMPONENT_TYPE: str = "" 

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

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

110 """ 

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

112 """ 

113 

114 REGISTRATION_FIELDS: Sequence[FormField] = [ 

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

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

117 ] 

118 """ 

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

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

121 """ 

122 REGISTRATION_INSTRUCTIONS: str = "Enter your credentials" 

123 """ 

124 The text presented to a user who wants to register (or modify) their 

125 :term:`legacy <Legacy>` account configuration. 

126 """ 

127 REGISTRATION_TYPE: RegistrationType = RegistrationType.SINGLE_STEP_FORM 

128 """ 

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

130 login to the :term:`legacy network <Legacy Network>`. 

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

132 once per user (unless they unregister). 

133 

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

135 presented to the user. 

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

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

138 """ 

139 

140 REGISTRATION_2FA_TITLE = "Enter your 2FA code" 

141 REGISTRATION_2FA_INSTRUCTIONS = ( 

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

143 ) 

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

145 

146 PREFERENCES: ClassVar[list[FormField]] = [ 

147 FormField( 

148 var="sync_presence", 

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

150 value="true", 

151 required=True, 

152 type="boolean", 

153 ), 

154 FormField( 

155 var="sync_avatar", 

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

157 value="true", 

158 required=True, 

159 type="boolean", 

160 ), 

161 FormField( 

162 var="always_invite_when_adding_bookmarks", 

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

164 value="true", 

165 required=True, 

166 type="boolean", 

167 ), 

168 FormField( 

169 var="last_seen_fallback", 

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

171 value="true", 

172 required=True, 

173 type="boolean", 

174 ), 

175 FormField( 

176 var="roster_push", 

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

178 value="true", 

179 required=True, 

180 type="boolean", 

181 ), 

182 FormField( 

183 var="reaction_fallback", 

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

185 value="false", 

186 required=True, 

187 type="boolean", 

188 ), 

189 ] 

190 

191 ROSTER_GROUP: str = "slidge" 

192 """ 

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

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

195 """ 

196 WELCOME_MESSAGE = ( 

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

198 "or just start messaging away!" 

199 ) 

200 """ 

201 A welcome message displayed to users on registration. 

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

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

204 incoming messages from components. 

205 """ 

206 

207 SEARCH_FIELDS: Sequence[FormField] = [ 

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

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

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

211 ] 

212 """ 

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

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

215 their usernames, eg their phone number. 

216 

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

218 (restricted to registered users). 

219 

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

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

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

223 """ 

224 SEARCH_TITLE: str = "Search for legacy contacts" 

225 """ 

226 Title of the search form. 

227 """ 

228 SEARCH_INSTRUCTIONS: str = "" 

229 """ 

230 Instructions of the search form. 

231 """ 

232 

233 MARK_ALL_MESSAGES = False 

234 """ 

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

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

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

238 """ 

239 

240 PROPER_RECEIPTS = False 

241 """ 

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

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

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

245 """ 

246 

247 GROUPS = False 

248 """ 

249 This must be set to True if this gateway supports groups. 

250 """ 

251 SPACES = False 

252 """ 

253 This must be set to True if this gateway supports spaces, cf :xep:`0503`. 

254 """ 

255 

256 mtype: MessageTypes = "chat" 

257 is_group = False 

258 _can_send_carbon = False 

259 store: SlidgeStore 

260 _session_cls: type[SessionType] 

261 

262 DB_POOL_SIZE: int = 5 

263 """ 

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

265 libraries, this does not need to be changed. 

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

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

268 """ 

269 

270 http: aiohttp.ClientSession 

271 avatar: CachedAvatar | None = None 

272 

273 def __init__(self) -> None: 

274 if config.COMPONENT_NAME: 

275 self.COMPONENT_NAME = config.COMPONENT_NAME 

276 if config.WELCOME_MESSAGE: 

277 self.WELCOME_MESSAGE = config.WELCOME_MESSAGE 

278 self.log = log 

279 self.datetime_started = datetime.now() 

280 # FIXME: ugly hack to work with the BaseSender mixin :/ 

281 self.xmpp = cast("AnyGateway", self) 

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

283 super().__init__( 

284 config.JID, 

285 config.SECRET, 

286 config.SERVER, 

287 config.PORT, 

288 plugin_whitelist=SLIXMPP_PLUGINS, 

289 plugin_config={ 

290 "xep_0077": { 

291 "form_fields": None, 

292 "form_instructions": self.REGISTRATION_INSTRUCTIONS, 

293 "enable_subscription": self.REGISTRATION_TYPE 

294 == RegistrationType.SINGLE_STEP_FORM, 

295 }, 

296 "xep_0100": { 

297 "component_name": self.COMPONENT_NAME, 

298 "type": self.COMPONENT_TYPE, 

299 }, 

300 "xep_0184": { 

301 "auto_ack": False, 

302 "auto_request": False, 

303 }, 

304 "xep_0363": { 

305 "upload_service": config.UPLOAD_SERVICE, 

306 }, 

307 }, 

308 fix_error_ns=True, 

309 ) 

310 self.loop.set_exception_handler(self.__exception_handler) 

311 self.loop.create_task(self.__set_http()) 

312 self.has_crashed: bool = False 

313 self.use_origin_id = False 

314 

315 if config.USER_JID_VALIDATOR is None: 

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

317 log.info( 

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

319 config.USER_JID_VALIDATOR, 

320 ) 

321 self.jid_validator: re.Pattern[str] = re.compile(config.USER_JID_VALIDATOR) 

322 self.qr_pending_registrations = dict[ 

323 str, asyncio.Future[JSONSerializable | None] 

324 ]() 

325 

326 self.register_plugins() 

327 self.__setup_legacy_module_subclasses() 

328 

329 self.get_session_from_stanza = self._session_cls.from_stanza 

330 self.get_session_from_user = self._session_cls.from_user 

331 

332 self.__register_slixmpp_events() 

333 self.__register_slixmpp_api() 

334 self.roster.set_backend(RosterBackend(self)) 

335 

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

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

338 self.delivery_receipt = DeliveryReceipt(self) 

339 

340 # with this we receive user avatar updates 

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

342 

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

344 

345 if self.GROUPS: 

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

347 self.plugin["xep_0030"].add_feature(self.plugin["xep_0463"].stanza.NS) 

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

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

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

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

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

353 category="conference", 

354 name=self.COMPONENT_NAME, 

355 itype="text", 

356 jid=self.boundjid, 

357 ) 

358 if self.SPACES: 

359 self.plugin["xep_0030"].add_feature("urn:xmpp:spaces:0") 

360 

361 self.__adhoc_handler = AdhocProvider(self) 

362 self.__chat_commands_handler = ChatCommandProvider(self) 

363 

364 self.__dispatcher = SessionDispatcher(self) 

365 

366 self.__register_commands() 

367 

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

369 

370 def __setup_legacy_module_subclasses(self) -> None: 

371 from ..contact.roster import LegacyRoster 

372 from ..group.bookmarks import LegacyBookmarks 

373 from ..group.participant import LegacyParticipant 

374 from ..group.room import LegacyMUC 

375 from .session import BaseSession 

376 

377 self._session_cls = BaseSession.get_unique_subclass() # type:ignore 

378 contact_cls = LegacyContact.get_self_or_unique_subclass() 

379 muc_cls = LegacyMUC.get_self_or_unique_subclass() 

380 participant_cls = LegacyParticipant.get_self_or_unique_subclass() 

381 bookmarks_cls = LegacyBookmarks.get_self_or_unique_subclass() 

382 roster_cls = LegacyRoster.get_self_or_unique_subclass() 

383 

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

385 form = Form() 

386 form["type"] = "result" 

387 form.add_field( 

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

389 ) 

390 form.add_field("max_reactions_per_user", value="1", type="text-single") 

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

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

393 

394 self._session_cls.xmpp = self 

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

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

397 

398 self._session_cls._bookmarks_cls = bookmarks_cls # type:ignore[assignment] 

399 self._session_cls._roster_cls = roster_cls # type:ignore[assignment] 

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

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

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

403 

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

405 await self._session_cls.kill_by_jid(jid) 

406 

407 async def __set_http(self) -> None: 

408 self.http = aiohttp.ClientSession() 

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

410 return 

411 avatar_cache.http = self.http 

412 

413 def __register_commands(self) -> None: 

414 for cls in Command.subclasses: # type:ignore[misc] 

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

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

417 continue 

418 if cls is Exec: 

419 if config.DEV_MODE: 

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

421 else: 

422 continue 

423 if cls.CATEGORY == slidge.command.categories.GROUPS and not self.GROUPS: 

424 continue 

425 if cls.CATEGORY == slidge.command.categories.SPACES and not self.SPACES: 

426 continue 

427 c = cls(cast(AnyGateway, self)) 

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

429 self.__adhoc_handler.register(c) 

430 self.__chat_commands_handler.register(c) 

431 

432 def __exception_handler( 

433 self, loop: asyncio.AbstractEventLoop, context: dict[Any, Any] 

434 ) -> None: 

435 """ 

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

437 

438 :param loop: 

439 :param context: 

440 :return: 

441 """ 

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

443 exc = context.get("exception") 

444 if exc is None: 

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

446 elif isinstance(exc, SystemExit): 

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

448 else: 

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

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

451 self.has_crashed = True 

452 loop.stop() 

453 

454 def __register_slixmpp_events(self) -> None: 

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

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

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

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

459 self.del_event_handler( 

460 "roster_subscription_request", self._handle_new_subscription 

461 ) 

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

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

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

465 self.__upload_service_found = asyncio.Event() 

466 

467 def __register_slixmpp_api(self) -> None: 

468 def with_session( 

469 func: Callable[Concatenate[OrmSession, P], T], commit: bool = True 

470 ) -> Callable[P, T]: 

471 def wrapped(*a: P.args, **kw: P.kwargs) -> T: 

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

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

474 if commit: 

475 orm.commit() 

476 return res 

477 

478 return wrapped 

479 

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

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

482 ) 

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

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

485 ) 

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

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

488 ) 

489 

490 @property # type:ignore[override] 

491 def jid(self) -> JID: 

492 # Override to avoid slixmpp deprecation warnings. 

493 return self.boundjid 

494 

495 @jid.setter 

496 def jid(self, jid: JID) -> None: 

497 raise RuntimeError 

498 

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

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

501 

502 await self.__setup_attachments() 

503 

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

505 disco = self.plugin["xep_0030"] 

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

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

508 

509 if self.COMPONENT_AVATAR is not None: 

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

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

512 assert avatar is not None 

513 try: 

514 cached_avatar = await avatar_cache.convert_or_get(avatar) 

515 except Exception as e: 

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

517 cached_avatar = None 

518 else: 

519 assert cached_avatar is not None 

520 self.avatar = cached_avatar 

521 else: 

522 cached_avatar = None 

523 

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

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

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

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

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

529 # as last resort. 

530 try: 

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

532 await self.__add_component_to_mds_whitelist(user.jid) 

533 except (IqError, IqTimeout) as e: 

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

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

536 log.warning( 

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

538 user, 

539 exc_info=e, 

540 ) 

541 continue 

542 session = self._session_cls.from_user(user) 

543 session.create_task(self.login_wrap(session)) 

544 if cached_avatar is not None: 

545 await self.pubsub.broadcast_avatar( 

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

547 ) 

548 

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

550 

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

552 await self.__upload_service_found.wait() 

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

554 config.SERVER 

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

556 IqPermission.SET, 

557 IqPermission.BOTH, 

558 IqPermission.GET, 

559 ): 

560 AttachmentMixin.PRIVILEGED_UPLOAD = True 

561 

562 async def __setup_attachments(self) -> None: 

563 if config.NO_UPLOAD_PATH: 

564 if config.NO_UPLOAD_URL_PREFIX is None: 

565 raise RuntimeError( 

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

567 ) 

568 elif not config.UPLOAD_SERVICE: 

569 try: 

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

571 self.infer_real_domain() 

572 ) 

573 except XMPPError: 

574 info_iq = None 

575 log.exception( 

576 "The upload service could not be automatically determine. " 

577 "Attachments to XMPP will not work. " 

578 "Either specify 'upload-service' or 'no-upload-path' to fix that." 

579 ) 

580 if info_iq is None: 

581 if self.REGISTRATION_TYPE == RegistrationType.QRCODE: 

582 log.warning( 

583 "No method was configured for attachment and slidge " 

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

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

586 "QR-code based registration flow." 

587 ) 

588 if not config.USE_ATTACHMENT_ORIGINAL_URLS: 

589 log.warning( 

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

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

592 "networks, especially for the E2EE attachments." 

593 ) 

594 config.USE_ATTACHMENT_ORIGINAL_URLS = True 

595 else: 

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

597 config.UPLOAD_SERVICE = info_iq["from"] 

598 self.__upload_service_found.set() 

599 

600 def infer_real_domain(self) -> JID: 

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

602 

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

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

605 # MDS node so we receive MDS events 

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

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

608 

609 try: 

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

611 except PermissionError: 

612 log.warning( 

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

614 "create the MDS node of %s", 

615 user_jid, 

616 ) 

617 except PrivilegedIqError as exc: 

618 nested = exc.nested_error() 

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

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

621 log.exception( 

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

623 ) 

624 except Exception as e: 

625 log.exception( 

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

627 user_jid, 

628 exc_info=e, 

629 ) 

630 

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

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

633 "xep_0490" 

634 ].stanza.NS 

635 

636 aff = OwnerAffiliation() 

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

638 aff["affiliation"] = "member" 

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

640 

641 try: 

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

643 except PermissionError: 

644 log.warning( 

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

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

647 user_jid, 

648 ) 

649 except Exception as e: 

650 log.exception( 

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

652 user_jid, 

653 exc_info=e, 

654 ) 

655 

656 async def login_wrap(self, session: SessionType) -> str: 

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

658 session.is_logging_in = True 

659 try: 

660 status = await session.login() 

661 except Exception as e: 

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

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

664 msg = ( 

665 "You are not connected to this gateway! " 

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

667 ) 

668 session.send_gateway_message(msg) 

669 session.logged = False 

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

671 return msg 

672 

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

674 session.logged = True 

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

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

677 await session.contacts._fill(orm) 

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

679 r.set_result(True) 

680 if self.GROUPS: 

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

682 await session.bookmarks.fill() 

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

684 r.set_result(True) 

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

686 if status is None: 

687 status = "Logged in" 

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

689 

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

691 session.create_task(self.fetch_user_avatar(session)) 

692 else: 

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

694 session.user.avatar_hash = None 

695 orm.add(session.user) 

696 orm.commit() 

697 return status 

698 

699 async def fetch_user_avatar(self, session: SessionType) -> None: 

700 try: 

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

702 session.user_jid.bare, 

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

704 ifrom=self.boundjid.bare, 

705 ) 

706 except IqTimeout: 

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

708 return 

709 except IqError as e: 

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

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

712 try: 

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

714 except NotImplementedError: 

715 pass 

716 else: 

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

718 session.user.avatar_hash = None 

719 orm.add(session.user) 

720 orm.commit() 

721 return 

722 await self.__dispatcher.on_avatar_metadata_info( 

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

724 ) 

725 

726 def _send( 

727 self, 

728 stanza: MessageOrPresenceTypeVar, 

729 **send_kwargs: Any, # noqa:ANN401 

730 ) -> MessageOrPresenceTypeVar: 

731 stanza.set_from(self.boundjid.bare) 

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

733 stanza.set_to(mto) 

734 stanza.send() 

735 return stanza 

736 

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

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

739 raise XMPPError( 

740 condition="not-allowed", 

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

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

743 ) 

744 

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

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

747 # to make them more readable. 

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

749 stripped = copy(data) if isinstance(data, str) else data.decode("utf-8") 

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

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

752 # does not matter much 

753 for el in LOG_STRIP_ELEMENTS: 

754 stripped = re.sub( 

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

756 "\1[STRIPPED]\3", 

757 stripped, 

758 flags=re.DOTALL | re.IGNORECASE, 

759 ) 

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

761 if not self.transport: 

762 raise NotConnectedError() 

763 if isinstance(data, str): 

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

765 self.transport.write(data) 

766 

767 def get_session_from_jid(self, j: JID) -> SessionType | None: 

768 try: 

769 return self._session_cls.from_jid(j) 

770 except XMPPError: 

771 return None 

772 

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

774 # """ 

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

776 # 

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

778 # 

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

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

781 # 

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

783 # """ 

784 if isinstance(exception, IqError): 

785 iq = exception.iq 

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

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

788 elif isinstance(exception, IqTimeout): 

789 iq = exception.iq 

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

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

792 elif isinstance(exception, SyntaxError): 

793 # Hide stream parsing errors that occur when the 

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

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

796 pass 

797 else: 

798 if exception: 

799 log.exception(exception) 

800 self.loop.stop() 

801 exit(1) 

802 

803 async def make_registration_form( 

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

805 ) -> Iq: 

806 self.raise_if_not_allowed_jid(iq.get_from()) 

807 reg = iq["register"] 

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

809 user = ( 

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

811 ) 

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

813 

814 form = reg["form"] 

815 form.add_field( 

816 "FORM_TYPE", 

817 ftype="hidden", 

818 value="jabber:iq:register", 

819 ) 

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

821 form["instructions"] = self.REGISTRATION_INSTRUCTIONS 

822 

823 if user is not None: 

824 reg["registered"] = False 

825 form.add_field( 

826 "remove", 

827 label="Remove my registration", 

828 required=True, 

829 ftype="boolean", 

830 value=False, 

831 ) 

832 

833 for field in self.REGISTRATION_FIELDS: 

834 if field.var in reg.interfaces: 

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

836 if val is None: 

837 reg.add_field(field.var) 

838 else: 

839 reg[field.var] = val 

840 

841 reg["instructions"] = self.REGISTRATION_INSTRUCTIONS 

842 

843 for field in self.REGISTRATION_FIELDS: 

844 form.add_field( 

845 field.var, 

846 label=field.label, 

847 required=field.required, 

848 ftype=field.type, 

849 options=field.options, 

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

851 ) 

852 

853 reply = iq.reply() 

854 reply.set_payload(reg) 

855 return reply # type:ignore[no-any-return] 

856 

857 async def user_prevalidate( 

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

859 ) -> JSONSerializable | None: 

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

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

862 for field in self.REGISTRATION_FIELDS: 

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

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

865 

866 return await self.validate(ifrom, form_dict) 

867 

868 @abc.abstractmethod 

869 async def validate( 

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

871 ) -> JSONSerializable | None: 

872 """ 

873 Validate a user's initial registration form. 

874 

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

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

877 

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

879 :attr:`.RegistrationType.SINGLE_STEP_FORM`, 

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

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

882 

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

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

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

886 effectively a confirmation dialog displaying 

887 :attr:`.REGISTRATION_INSTRUCTIONS`. 

888 

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

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

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

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

893 of the 

894 

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

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

897 content will be stored. 

898 """ 

899 raise NotImplementedError 

900 

901 async def validate_two_factor_code( 

902 self, user: GatewayUser, code: str 

903 ) -> JSONSerializable | None: 

904 """ 

905 Called when the user enters their 2FA code. 

906 

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

908 if the login fails, and return successfully otherwise. 

909 

910 Only used when :attr:`REGISTRATION_TYPE` is 

911 :attr:`.RegistrationType.TWO_FACTOR_CODE`. 

912 

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

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

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

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

917 adhoc command 

918 

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

920 for this user. 

921 """ 

922 raise NotImplementedError 

923 

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

925 """ 

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

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

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

929 

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

931 :attr:`.RegistrationType.QRCODE`. 

932 

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

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

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

936 """ 

937 raise NotImplementedError 

938 

939 async def confirm_qr( 

940 self, 

941 user_bare_jid: str, 

942 exception: Exception | None = None, 

943 legacy_data: JSONSerializable | None = None, 

944 ) -> None: 

945 """ 

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

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

948 

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

950 :attr:`.RegistrationType.QRCODE`. 

951 

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

953 :class:`GatewayUser` instance 

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

955 QR code flashing. 

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

957 "legacy_module_data" for this user. 

958 """ 

959 fut = self.qr_pending_registrations[user_bare_jid] 

960 if exception is None: 

961 fut.set_result(legacy_data) 

962 else: 

963 fut.set_exception(exception) 

964 

965 async def unregister_user( 

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

967 ) -> None: 

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

969 await self.xmpp.plugin["xep_0077"].api["user_remove"](None, None, user.jid) # type:ignore[call-arg] 

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

971 

972 async def unregister(self, session: SessionType) -> None: 

973 """ 

974 Optionally override this if you need to clean additional 

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

976 

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

978 

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

980 """ 

981 with contextlib.suppress(NotImplementedError): 

982 await session.logout() 

983 

984 async def input( 

985 self, 

986 jid: JID, 

987 text: str | None = None, 

988 mtype: MessageTypes = "chat", 

989 **input_kwargs: Any, # noqa:ANN401 

990 ) -> str: 

991 """ 

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

993 

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

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

996 

997 :param jid: The JID we want input from 

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

999 :param mtype: Message type 

1000 :return: The user's reply 

1001 """ 

1002 return await self.__chat_commands_handler.input( 

1003 jid, text, mtype=mtype, **input_kwargs 

1004 ) 

1005 

1006 async def send_qr( 

1007 self, 

1008 text: str, 

1009 **msg_kwargs: Any, # noqa:ANN401 

1010 ) -> None: 

1011 """ 

1012 Sends a QR Code to a JID 

1013 

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

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

1016 

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

1018 :param msg_kwargs: Optional additional arguments to pass to 

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

1020 code 

1021 """ 

1022 qr = qrcode.make(text) 

1023 with tempfile.NamedTemporaryFile( 

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

1025 ) as f: 

1026 qr.save(f.name) 

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

1028 

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

1030 # """ 

1031 # Called by the slidge entrypoint on normal exit. 

1032 # 

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

1034 # the gateway component itself. 

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

1036 # """ 

1037 log.debug("Shutting down") 

1038 tasks = [] 

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

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

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

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

1043 return tasks 

1044 

1045 

1046SLIXMPP_PLUGINS = [ 

1047 "xep_0030", # Service discovery 

1048 "xep_0045", # Multi-User Chat 

1049 "xep_0050", # Adhoc commands 

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

1051 "xep_0055", # Jabber search 

1052 "xep_0059", # Result Set Management 

1053 "xep_0066", # Out of Band Data 

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

1055 "xep_0077", # In-band registration 

1056 "xep_0084", # User Avatar 

1057 "xep_0085", # Chat state notifications 

1058 "xep_0100", # Gateway interaction 

1059 "xep_0106", # JID Escaping 

1060 "xep_0115", # Entity capabilities 

1061 "xep_0122", # Data Forms Validation 

1062 "xep_0128", # Service Discovery Extensions 

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

1064 "xep_0172", # User nickname 

1065 "xep_0184", # Message Delivery Receipts 

1066 "xep_0199", # XMPP Ping 

1067 "xep_0221", # Data Forms Media Element 

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

1069 "xep_0249", # Direct MUC Invitations 

1070 "xep_0264", # Jingle Content Thumbnails 

1071 "xep_0280", # Carbons 

1072 "xep_0292_provider", # VCard4 

1073 "xep_0308", # Last message correction 

1074 "xep_0313", # Message Archive Management 

1075 "xep_0317", # Hats 

1076 "xep_0319", # Last User Interaction in Presence 

1077 "xep_0333", # Chat markers 

1078 "xep_0334", # Message Processing Hints 

1079 "xep_0356", # Privileged Entity 

1080 "xep_0363", # HTTP file upload 

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

1082 "xep_0402", # PEP Native Bookmarks 

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

1084 "xep_0424", # Message retraction 

1085 "xep_0425", # Message moderation 

1086 "xep_0444", # Message reactions 

1087 "xep_0447", # Stateless File Sharing 

1088 "xep_0449", # Stickers 

1089 "xep_0461", # Message replies 

1090 "xep_0462", # Pubsub Type Filtering 

1091 "xep_0463", # MUC Affiliation Versioning 

1092 "xep_0469", # Bookmark Pinning 

1093 "xep_0490", # Message Displayed Synchronization 

1094 "xep_0492", # Chat Notification Settings 

1095 # "xep_0503", # Server-side spaces 

1096 "xep_0511", # Link Metadata 

1097] 

1098 

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

1100 

1101log = logging.getLogger(__name__)