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

466 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-20 19:56 +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 ( 

16 TYPE_CHECKING, 

17 Any, 

18 ClassVar, 

19 Concatenate, 

20 Generic, 

21 ParamSpec, 

22 TypeVar, 

23 cast, 

24) 

25 

26import aiohttp 

27import qrcode 

28from slixmpp import JID, ComponentXMPP, Iq 

29from slixmpp.exceptions import IqError, IqTimeout, XMPPError 

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

31from slixmpp.plugins.xep_0060.stanza import OwnerAffiliation 

32from slixmpp.plugins.xep_0356.permissions import IqPermission 

33from slixmpp.plugins.xep_0356.privilege import PrivilegedIqError 

34from slixmpp.types import MessageTypes 

35from slixmpp.xmlstream.xmlstream import NotConnectedError 

36from sqlalchemy.orm import Session as OrmSession 

37 

38import slidge.command.categories 

39from slidge.command.adhoc import AdhocProvider 

40from slidge.command.admin import Exec 

41from slidge.command.base import Command, FormField 

42from slidge.command.chat_command import ChatCommandProvider 

43from slidge.command.register import RegistrationType 

44from slidge.contact import LegacyContact 

45from slidge.core import config 

46from slidge.core.dispatcher.session_dispatcher import SessionDispatcher 

47from slidge.core.mixins.attachment import AttachmentMixin 

48from slidge.core.mixins.avatar import convert_avatar 

49from slidge.core.mixins.message import MessageMixin 

50from slidge.core.pubsub import PubSubComponent 

51from slidge.db import GatewayUser, SlidgeStore 

52from slidge.db.avatar import CachedAvatar, avatar_cache 

53from slidge.db.meta import JSONSerializable 

54from slidge.slixfix.delivery_receipt import DeliveryReceipt 

55from slidge.slixfix.roster import RosterBackend 

56from slidge.util import SubclassableOnce 

57from slidge.util.types import ( 

58 AnyGateway, 

59 Avatar, 

60 MessageOrPresenceTypeVar, 

61 SessionType, 

62) 

63 

64if TYPE_CHECKING: 

65 pass 

66 

67 

68T = TypeVar("T") 

69P = ParamSpec("P") 

70 

71 

72class BaseGateway( 

73 ComponentXMPP, 

74 MessageMixin, 

75 SubclassableOnce, 

76 Generic[SessionType], 

77 abc.ABC, 

78): 

79 """ 

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

81 

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

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

84 ``.xmpp`` attribute. 

85 

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

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

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

89 

90 Abstract methods related to the registration process must be overriden 

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

92 

93 - :meth:`.validate` 

94 - :meth:`.validate_two_factor_code` 

95 - :meth:`.get_qr_text` 

96 - :meth:`.confirm_qr` 

97 

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

99 :attr:`REGISTRATION_TYPE`. 

100 

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

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

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

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

105 `mto` parameter. 

106 

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

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

109 

110 .. code-block:: python 

111 

112 self.send_presence( 

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

114 pto="someonwelse@anotherexample.com", 

115 ) 

116 

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

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

119 as sending messages, or displaying a custom status. 

120 

121 """ 

122 

123 COMPONENT_NAME: str = NotImplemented 

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

125 COMPONENT_TYPE: str = "" 

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

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

128 """ 

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

130 """ 

131 

132 REGISTRATION_FIELDS: Sequence[FormField] = [ 

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

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

135 ] 

136 """ 

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

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

139 """ 

140 REGISTRATION_INSTRUCTIONS: str = "Enter your credentials" 

141 """ 

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

143 configuration. 

144 """ 

145 REGISTRATION_TYPE: RegistrationType = RegistrationType.SINGLE_STEP_FORM 

146 """ 

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

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

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

150 once per user (unless they unregister). 

151 

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

153 presented to the user. 

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

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

156 """ 

157 

158 REGISTRATION_2FA_TITLE = "Enter your 2FA code" 

159 REGISTRATION_2FA_INSTRUCTIONS = ( 

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

161 ) 

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

163 

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

165 FormField( 

166 var="sync_presence", 

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

168 value="true", 

169 required=True, 

170 type="boolean", 

171 ), 

172 FormField( 

173 var="sync_avatar", 

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

175 value="true", 

176 required=True, 

177 type="boolean", 

178 ), 

179 FormField( 

180 var="always_invite_when_adding_bookmarks", 

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

182 value="true", 

183 required=True, 

184 type="boolean", 

185 ), 

