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

460 statements  

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

1""" 

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

3""" 

4 

5import abc 

6import asyncio 

7import logging 

8import re 

9import tempfile 

10from collections.abc import Callable, Sequence 

11from copy import copy 

12from datetime import datetime 

13from pathlib import Path 

14from typing import ( 

15 TYPE_CHECKING, 

16 Any, 

17 ClassVar, 

18 Concatenate, 

19 ParamSpec, 

20 TypeVar, 

21 cast, 

22) 

23 

24import aiohttp 

25import qrcode 

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

27from slixmpp.exceptions import IqError, IqTimeout, XMPPError 

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

29from slixmpp.plugins.xep_0060.stanza import OwnerAffiliation 

30from slixmpp.plugins.xep_0356.permissions import IqPermission 

31from slixmpp.plugins.xep_0356.privilege import PrivilegedIqError 

32from slixmpp.types import MessageTypes 

33from slixmpp.xmlstream.xmlstream import NotConnectedError 

34from sqlalchemy.orm import Session as OrmSession 

35 

36from slidge.command.adhoc import AdhocProvider 

37from slidge.command.admin import Exec 

38from slidge.command.base import Command, FormField 

39from slidge.command.chat_command import ChatCommandProvider 

40from slidge.command.register import RegistrationType 

41from slidge.contact import LegacyContact 

42from slidge.core import config 

43from slidge.core.dispatcher.session_dispatcher import SessionDispatcher 

44from slidge.core.mixins.attachment import AttachmentMixin 

45from slidge.core.mixins.avatar import convert_avatar 

46from slidge.core.mixins.message import MessageMixin 

47from slidge.core.pubsub import PubSubComponent 

48from slidge.core.session import BaseSession 

49from slidge.db import GatewayUser, SlidgeStore 

50from slidge.db.avatar import CachedAvatar, avatar_cache 

51from slidge.db.meta import JSONSerializable 

52from slidge.slixfix.delivery_receipt import DeliveryReceipt 

53from slidge.slixfix.roster import RosterBackend 

54from slidge.util import SubclassableOnce 

55from slidge.util.types import AnySession, Avatar, MessageOrPresenceTypeVar 

56 

57if TYPE_CHECKING: 

58 pass 

59 

60 

61T = TypeVar("T") 

62P = ParamSpec("P") 

63 

64 

65class BaseGateway( 

66 ComponentXMPP, 

67 MessageMixin, 

68 SubclassableOnce, 

69 abc.ABC, 

70): 

71 """ 

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

73 

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

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

76 ``.xmpp`` attribute. 

77 

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

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

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

81 

82 Abstract methods related to the registration process must be overriden 

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

84 

85 - :meth:`.validate` 

86 - :meth:`.validate_two_factor_code` 

87 - :meth:`.get_qr_text` 

88 - :meth:`.confirm_qr` 

89 

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

91 :attr:`REGISTRATION_TYPE`. 

92 

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

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

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

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

97 `mto` parameter. 

98 

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

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

101 

102 .. code-block:: python 

103 

104 self.send_presence( 

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

106 pto="someonwelse@anotherexample.com", 

107 ) 

108 

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

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

111 as sending messages, or displaying a custom status. 

112 

113 """ 

114 

115 COMPONENT_NAME: str = NotImplemented 

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

117 COMPONENT_TYPE: str = "" 

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

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

120 """ 

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

122 """ 

123 

124 REGISTRATION_FIELDS: Sequence[FormField] = [ 

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

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

127 ] 

128 """ 

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

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

131 """ 

132 REGISTRATION_INSTRUCTIONS: str = "Enter your credentials" 

133 """ 

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

135 configuration. 

136 """ 

137 REGISTRATION_TYPE: RegistrationType = RegistrationType.SINGLE_STEP_FORM 

138 """ 

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

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

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

142 once per user (unless they unregister). 

143 

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

145 presented to the user. 

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

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

148 """ 

149 

150 REGISTRATION_2FA_TITLE = "Enter your 2FA code" 

151 REGISTRATION_2FA_INSTRUCTIONS = ( 

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

153 ) 

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

155 

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

157 FormField( 

158 var="sync_presence", 

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

160 value="true", 

161 required=True, 

162 type="boolean", 

163 ), 

164 FormField( 

165 var="sync_avatar", 

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

167 value="true", 

168 required=True, 

169 type="boolean", 

170 ), 