186 FormField( 

187 var="last_seen_fallback", 

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

189 value="true", 

190 required=True, 

191 type="boolean", 

192 ), 

193 FormField( 

194 var="roster_push", 

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

196 value="true", 

197 required=True, 

198 type="boolean", 

199 ), 

200 FormField( 

201 var="reaction_fallback", 

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

203 value="false", 

204 required=True, 

205 type="boolean", 

206 ), 

207 ] 

208 

209 ROSTER_GROUP: str = "slidge" 

210 """ 

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

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

213 """ 

214 WELCOME_MESSAGE = ( 

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

216 "or just start messaging away!" 

217 ) 

218 """ 

219 A welcome message displayed to users on registration. 

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

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

222 incoming messages from components. 

223 """ 

224 

225 SEARCH_FIELDS: Sequence[FormField] = [ 

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

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

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

229 ] 

230 """ 

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

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

233 their usernames, eg their phone number. 

234 

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

236 (restricted to registered users). 

237 

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

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

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

241 """ 

242 SEARCH_TITLE: str = "Search for legacy contacts" 

243 """ 

244 Title of the search form. 

245 """ 

246 SEARCH_INSTRUCTIONS: str = "" 

247 """ 

248 Instructions of the search form. 

249 """ 

250 

251 MARK_ALL_MESSAGES = False 

252 """ 

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

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

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

256 """ 

257 

258 PROPER_RECEIPTS = False 

259 """ 

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

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

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

263 """ 

264 

265 GROUPS = False 

266 """ 

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

268 """ 

269 SPACES = False 

270 """ 

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

272 """ 

273 

274 mtype: MessageTypes = "chat" 

275 is_group = False 

276 _can_send_carbon = False 

277 store: SlidgeStore 

278 _session_cls: type[SessionType] 

279 

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

281 """ 

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

283 

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

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

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

287 Common example: ``int``. 

288 """ 

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

290 # (maybe we do) 

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

292 """ 

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

294 

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

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

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

298 Common example: ``int``. 

299 """ 

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

301 """ 

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

303 

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

305 The callable specified here is responsible for converting the 

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

307 Common example: ``int``. 

308 """ 

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

310 """ 

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

312 

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

314 The callable specified here is responsible for converting the 

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

316 Common example: ``int``. 

317 """ 

318 

319 DB_POOL_SIZE: int = 5 

320 """ 

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

322 libraries, this does not need to be changed. 

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

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

325 """ 

326 

327 http: aiohttp.ClientSession 

328 avatar: CachedAvatar | None = None 

329 

330 def __init__(self) -> None: 

331 if config.COMPONENT_NAME: 

332 self.COMPONENT_NAME = config.COMPONENT_NAME 

333 if config.WELCOME_MESSAGE: 

334 self.WELCOME_MESSAGE = config.WELCOME_MESSAGE 

335 self.log = log 

336 self.datetime_started = datetime.now() 

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

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

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

340 super().__init__( 

341 config.JID, 

342 config.SECRET, 

343 config.SERVER, 

344 config.PORT, 

345 plugin_whitelist=SLIXMPP_PLUGINS, 

346 plugin_config={ 

347 "xep_0077": { 

348 "form_fields": None, 

349 "form_instructions": self.REGISTRATION_INSTRUCTIONS, 

350 "enable_subscription": self.REGISTRATION_TYPE 

351 == RegistrationType.SINGLE_STEP_FORM, 

352 }, 

353 "xep_0100": { 

354 "component_name": self.COMPONENT_NAME, 

355 "type": self.COMPONENT_TYPE, 

356 }, 

357 "xep_0184": { 

358 "auto_ack": False, 

359 "auto_request": False, 

360 }, 

361 "xep_0363": { 

362 "upload_service": config.UPLOAD_SERVICE, 

363 }, 

364 }, 

365 fix_error_ns=True, 

366 ) 

367 self.loop.set_exception_handler(self.__exception_handler) 

368 self.loop.create_task(self.__set_http()) 

369 self.has_crashed: bool = False 

370 self.use_origin_id = False 

371 

372 if config.USER_JID_VALIDATOR is None: 

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

374 log.info( 

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

376 config.USER_JID_VALIDATOR, 

377 ) 

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

379 self.qr_pending_registrations = dict[ 

380 str, asyncio.Future[JSONSerializable | None] 

381 ]() 

382 

383 self.register_plugins() 

384 self.__setup_legacy_module_subclasses() 