171 FormField( 

172 var="always_invite_when_adding_bookmarks", 

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

174 value="true", 

175 required=True, 

176 type="boolean", 

177 ), 

178 FormField( 

179 var="last_seen_fallback", 

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

181 value="true", 

182 required=True, 

183 type="boolean", 

184 ), 

185 FormField( 

186 var="roster_push", 

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

188 value="true", 

189 required=True, 

190 type="boolean", 

191 ), 

192 FormField( 

193 var="reaction_fallback", 

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

195 value="false", 

196 required=True, 

197 type="boolean", 

198 ), 

199 ] 

200 

201 ROSTER_GROUP: str = "slidge" 

202 """ 

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

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

205 """ 

206 WELCOME_MESSAGE = ( 

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

208 "or just start messaging away!" 

209 ) 

210 """ 

211 A welcome message displayed to users on registration. 

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

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

214 incoming messages from components. 

215 """ 

216 

217 SEARCH_FIELDS: Sequence[FormField] = [ 

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

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

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

221 ] 

222 """ 

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

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

225 their usernames, eg their phone number. 

226 

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

228 (restricted to registered users). 

229 

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

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

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

233 """ 

234 SEARCH_TITLE: str = "Search for legacy contacts" 

235 """ 

236 Title of the search form. 

237 """ 

238 SEARCH_INSTRUCTIONS: str = "" 

239 """ 

240 Instructions of the search form. 

241 """ 

242 

243 MARK_ALL_MESSAGES = False 

244 """ 

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

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

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

248 """ 

249 

250 PROPER_RECEIPTS = False 

251 """ 

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

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

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

255 """ 

256 

257 GROUPS = False 

258 

259 mtype: MessageTypes = "chat" 

260 is_group = False 

261 _can_send_carbon = False 

262 store: SlidgeStore 

263 

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

265 """ 

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

267 

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

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

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

271 Common example: ``int``. 

272 """ 

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

274 # (maybe we do) 

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

276 """ 

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

278 

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

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

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

282 Common example: ``int``. 

283 """ 

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

285 """ 

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

287 

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

289 The callable specified here is responsible for converting the 

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

291 Common example: ``int``. 

292 """ 

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

294 """ 

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

296 

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

298 The callable specified here is responsible for converting the 

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

300 Common example: ``int``. 

301 """ 

302 

303 DB_POOL_SIZE: int = 5 

304 """ 

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

306 libraries, this does not need to be changed. 

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

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

309 """ 

310 

311 http: aiohttp.ClientSession 

312 avatar: CachedAvatar | None = None 

313 

314 def __init__(self) -> None: 

315 if config.COMPONENT_NAME: 

316 self.COMPONENT_NAME = config.COMPONENT_NAME 

317 if config.WELCOME_MESSAGE: 

318 self.WELCOME_MESSAGE = config.WELCOME_MESSAGE 

319 self.log = log 

320 self.datetime_started = datetime.now() 

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

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

323 super().__init__( 

324 config.JID, 

325 config.SECRET, 

326 config.SERVER, 

327 config.PORT, 

328 plugin_whitelist=SLIXMPP_PLUGINS, 

329 plugin_config={ 

330 "xep_0077": { 

331 "form_fields": None, 

332 "form_instructions": self.REGISTRATION_INSTRUCTIONS, 

333 "enable_subscription": self.REGISTRATION_TYPE 

334 == RegistrationType.SINGLE_STEP_FORM, 

335 }, 

336 "xep_0100": { 

337 "component_name": self.COMPONENT_NAME, 

338 "type": self.COMPONENT_TYPE, 

339 }, 

340 "xep_0184": { 

341 "auto_ack": False, 

342 "auto_request": False, 

343 }, 

344 "xep_0363": { 

345 "upload_service": config.UPLOAD_SERVICE, 

346 }, 

347 }, 

348 fix_error_ns=True, 

349 ) 

350 self.loop.set_exception_handler(self.__exception_handler) 

351 self.loop.create_task(self.__set_http()) 

352 self.has_crashed: bool = False 

353 self.use_origin_id = False 

354 

355 if config.USER_JID_VALIDATOR is None: 

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

357 log.info( 

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

359 config.USER_JID_VALIDATOR, 

360 ) 

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

362 self.qr_pending_registrations = dict[ 

363 str, asyncio.Future[JSONSerializable | None] 

364 ]() 

365 

366 self.register_plugins() 

367 self.__setup_legacy_module_subclasses() 

368 

369 self.get_session_from_stanza: Callable[ 

370 [Message | Presence | Iq], AnySession 

371 ] = self._session_cls.from_stanza 

372 self.get_session_from_user: Callable[[GatewayUser], AnySession] = ( 

373 self._session_cls.from_user 

374 ) 

375 

376 self.__register_slixmpp_events() 

377 self.__register_slixmpp_api() 

378 self.roster.set_backend(RosterBackend(self)) 

379 

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

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

382 self.delivery_receipt: DeliveryReceipt = DeliveryReceipt(self) 

383 

384 # with this we receive user avatar updates 

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

386 

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

388 

389 if self.GROUPS: 

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

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

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

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

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

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

396 category="conference", 

397 name=self.COMPONENT_NAME, 

398 itype="text", 

399 jid=self.boundjid, 

400 ) 

401 

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

403 self.__adhoc_handler: AdhocProvider = AdhocProvider(self) 

404 self.__chat_commands_handler: ChatCommandProvider = ChatCommandProvider(self) 

405 

406 self.__dispatcher = SessionDispatcher(self) 

407 

408 self.__register_commands() 

409 

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

411 

412 def __setup_legacy_module_subclasses(self) -> None: 

413 from ..contact.roster import LegacyRoster 

414 from ..group.bookmarks import LegacyBookmarks 

415 from ..group.participant import LegacyParticipant 

416 from ..group.room import LegacyMUC 

417 

418 session_cls: type[AnySession] = cast( 

419 type[AnySession], BaseSession.get_unique_subclass() 

420 ) 

421 contact_cls = LegacyContact.get_self_or_unique_subclass() 

422 muc_cls = LegacyMUC.get_self_or_unique_subclass() 

423 participant_cls = LegacyParticipant.get_self_or_unique_subclass() 

424 bookmarks_cls = LegacyBookmarks.get_self_or_unique_subclass() 

425 roster_cls = LegacyRoster.get_self_or_unique_subclass() 

426 

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

428 form = Form() 

429 form["type"] = "result" 

430 form.add_field( 

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

432 ) 

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

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

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

436 

437 session_cls.xmpp = self 

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

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

440 

441 self._session_cls = session_cls 

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

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

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

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

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

447 

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

449 await self._session_cls.kill_by_jid(jid) 

450 

451 async def __set_http(self) -> None: 

452 self.http = aiohttp.ClientSession() 

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

454 return 

455 avatar_cache.http = self.http 

456 

457 def __register_commands(self) -> None: 

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

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

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

461 continue 

462 if cls is Exec: 

463 if config.DEV_MODE: 

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

465 else: 

466 continue 

467 c = cls(self) 

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

469 self.__adhoc_handler.register(c) 

470 self.__chat_commands_handler.register(c) 

471 

472 def __exception_handler( 

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

474 ) -> None: 

475 """ 

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

477 

478 :param loop: 

479 :param context: 

480 :return: 

481 """ 

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

483 exc = context.get("exception") 

484 if exc is None: 

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

486 elif isinstance(exc, SystemExit): 

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

488 else: 

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

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

491 self.has_crashed = True 

492 loop.stop() 

493 

494 def __register_slixmpp_events(self) -> None: 

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

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

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

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

499 self.del_event_handler( 

500 "roster_subscription_request", self._handle_new_subscription 

501 ) 

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

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

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

505 self.__upload_service_found = asyncio.Event() 

506 

507 def __register_slixmpp_api(self) -> None: 

508 def with_session( 

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

510 ) -> Callable[P, T]: 

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

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

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

514 if commit: 

515 orm.commit() 

516 return res 

517 

518 return wrapped 

519 

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

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

522 ) 

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

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

525 ) 

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

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

528 ) 

529 

530 @property # type:ignore[override] 

531 def jid(self) -> JID: 

532 # Override to avoid slixmpp deprecation warnings. 

533 return self.boundjid 

534 

535 @jid.setter 

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

537 raise RuntimeError 

538 

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

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

541 

542 await self.__setup_attachments() 

543 

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

545 disco = self.plugin["xep_0030"] 

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

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

548 

549 if self.COMPONENT_AVATAR is not None: 

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

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

552 assert avatar is not None 

553 try: 

554 cached_avatar = await avatar_cache.convert_or_get(avatar) 

555 except Exception as e: 

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

557 cached_avatar = None 

558 else: 

559 assert cached_avatar is not None 

560 self.avatar = cached_avatar 

561 else: 

562 cached_avatar = None 