385 

386 self.get_session_from_stanza = self._session_cls.from_stanza 

387 self.get_session_from_user = self._session_cls.from_user 

388 

389 self.__register_slixmpp_events() 

390 self.__register_slixmpp_api() 

391 self.roster.set_backend(RosterBackend(self)) 

392 

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

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

395 self.delivery_receipt = DeliveryReceipt(self) 

396 

397 # with this we receive user avatar updates 

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

399 

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

401 

402 if self.GROUPS: 

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

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

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

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

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

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

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

410 category="conference", 

411 name=self.COMPONENT_NAME, 

412 itype="text", 

413 jid=self.boundjid, 

414 ) 

415 if self.SPACES: 

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

417 

418 self.__adhoc_handler = AdhocProvider(self) 

419 self.__chat_commands_handler = ChatCommandProvider(self) 

420 

421 self.__dispatcher = SessionDispatcher(self) 

422 

423 self.__register_commands() 

424 

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

426 

427 def __setup_legacy_module_subclasses(self) -> None: 

428 from ..contact.roster import LegacyRoster 

429 from ..group.bookmarks import LegacyBookmarks 

430 from ..group.participant import LegacyParticipant 

431 from ..group.room import LegacyMUC 

432 

433 contact_cls = LegacyContact.get_self_or_unique_subclass() 

434 muc_cls = LegacyMUC.get_self_or_unique_subclass() 

435 participant_cls = LegacyParticipant.get_self_or_unique_subclass() 

436 bookmarks_cls = LegacyBookmarks.get_self_or_unique_subclass() 

437 roster_cls = LegacyRoster.get_self_or_unique_subclass() 

438 

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

440 form = Form() 

441 form["type"] = "result" 

442 form.add_field( 

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

444 ) 

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

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

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

448 

449 self._session_cls.xmpp = cast(AnyGateway, self) 

450 contact_cls.xmpp = cast(AnyGateway, self) # type:ignore[attr-defined] 

451 muc_cls.xmpp = cast(AnyGateway, self) # type:ignore[attr-defined] 

452 

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

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

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

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

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

458 

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

460 await self._session_cls.kill_by_jid(jid) 

461 

462 async def __set_http(self) -> None: 

463 self.http = aiohttp.ClientSession() 

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

465 return 

466 avatar_cache.http = self.http 

467 

468 def __register_commands(self) -> None: 

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

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

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

472 continue 

473 if cls is Exec: 

474 if config.DEV_MODE: 

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

476 else: 

477 continue 

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

479 continue 

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

481 continue 

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

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

484 self.__adhoc_handler.register(c) 

485 self.__chat_commands_handler.register(c) 

486 

487 def __exception_handler( 

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

489 ) -> None: 

490 """ 

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

492 

493 :param loop: 

494 :param context: 

495 :return: 

496 """ 

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

498 exc = context.get("exception") 

499 if exc is None: 

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

501 elif isinstance(exc, SystemExit): 

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

503 else: 

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

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

506 self.has_crashed = True 

507 loop.stop() 

508 

509 def __register_slixmpp_events(self) -> None: 

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

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

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

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

514 self.del_event_handler( 

515 "roster_subscription_request", self._handle_new_subscription 

516 ) 

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

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

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

520 self.__upload_service_found = asyncio.Event() 

521 

522 def __register_slixmpp_api(self) -> None: 

523 def with_session( 

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

525 ) -> Callable[P, T]: 

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

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

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

529 if commit: 

530 orm.commit() 

531 return res 

532 

533 return wrapped 

534 

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

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

537 ) 

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

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

540 ) 

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

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

543 ) 

544 

545 @property # type:ignore[override] 

546 def jid(self) -> JID: 

547 # Override to avoid slixmpp deprecation warnings. 

548 return self.boundjid 

549 

550 @jid.setter 

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

552 raise RuntimeError 

553 

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

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

556 

557 await self.__setup_attachments() 

558 

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

560 disco = self.plugin["xep_0030"] 

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

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

563 

564 if self.COMPONENT_AVATAR is not None: 

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

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

567 assert avatar is not None 

568 try: 

569 cached_avatar = await avatar_cache.convert_or_get(avatar) 

570 except Exception as e: 

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

572 cached_avatar = None 

573 else: 

574 assert cached_avatar is not None 

575 self.avatar = cached_avatar 

576 else: 

577 cached_avatar = None 

578 

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

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

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

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

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