563 

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

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

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

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

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

569 # as last resort. 

570 try: 

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

572 await self.__add_component_to_mds_whitelist(user.jid) 

573 except (IqError, IqTimeout) as e: 

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

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

576 log.warning( 

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

578 user, 

579 exc_info=e, 

580 ) 

581 continue 

582 session = self._session_cls.from_user(user) 

583 session.create_task(self.login_wrap(session)) 

584 if cached_avatar is not None: 

585 await self.pubsub.broadcast_avatar( 

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

587 ) 

588 

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

590 

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

592 await self.__upload_service_found.wait() 

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

594 config.SERVER 

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

596 IqPermission.SET, 

597 IqPermission.BOTH, 

598 IqPermission.GET, 

599 ): 

600 AttachmentMixin.PRIVILEGED_UPLOAD = True 

601 

602 async def __setup_attachments(self) -> None: 

603 if config.NO_UPLOAD_PATH: 

604 if config.NO_UPLOAD_URL_PREFIX is None: 

605 raise RuntimeError( 

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

607 ) 

608 elif not config.UPLOAD_SERVICE: 

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

610 self.infer_real_domain() 

611 ) 

612 if info_iq is None: 

613 if self.REGISTRATION_TYPE == RegistrationType.QRCODE: 

614 log.warning( 

615 "No method was configured for attachment and slidge " 

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

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

618 "QR-code based registration flow." 

619 ) 

620 if not config.USE_ATTACHMENT_ORIGINAL_URLS: 

621 log.warning( 

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

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

624 "networks, especially for the E2EE attachments." 

625 ) 

626 config.USE_ATTACHMENT_ORIGINAL_URLS = True 

627 else: 

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

629 config.UPLOAD_SERVICE = info_iq["from"] 

630 self.__upload_service_found.set() 

631 

632 def infer_real_domain(self) -> JID: 

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

634 

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

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

637 # MDS node so we receive MDS events 

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

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

640 

641 try: 

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

643 except PermissionError: 

644 log.warning( 

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

646 "create the MDS node of %s", 

647 user_jid, 

648 ) 

649 except PrivilegedIqError as exc: 

650 nested = exc.nested_error() 

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

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

653 log.exception( 

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

655 ) 

656 except Exception as e: 

657 log.exception( 

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

659 user_jid, 

660 exc_info=e, 

661 ) 

662 

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

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

665 "xep_0490" 

666 ].stanza.NS 

667 

668 aff = OwnerAffiliation() 

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

670 aff["affiliation"] = "member" 

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

672 

673 try: 

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

675 except PermissionError: 

676 log.warning( 

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

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

679 user_jid, 

680 ) 

681 except Exception as e: 

682 log.exception( 

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

684 user_jid, 

685 exc_info=e, 

686 ) 

687 

688 async def login_wrap(self, session: AnySession) -> str: 

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

690 session.is_logging_in = True 

691 try: 

692 status = await session.login() 

693 except Exception as e: 

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

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

696 msg = ( 

697 "You are not connected to this gateway! " 

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

699 ) 

700 session.send_gateway_message(msg) 

701 session.logged = False 

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

703 return msg 

704 

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

706 session.logged = True 

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

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

709 await session.contacts._fill(orm) 

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

711 r.set_result(True) 

712 if self.GROUPS: 

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

714 await session.bookmarks.fill() 

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

716 r.set_result(True) 

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

718 if status is None: 

719 status = "Logged in" 

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

721 

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

723 session.create_task(self.fetch_user_avatar(session)) 

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 status 

730 

731 async def fetch_user_avatar(self, session: AnySession) -> None: 

732 try: 

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

734 session.user_jid.bare, 

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

736 ifrom=self.boundjid.bare, 

737 ) 

738 except IqTimeout: 

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

740 return 

741 except IqError as e: 

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

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

744 try: 

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

746 except NotImplementedError: 

747 pass 

748 else: 

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

750 session.user.avatar_hash = None 

751 orm.add(session.user) 

752 orm.commit() 

753 return 

754 await self.__dispatcher.on_avatar_metadata_info( 

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

756 ) 

757 

758 def _send( 

759 self, 

760 stanza: MessageOrPresenceTypeVar, 

761 **send_kwargs: Any, # noqa:ANN401 

762 ) -> MessageOrPresenceTypeVar: 

763 stanza.set_from(self.boundjid.bare) 

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

765 stanza.set_to(mto) 

766 stanza.send() 

767 return stanza 

768 

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

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

771 raise XMPPError( 

772 condition="not-allowed", 

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

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

775 ) 

776 

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

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

779 # to make them more readable. 

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

781 if isinstance(data, str): 

782 stripped = copy(data) 

783 else: 

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

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

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

787 # does not matter much 

788 for el in LOG_STRIP_ELEMENTS: 

789 stripped = re.sub( 

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

791 "\1[STRIPPED]\3", 

792 stripped, 

793 flags=re.DOTALL | re.IGNORECASE, 

794 ) 

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

796 if not self.transport: 

797 raise NotConnectedError() 

798 if isinstance(data, str): 

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

800 self.transport.write(data) 

801 

802 def get_session_from_jid(self, j: JID) -> AnySession | None: 

803 try: 

804 return self._session_cls.from_jid(j) 

805 except XMPPError: 

806 return None 

807 

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

809 # """ 

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

811 # 

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

813 # 

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

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

816 # 

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

818 # """ 

819 if isinstance(exception, IqError): 

820 iq = exception.iq 

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

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

823 elif isinstance(exception, IqTimeout): 

824 iq = exception.iq 

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

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

827 elif isinstance(exception, SyntaxError): 

828 # Hide stream parsing errors that occur when the 

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

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

831 pass 

832 else: 

833 if exception: 

834 log.exception(exception) 

835 self.loop.stop() 

836 exit(1) 

837 

838 async def make_registration_form( 

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

840 ) -> Iq: 

841 self.raise_if_not_allowed_jid(iq.get_from()) 

842 reg = iq["register"] 

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

844 user = ( 

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

846 ) 

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

848 

849 form = reg["form"] 

850 form.add_field( 

851 "FORM_TYPE", 

852 ftype="hidden", 

853 value="jabber:iq:register", 

854 ) 

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

856 form["instructions"] = self.REGISTRATION_INSTRUCTIONS 

857 

858 if user is not None: 

859 reg["registered"] = False 

860 form.add_field( 

861 "remove", 

862 label="Remove my registration", 

863 required=True, 

864 ftype="boolean", 

865 value=False, 

866 ) 

867 

868 for field in self.REGISTRATION_FIELDS: 

869 if field.var in reg.interfaces: 

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

871 if val is None: 

872 reg.add_field(field.var) 

873 else: 

874 reg[field.var] = val 

875 

876 reg["instructions"] = self.REGISTRATION_INSTRUCTIONS 

877 

878 for field in self.REGISTRATION_FIELDS: 

879 form.add_field( 

880 field.var, 

881 label=field.label, 

882 required=field.required, 

883 ftype=field.type, 

884 options=field.options, 

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

886 ) 

887 

888 reply = iq.reply() 

889 reply.set_payload(reg) 

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

891 

892 async def user_prevalidate( 

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

894 ) -> JSONSerializable | None: 

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

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

897 for field in self.REGISTRATION_FIELDS: 

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

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

900 

901 return await self.validate(ifrom, form_dict) 

902 

903 @abc.abstractmethod 

904 async def validate( 

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

906 ) -> JSONSerializable | None: 

907 """ 

908 Validate a user's initial registration form. 

909 

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

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

912 

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

914 :attr:`.RegistrationType.SINGLE_STEP_FORM`, 

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

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

917 

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

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

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

921 effectively a confirmation dialog displaying 

922 :attr:`.REGISTRATION_INSTRUCTIONS`. 

923 

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

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

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

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

928 of the 

929 

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

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

932 content will be stored. 

933 """ 

934 raise NotImplementedError 

935 

936 async def validate_two_factor_code( 

937 self, user: GatewayUser, code: str 

938 ) -> JSONSerializable | None: 

939 """ 

940 Called when the user enters their 2FA code. 

941 

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

943 if the login fails, and return successfully otherwise. 

944 

945 Only used when :attr:`REGISTRATION_TYPE` is 

946 :attr:`.RegistrationType.TWO_FACTOR_CODE`. 

947 

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

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

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

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

952 adhoc command 

953 

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

955 for this user. 

956 """ 

957 raise NotImplementedError 

958 

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

960 """ 

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

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

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

964 

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

966 :attr:`.RegistrationType.QRCODE`. 

967 

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

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

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

971 """ 

972 raise NotImplementedError 

973 

974 async def confirm_qr( 

975 self, 

976 user_bare_jid: str, 

977 exception: Exception | None = None, 

978 legacy_data: JSONSerializable | None = None, 

979 ) -> None: 