584 # as last resort. 

585 try: 

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

587 await self.__add_component_to_mds_whitelist(user.jid) 

588 except (IqError, IqTimeout) as e: 

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

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

591 log.warning( 

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

593 user, 

594 exc_info=e, 

595 ) 

596 continue 

597 session = self._session_cls.from_user(user) 

598 session.create_task(self.login_wrap(session)) 

599 if cached_avatar is not None: 

600 await self.pubsub.broadcast_avatar( 

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

602 ) 

603 

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

605 

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

607 await self.__upload_service_found.wait() 

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

609 config.SERVER 

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

611 IqPermission.SET, 

612 IqPermission.BOTH, 

613 IqPermission.GET, 

614 ): 

615 AttachmentMixin.PRIVILEGED_UPLOAD = True 

616 

617 async def __setup_attachments(self) -> None: 

618 if config.NO_UPLOAD_PATH: 

619 if config.NO_UPLOAD_URL_PREFIX is None: 

620 raise RuntimeError( 

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

622 ) 

623 elif not config.UPLOAD_SERVICE: 

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

625 self.infer_real_domain() 

626 ) 

627 if info_iq is None: 

628 if self.REGISTRATION_TYPE == RegistrationType.QRCODE: 

629 log.warning( 

630 "No method was configured for attachment and slidge " 

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

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

633 "QR-code based registration flow." 

634 ) 

635 if not config.USE_ATTACHMENT_ORIGINAL_URLS: 

636 log.warning( 

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

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

639 "networks, especially for the E2EE attachments." 

640 ) 

641 config.USE_ATTACHMENT_ORIGINAL_URLS = True 

642 else: 

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

644 config.UPLOAD_SERVICE = info_iq["from"] 

645 self.__upload_service_found.set() 

646 

647 def infer_real_domain(self) -> JID: 

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

649 

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

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

652 # MDS node so we receive MDS events 

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

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

655 

656 try: 

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

658 except PermissionError: 

659 log.warning( 

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

661 "create the MDS node of %s", 

662 user_jid, 

663 ) 

664 except PrivilegedIqError as exc: 

665 nested = exc.nested_error() 

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

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

668 log.exception( 

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

670 ) 

671 except Exception as e: 

672 log.exception( 

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

674 user_jid, 

675 exc_info=e, 

676 ) 

677 

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

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

680 "xep_0490" 

681 ].stanza.NS 

682 

683 aff = OwnerAffiliation() 

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

685 aff["affiliation"] = "member" 

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

687 

688 try: 

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

690 except PermissionError: 

691 log.warning( 

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

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

694 user_jid, 

695 ) 

696 except Exception as e: 

697 log.exception( 

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

699 user_jid, 

700 exc_info=e, 

701 ) 

702 

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

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

705 session.is_logging_in = True 

706 try: 

707 status = await session.login() 

708 except Exception as e: 

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

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

711 msg = ( 

712 "You are not connected to this gateway! " 

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

714 ) 

715 session.send_gateway_message(msg) 

716 session.logged = False 

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

718 return msg 

719 

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

721 session.logged = True 

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

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

724 await session.contacts._fill(orm) 

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

726 r.set_result(True) 

727 if self.GROUPS: 

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

729 await session.bookmarks.fill() 

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

731 r.set_result(True) 

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

733 if status is None: 

734 status = "Logged in" 

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

736 

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

738 session.create_task(self.fetch_user_avatar(session)) 

739 else: 

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

741 session.user.avatar_hash = None 

742 orm.add(session.user) 

743 orm.commit() 

744 return status 

745 

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

747 try: 

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

749 session.user_jid.bare, 

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

751 ifrom=self.boundjid.bare, 

752 ) 

753 except IqTimeout: 

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

755 return 

756 except IqError as e: 

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

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

759 try: 

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

761 except NotImplementedError: 

762 pass 

763 else: 

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

765 session.user.avatar_hash = None 

766 orm.add(session.user) 

767 orm.commit() 

768 return 

769 await self.__dispatcher.on_avatar_metadata_info( 

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

771 ) 

772 

773 def _send( 

774 self, 

775 stanza: MessageOrPresenceTypeVar, 

776 **send_kwargs: Any, # noqa:ANN401 

777 ) -> MessageOrPresenceTypeVar: 

778 stanza.set_from(self.boundjid.bare) 

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

780 stanza.set_to(mto) 

781 stanza.send() 

782 return stanza 

783 

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

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