980 """ 

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

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

983 

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

985 :attr:`.RegistrationType.QRCODE`. 

986 

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

988 :class:`GatewayUser` instance 

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

990 QR code flashing. 

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

992 "legacy_module_data" for this user. 

993 """ 

994 fut = self.qr_pending_registrations[user_bare_jid] 

995 if exception is None: 

996 fut.set_result(legacy_data) 

997 else: 

998 fut.set_exception(exception) 

999 

1000 async def unregister_user( 

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

1002 ) -> None: 

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

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

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

1006 

1007 async def unregister(self, session: AnySession) -> None: 

1008 """ 

1009 Optionally override this if you need to clean additional 

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

1011 

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

1013 

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

1015 """ 

1016 try: 

1017 await session.logout() 

1018 except NotImplementedError: 

1019 pass 

1020 

1021 async def input( 

1022 self, 

1023 jid: JID, 

1024 text: str | None = None, 

1025 mtype: MessageTypes = "chat", 

1026 **input_kwargs: Any, # noqa:ANN401 

1027 ) -> str: 

1028 """ 

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

1030 

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

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

1033 

1034 :param jid: The JID we want input from 

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

1036 :param mtype: Message type 

1037 :return: The user's reply 

1038 """ 

1039 return await self.__chat_commands_handler.input( 

1040 jid, text, mtype=mtype, **input_kwargs 

1041 ) 

1042 

1043 async def send_qr( 

1044 self, 

1045 text: str, 

1046 **msg_kwargs: Any, # noqa:ANN401 

1047 ) -> None: 

1048 """ 

1049 Sends a QR Code to a JID 

1050 

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

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

1053 

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

1055 :param msg_kwargs: Optional additional arguments to pass to 

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

1057 code 

1058 """ 

1059 qr = qrcode.make(text) 

1060 with tempfile.NamedTemporaryFile( 

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

1062 ) as f: 

1063 qr.save(f.name) 

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

1065 

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

1067 # """ 

1068 # Called by the slidge entrypoint on normal exit. 

1069 # 

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

1071 # the gateway component itself. 

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

1073 # """ 

1074 log.debug("Shutting down") 

1075 tasks = [] 

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

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

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

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

1080 return tasks 

1081 

1082 

1083SLIXMPP_PLUGINS = [ 

1084 "xep_0030", # Service discovery 

1085 "xep_0045", # Multi-User Chat 

1086 "xep_0050", # Adhoc commands 

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

1088 "xep_0055", # Jabber search 

1089 "xep_0059", # Result Set Management 

1090 "xep_0066", # Out of Band Data 

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

1092 "xep_0077", # In-band registration 

1093 "xep_0084", # User Avatar 

1094 "xep_0085", # Chat state notifications 

1095 "xep_0100", # Gateway interaction 

1096 "xep_0106", # JID Escaping 

1097 "xep_0115", # Entity capabilities 

1098 "xep_0122", # Data Forms Validation 

1099 "xep_0128", # Service Discovery Extensions 

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

1101 "xep_0172", # User nickname 

1102 "xep_0184", # Message Delivery Receipts 

1103 "xep_0199", # XMPP Ping 

1104 "xep_0221", # Data Forms Media Element 

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

1106 "xep_0249", # Direct MUC Invitations 

1107 "xep_0264", # Jingle Content Thumbnails 

1108 "xep_0280", # Carbons 

1109 "xep_0292_provider", # VCard4 

1110 "xep_0308", # Last message correction 

1111 "xep_0313", # Message Archive Management 

1112 "xep_0317", # Hats 

1113 "xep_0319", # Last User Interaction in Presence 

1114 "xep_0333", # Chat markers 

1115 "xep_0334", # Message Processing Hints 

1116 "xep_0356", # Privileged Entity 

1117 "xep_0363", # HTTP file upload 

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

1119 "xep_0402", # PEP Native Bookmarks 

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

1121 "xep_0424", # Message retraction 

1122 "xep_0425", # Message moderation 

1123 "xep_0444", # Message reactions 

1124 "xep_0447", # Stateless File Sharing 

1125 "xep_0461", # Message replies 

1126 "xep_0469", # Bookmark Pinning 

1127 "xep_0490", # Message Displayed Synchronization 

1128 "xep_0492", # Chat Notification Settings 

1129 "xep_0511", # Link Metadata 

1130] 

1131 

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

1133 

1134log = logging.getLogger(__name__)