786 raise XMPPError( 

787 condition="not-allowed", 

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

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

790 ) 

791 

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

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

794 # to make them more readable. 

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

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

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

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

799 # does not matter much 

800 for el in LOG_STRIP_ELEMENTS: 

801 stripped = re.sub( 

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

803 "\1[STRIPPED]\3", 

804 stripped, 

805 flags=re.DOTALL | re.IGNORECASE, 

806 ) 

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

808 if not self.transport: 

809 raise NotConnectedError() 

810 if isinstance(data, str): 

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

812 self.transport.write(data) 

813 

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

815 try: 

816 return self._session_cls.from_jid(j) 

817 except XMPPError: 

818 return None 

819 

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

821 # """ 

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

823 # 

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

825 # 

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

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

828 # 

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

830 # """ 

831 if isinstance(exception, IqError): 

832 iq = exception.iq 

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

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

835 elif isinstance(exception, IqTimeout): 

836 iq = exception.iq 

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

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

839 elif isinstance(exception, SyntaxError): 

840 # Hide stream parsing errors that occur when the 

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

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

843 pass 

844 else: 

845 if exception: 

846 log.exception(exception) 

847 self.loop.stop() 

848 exit(1) 

849 

850 async def make_registration_form( 

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

852 ) -> Iq: 

853 self.raise_if_not_allowed_jid(iq.get_from()) 

854 reg = iq["register"] 

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

856 user = ( 

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

858 ) 

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

860 

861 form = reg["form"] 

862 form.add_field( 

863 "FORM_TYPE", 

864 ftype="hidden", 

865 value="jabber:iq:register", 

866 ) 

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

868 form["instructions"] = self.REGISTRATION_INSTRUCTIONS 

869 

870 if user is not None: 

871 reg["registered"] = False 

872 form.add_field( 

873 "remove", 

874 label="Remove my registration", 

875 required=True, 

876 ftype="boolean", 

877 value=False, 

878 ) 

879 

880 for field in self.REGISTRATION_FIELDS: 

881 if field.var in reg.interfaces: 

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

883 if val is None: 

884 reg.add_field(field.var) 

885 else: 

886 reg[field.var] = val 

887 

888 reg["instructions"] = self.REGISTRATION_INSTRUCTIONS 

889 

890 for field in self.REGISTRATION_FIELDS: 

891 form.add_field( 

892 field.var, 

893 label=field.label, 

894 required=field.required, 

895 ftype=field.type, 

896 options=field.options, 

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

898 ) 

899 

900 reply = iq.reply() 

901 reply.set_payload(reg) 

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

903 

904 async def user_prevalidate( 

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

906 ) -> JSONSerializable | None: 

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

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

909 for field in self.REGISTRATION_FIELDS: 

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

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

912 

913 return await self.validate(ifrom, form_dict) 

914 

915 @abc.abstractmethod 

916 async def validate( 

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

918 ) -> JSONSerializable | None: 

919 """ 

920 Validate a user's initial registration form. 

921 

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

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

924 

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

926 :attr:`.RegistrationType.SINGLE_STEP_FORM`, 

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

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

929 

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

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

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

933 effectively a confirmation dialog displaying 

934 :attr:`.REGISTRATION_INSTRUCTIONS`. 

935 

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

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

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

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

940 of the 

941 

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

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

944 content will be stored. 

945 """ 

946 raise NotImplementedError 

947 

948 async def validate_two_factor_code( 

949 self, user: GatewayUser, code: str 

950 ) -> JSONSerializable | None: 

951 """ 

952 Called when the user enters their 2FA code. 

953 

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

955 if the login fails, and return successfully otherwise. 

956 

957 Only used when :attr:`REGISTRATION_TYPE` is 

958 :attr:`.RegistrationType.TWO_FACTOR_CODE`. 

959 

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

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

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

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

964 adhoc command 

965 

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

967 for this user. 

968 """ 

969 raise NotImplementedError 

970 

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

972 """ 

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

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

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

976 

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

978 :attr:`.RegistrationType.QRCODE`. 

979 

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

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

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

983 """ 

984 raise NotImplementedError 

985 

986 async def confirm_qr( 

987 self, 

988 user_bare_jid: str, 

989 exception: Exception | None = None, 

990 legacy_data: JSONSerializable | None = None, 

991 ) -> None: 

992 """ 

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

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

995 

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

997 :attr:`.RegistrationType.QRCODE`. 

998 

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

1000 :class:`GatewayUser` instance 

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

1002 QR code flashing. 

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

1004 "legacy_module_data" for this user. 

1005 """ 

1006 fut = self.qr_pending_registrations[user_bare_jid] 

1007 if exception is None: 

1008 fut.set_result(legacy_data) 

1009 else: 

1010 fut.set_exception(exception) 

1011 

1012 async def unregister_user( 

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

1014 ) -> None: 

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

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

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

1018 

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

1020 """ 

1021 Optionally override this if you need to clean additional 

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

1023 

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

1025 

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

1027 """ 

1028 with contextlib.suppress(NotImplementedError): 

1029 await session.logout() 

1030 

1031 async def input( 

1032 self, 

1033 jid: JID, 

1034 text: str | None = None, 

1035 mtype: MessageTypes = "chat", 

1036 **input_kwargs: Any, # noqa:ANN401 

1037 ) -> str: 

1038 """ 

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

1040 

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

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

1043 

1044 :param jid: The JID we want input from 

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

1046 :param mtype: Message type 

1047 :return: The user's reply 

1048 """ 

1049 return await self.__chat_commands_handler.input( 

1050 jid, text, mtype=mtype, **input_kwargs 

1051 ) 

1052 

1053 async def send_qr( 

1054 self, 

1055 text: str, 

1056 **msg_kwargs: Any, # noqa:ANN401 

1057 ) -> None: 

1058 """ 

1059 Sends a QR Code to a JID 

1060 

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

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

1063 

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

1065 :param msg_kwargs: Optional additional arguments to pass to 

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

1067 code 

1068 """ 

1069 qr = qrcode.make(text) 

1070 with tempfile.NamedTemporaryFile( 

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

1072 ) as f: 

1073 qr.save(f.name) 

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

1075 

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

1077 # """ 

1078 # Called by the slidge entrypoint on normal exit. 

1079 # 

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

1081 # the gateway component itself. 

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

1083 # """ 

1084 log.debug("Shutting down") 

1085 tasks = [] 

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

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

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

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

1090 return tasks 

1091 

1092 

1093SLIXMPP_PLUGINS = [ 

1094 "xep_0030", # Service discovery 

1095 "xep_0045", # Multi-User Chat 

1096 "xep_0050", # Adhoc commands 

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

1098 "xep_0055", # Jabber search 

1099 "xep_0059", # Result Set Management 

1100 "xep_0066", # Out of Band Data 

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

1102 "xep_0077", # In-band registration 

1103 "xep_0084", # User Avatar 

1104 "xep_0085", # Chat state notifications 

1105 "xep_0100", # Gateway interaction 

1106 "xep_0106", # JID Escaping 

1107 "xep_0115", # Entity capabilities 

1108 "xep_0122", # Data Forms Validation 

1109 "xep_0128", # Service Discovery Extensions 

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

1111 "xep_0172", # User nickname 

1112 "xep_0184", # Message Delivery Receipts 

1113 "xep_0199", # XMPP Ping 

1114 "xep_0221", # Data Forms Media Element 

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

1116 "xep_0249", # Direct MUC Invitations 

1117 "xep_0264", # Jingle Content Thumbnails 

1118 "xep_0280", # Carbons 

1119 "xep_0292_provider", # VCard4 

1120 "xep_0308", # Last message correction 

1121 "xep_0313", # Message Archive Management 

1122 "xep_0317", # Hats 

1123 "xep_0319", # Last User Interaction in Presence 

1124 "xep_0333", # Chat markers 

1125 "xep_0334", # Message Processing Hints 

1126 "xep_0356", # Privileged Entity 

1127 "xep_0363", # HTTP file upload 

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

1129 "xep_0402", # PEP Native Bookmarks 

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

1131 "xep_0424", # Message retraction 

1132 "xep_0425", # Message moderation 

1133 "xep_0444", # Message reactions 

1134 "xep_0447", # Stateless File Sharing 

1135 "xep_0449", # Stickers 

1136 "xep_0461", # Message replies 

1137 "xep_0462", # Pubsub Type Filtering 

1138 "xep_0463", # MUC Affiliation Versioning 

1139 "xep_0469", # Bookmark Pinning 

1140 "xep_0490", # Message Displayed Synchronization 

1141 "xep_0492", # Chat Notification Settings 

1142 # "xep_0503", # Server-side spaces 

1143 "xep_0511", # Link Metadata 

1144] 

1145 

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

1147 

1148log = logging.getLogger(__name__)