Coverage for slidge/command/user.py: 50%

243 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-28 18:29 +0000

1# Commands available to users 

2import contextlib 

3from copy import deepcopy 

4from typing import Any, cast 

5 

6from slixmpp import JID 

7from slixmpp.exceptions import XMPPError 

8 

9from slidge.db.meta import JSONSerializable 

10from slidge.db.models import Space 

11 

12from ..util.types import ( 

13 AnyMUC, 

14 AnySession, 

15 MucType, 

16 UserPreferences, 

17) 

18from .base import ( 

19 Command, 

20 CommandAccess, 

21 Confirmation, 

22 ConfirmationSession, 

23 Form, 

24 FormField, 

25 FormSession, 

26 FormValues, 

27 SearchResult, 

28 TableResult, 

29) 

30from .categories import CONTACTS, GROUPS, SPACES 

31 

32 

33class Search(Command[AnySession]): 

34 NAME = "🔎 Search for contacts" 

35 HELP = "Search for contacts via this gateway" 

36 CHAT_COMMAND = "find" 

37 NODE = CONTACTS.node + "/" + CHAT_COMMAND 

38 ACCESS = CommandAccess.USER_LOGGED 

39 CATEGORY = CONTACTS 

40 

41 async def run( 

42 self, session: "AnySession | None", _ifrom: JID, *args: str 

43 ) -> FormSession[AnySession] | SearchResult | None: 

44 assert session is not None 

45 await session.ready 

46 if args: 

47 return await session.on_search( 

48 {self.xmpp.SEARCH_FIELDS[0].var: " ".join(args)} 

49 ) 

50 return FormSession[AnySession]( 

51 title=self.xmpp.SEARCH_TITLE, 

52 instructions=self.xmpp.SEARCH_INSTRUCTIONS, 

53 fields=self.xmpp.SEARCH_FIELDS, 

54 handler=self.search, 

55 ) 

56 

57 @staticmethod 

58 async def search( 

59 form_values: FormValues, session: "AnySession | None", _ifrom: JID 

60 ) -> SearchResult: 

61 assert session is not None 

62 results = await session.on_search(form_values) # type: ignore 

63 if results is None: 

64 raise XMPPError("item-not-found", "No contact was found") 

65 

66 return results 

67 

68 

69class SyncContacts(Command[AnySession]): 

70 NAME = "🔄 Sync XMPP roster" 

71 HELP = ( 

72 "Synchronize your XMPP roster with your legacy contacts. " 

73 "Slidge will only add/remove/modify contacts in its dedicated roster group" 

74 ) 

75 CHAT_COMMAND = "sync-contacts" 

76 NODE = CONTACTS.node + "/" + CHAT_COMMAND 

77 ACCESS = CommandAccess.USER_LOGGED 

78 CATEGORY = CONTACTS 

79 

80 async def run( 

81 self, 

82 session: "AnySession | None", 

83 _ifrom: JID, 

84 *args: str, 

85 ) -> ConfirmationSession[AnySession]: 

86 assert session is not None 

87 await session.ready 

88 return ConfirmationSession[AnySession]( 

89 prompt="Are you sure you want to sync your roster?", 

90 success=None, 

91 handler=self.sync, 

92 ) 

93 

94 async def sync(self, session: "AnySession | None", _ifrom: JID) -> str: 

95 if session is None: 

96 raise RuntimeError 

97 roster_iq = await self.xmpp["xep_0356"].get_roster(session.user_jid.bare) 

98 

99 contacts = session.contacts.known_contacts() 

100 

101 added = 0 

102 removed = 0 

103 updated = 0 

104 for item in roster_iq["roster"]: 

105 groups = set(item["groups"]) 

106 if self.xmpp.ROSTER_GROUP in groups: 

107 contact = contacts.pop(item["jid"], None) 

108 if contact is None: 

109 if len(groups) == 1: 

110 await self.xmpp["xep_0356"].set_roster( 

111 session.user_jid, {item["jid"]: {"subscription": "remove"}} 

112 ) 

113 removed += 1 

114 else: 

115 groups.remove(self.xmpp.ROSTER_GROUP) 

116 await self.xmpp["xep_0356"].set_roster( 

117 session.user_jid, 

118 { 

119 item["jid"]: { 

120 "subscription": item["subscription"], 

121 "name": item["name"], 

122 "groups": groups, 

123 } 

124 }, 

125 ) 

126 updated += 1 

127 else: 

128 if contact.name != item["name"]: 

129 await contact.add_to_roster(force=True) 

130 updated += 1 

131 

132 # we popped before so this only acts on slidge contacts not in the xmpp roster 

133 for contact in contacts.values(): 

134 added += 1 

135 await contact.add_to_roster() 

136 

137 return f"{added} added, {removed} removed, {updated} updated" 

138 

139 

140class ListContacts(Command[AnySession]): 

141 NAME = HELP = "👤 List your legacy contacts" 

142 CHAT_COMMAND = "contacts" 

143 NODE = CONTACTS.node + "/" + CHAT_COMMAND 

144 ACCESS = CommandAccess.USER_LOGGED 

145 CATEGORY = CONTACTS 

146 

147 async def run( 

148 self, 

149 session: "AnySession | None", 

150 _ifrom: JID, 

151 *_: str, 

152 ) -> TableResult: 

153 assert session is not None 

154 await session.ready 

155 contacts = sorted( 

156 session.contacts, key=lambda c: c.name.casefold() if c.name else "" 

157 ) 

158 return TableResult( 

159 description="Your buddies", 

160 fields=[FormField("name"), FormField("jid", type="jid-single")], 

161 items=[{"name": c.name, "jid": c.jid.bare} for c in contacts], 

162 ) 

163 

164 

165class ListGroups(Command[AnySession]): 

166 NAME = HELP = "👥 List your legacy groups" 

167 CHAT_COMMAND = "groups" 

168 NODE = GROUPS.node + "/" + CHAT_COMMAND 

169 ACCESS = CommandAccess.USER_LOGGED 

170 CATEGORY = GROUPS 

171 

172 async def run( 

173 self, session: "AnySession | None", _ifrom: JID, *_: str 

174 ) -> TableResult: 

175 assert session is not None 

176 await session.ready 

177 groups: list[AnyMUC] = sorted( 

178 session.bookmarks, key=lambda g: (g.name or g.jid.node).casefold() 

179 ) 

180 return TableResult( 

181 description="Your groups", 

182 fields=[FormField("name"), FormField("jid", type="jid-single")], 

183 items=[ 

184 {"name": g.name or str(g.legacy_id), "jid": g.jid.bare} for g in groups 

185 ], 

186 jids_are_mucs=True, 

187 ) 

188 

189 

190class ListSpaces(Command[AnySession]): 

191 NAME = "🌐 List my spaces" 

192 HELP = "List the spaces you are part of. Spaces are collections of rooms." 

193 CHAT_COMMAND = "spaces" 

194 NODE = GROUPS.node + "/" + CHAT_COMMAND 

195 ACCESS = CommandAccess.USER_LOGGED 

196 CATEGORY = SPACES 

197 

198 related_to_spaces = True 

199 

200 async def run( 

201 self, session: "AnySession | None", _ifrom: JID, *_: str 

202 ) -> TableResult: 

203 assert session is not None 

204 spaces = await _get_updated_spaces(session) 

205 return TableResult( 

206 description="Your spaces. If your client does not support spaces, use the 'space-rooms' command.", 

207 fields=[FormField("name"), FormField("iri")], 

208 items=[ 

209 { 

210 "name": s.name or str(s.legacy_id), 

211 "iri": f"xmpp:{self.xmpp.boundjid.bare}?;node={await session.bookmarks.space_legacy_id_to_node(s.legacy_id)}", 

212 } 

213 for s in spaces 

214 ], 

215 jids_are_mucs=True, 

216 ) 

217 

218 

219class ListRoomsInSpace(Command[AnySession]): 

220 NAME = "🌐 List the rooms in a space" 

221 HELP = "List the rooms of a space you are part of. Spaces are collections of rooms." 

222 CHAT_COMMAND = "space-rooms" 

223 NODE = GROUPS.node + "/" + CHAT_COMMAND 

224 ACCESS = CommandAccess.USER_LOGGED 

225 CATEGORY = SPACES 

226 

227 async def run( 

228 self, session: "AnySession | None", _ifrom: JID, *_: str 

229 ) -> "FormSession[AnySession]": 

230 assert session is not None 

231 spaces = await _get_updated_spaces(session) 

232 return FormSession( 

233 title="Your spaces", 

234 instructions="Select a space to view its rooms", 

235 fields=[ 

236 FormField( 

237 var="space_legacy_id", 

238 label="Space", 

239 required=True, 

240 type="list-single", 

241 options=[ 

242 { 

243 "label": s.name or str(s.legacy_id), 

244 "value": str(s.legacy_id), 

245 } 

246 for s in spaces 

247 ], 

248 ) 

249 ], 

250 handler=self.list_rooms, 

251 handler_args=({str(s.legacy_id): s.name for s in spaces},), 

252 ) 

253 

254 async def list_rooms( 

255 self, 

256 form_values: FormValues, 

257 session: "AnySession | None", 

258 _ifrom: JID, 

259 space_names: dict[str, str], 

260 ) -> TableResult: 

261 assert session is not None 

262 space_legacy_id = form_values.get("space_legacy_id") 

263 if space_legacy_id is None: 

264 raise XMPPError("bad-request", "You need to specify a space") 

265 assert isinstance(space_legacy_id, str) 

266 await session.ready 

267 with self.xmpp.store.session() as orm: 

268 rooms = sorted( 

269 self.xmpp.store.spaces.get_rooms( 

270 orm, 

271 session.user_pk, 

272 await session.bookmarks.space_legacy_id_to_node(space_legacy_id), 

273 ), 

274 key=lambda r: (r.name or str(r.jid.node)).casefold(), 

275 ) 

276 name = space_names.get(space_legacy_id) 

277 return TableResult( 

278 fields=[ 

279 FormField("name", "Name"), 

280 FormField("jid", "JID", type="jid-single"), 

281 ], 

282 description=f"Rooms of '{name or space_legacy_id}'", 

283 items=[{"name": r.name or str(r.legacy_id), "jid": r.jid} for r in rooms], 

284 jids_are_mucs=True, 

285 ) 

286 

287 

288class Login(Command[AnySession]): 

289 NAME = "🔐 Re-login to the legacy network" 

290 HELP = "Login to the legacy service" 

291 CHAT_COMMAND = "re-login" 

292 NODE = "https://slidge.im/command/core/" + CHAT_COMMAND 

293 

294 ACCESS = CommandAccess.USER_NON_LOGGED 

295 

296 async def run( 

297 self, 

298 session: "AnySession | None", 

299 _ifrom: JID, 

300 *_: str, 

301 ) -> str: 

302 assert session is not None 

303 if session.is_logging_in: 

304 raise XMPPError("bad-request", "You are already logging in.") 

305 session.is_logging_in = True 

306 try: 

307 msg = await self.xmpp.login_wrap(session) 

308 except Exception as e: # noqa: BLE001 

309 session.send_gateway_status(f"Re-login failed: {e}", show="dnd") 

310 raise XMPPError( 

311 "internal-server-error", etype="wait", text=f"Could not login: {e}" 

312 ) 

313 finally: 

314 session.is_logging_in = False 

315 session.logged = True 

316 session.send_gateway_status(msg or "Re-connected", show="chat") 

317 session.send_gateway_message(msg or "Re-connected") 

318 return msg 

319 

320 

321class CreateGroup(Command[AnySession]): 

322 NAME = "🆕 New legacy group" 

323 HELP = "Create a group on the legacy service" 

324 CHAT_COMMAND = "create-group" 

325 NODE = GROUPS.node + "/" + CHAT_COMMAND 

326 CATEGORY = GROUPS 

327 

328 ACCESS = CommandAccess.USER_LOGGED 

329 

330 async def run( 

331 self, 

332 session: "AnySession | None", 

333 _ifrom: JID, 

334 *_: str, 

335 ) -> FormSession[AnySession]: 

336 assert session is not None 

337 await session.ready 

338 contacts = session.contacts.known_contacts(only_friends=True) 

339 return FormSession( 

340 title="Create a new group", 

341 instructions="Pick contacts that should be part of this new group", 

342 fields=[ 

343 FormField(var="group_name", label="Name of the group", required=True), 

344 FormField( 

345 var="contacts", 

346 label="Contacts to add to the new group", 

347 type="list-multi", 

348 options=[ 

349 {"value": str(contact.jid), "label": contact.name} 

350 for contact in sorted(contacts.values(), key=lambda c: c.name) 

351 ], 

352 required=False, 

353 ), 

354 ], 

355 handler=self.finish, 

356 ) 

357 

358 @staticmethod 

359 async def finish( 

360 form_values: FormValues, 

361 session: "AnySession | None", 

362 *_: Any, # noqa:ANN401 

363 ) -> TableResult: 

364 assert session is not None 

365 legacy_id: str = await session.on_create_group( 

366 cast(str, form_values["group_name"]), 

367 [ 

368 await session.contacts.by_jid(JID(j)) 

369 for j in form_values.get("contacts", []) # type:ignore 

370 ], 

371 ) 

372 muc = await session.bookmarks.by_legacy_id(legacy_id) 

373 return TableResult( 

374 description=f"Your new group: xmpp:{muc.jid}?join", 

375 fields=[FormField("name"), FormField("jid", type="jid-single")], 

376 items=[{"name": muc.name, "jid": muc.jid}], 

377 jids_are_mucs=True, 

378 ) 

379 

380 

381class Preferences(Command[AnySession]): 

382 NAME = "⚙️ Preferences" 

383 HELP = "Customize the gateway behaviour to your liking" 

384 CHAT_COMMAND = "preferences" 

385 NODE = "https://slidge.im/command/core/preferences" 

386 ACCESS = CommandAccess.USER 

387 

388 async def run( 

389 self, 

390 session: "AnySession | None", 

391 _ifrom: JID, 

392 *_: str, 

393 ) -> FormSession[AnySession]: 

394 fields = deepcopy(self.xmpp.PREFERENCES) 

395 assert session is not None 

396 current = session.user.preferences 

397 for field in fields: 

398 field.value = current.get(field.var, field.value) # type:ignore 

399 return Form( 

400 title="Preferences", 

401 instructions=self.HELP, 

402 fields=fields, 

403 handler=self.finish, # type:ignore 

404 handler_kwargs={"previous": current}, 

405 ) 

406 

407 async def finish( 

408 self, 

409 form_values: UserPreferences, 

410 session: "AnySession | None", 

411 *_: Any, # noqa:ANN401 

412 previous: JSONSerializable, 

413 ) -> str: 

414 assert session is not None 

415 if previous == form_values: 

416 return "No preference was changed" 

417 

418 previous = previous.copy() 

419 user = session.user 

420 user.preferences.update(form_values) # type:ignore 

421 self.xmpp.store.users.update(user) 

422 

423 with contextlib.suppress(NotImplementedError): 

424 await session.on_preferences(previous, form_values) # type:ignore[arg-type] 

425 

426 if not previous["sync_avatar"] and form_values["sync_avatar"]: 

427 await self.xmpp.fetch_user_avatar(session) 

428 else: 

429 user.avatar_hash = None 

430 

431 return "Your preferences have been updated." 

432 

433 

434class Unregister(Command[AnySession]): 

435 NAME = "❌ Unregister from the gateway" 

436 HELP = "Unregister from the gateway" 

437 CHAT_COMMAND = "unregister" 

438 NODE = "https://slidge.im/command/core/unregister" 

439 ACCESS = CommandAccess.USER 

440 

441 async def run( 

442 self, session: "AnySession | None", _ifrom: JID, *_: str 

443 ) -> ConfirmationSession[AnySession]: 

444 return ConfirmationSession[AnySession]( 

445 prompt=f"Are you sure you want to unregister from '{self.xmpp.boundjid}'?", 

446 success=f"You are not registered to '{self.xmpp.boundjid}' anymore.", 

447 handler=self.unregister, 

448 ) 

449 

450 async def unregister(self, session: "AnySession | None", _ifrom: JID) -> str: 

451 assert session is not None 

452 await self.xmpp.unregister_user(session.user) 

453 return "You are not registered anymore. Bye!" 

454 

455 

456class LeaveGroup(Command[AnySession]): 

457 NAME = HELP = "❌ Leave a legacy group" 

458 CHAT_COMMAND = "leave-group" 

459 NODE = GROUPS.node + "/" + CHAT_COMMAND 

460 ACCESS = CommandAccess.USER_LOGGED 

461 CATEGORY = GROUPS 

462 

463 async def run( 

464 self, 

465 session: "AnySession | None", 

466 ifrom: JID, 

467 *_: str, 

468 ) -> FormSession[AnySession]: 

469 assert session is not None 

470 await session.ready 

471 groups = sorted(session.bookmarks, key=lambda g: g.DISCO_NAME.casefold()) 

472 return FormSession( 

473 title="Leave a group", 

474 instructions="Select the group you want to leave", 

475 fields=[ 

476 FormField( 

477 "group", 

478 "Group name", 

479 type="list-single", 

480 options=[ 

481 {"label": g.name or str(g.legacy_id), "value": str(i)} 

482 for i, g in enumerate(groups) 

483 ], 

484 ) 

485 ], 

486 handler=self.confirm, 

487 handler_args=(groups,), 

488 ) 

489 

490 async def confirm( 

491 self, 

492 form_values: FormValues, 

493 _session: "AnySession | None", 

494 _ifrom: JID, 

495 groups: list[AnyMUC], 

496 ) -> Confirmation: 

497 group = groups[int(form_values["group"])] # type:ignore 

498 return Confirmation( 

499 prompt=f"Are you sure you want to leave the group '{group.name}'?", 

500 handler=self.finish, 

501 handler_args=(group,), 

502 ) 

503 

504 @staticmethod 

505 async def finish(session: AnySession, _ifrom: JID, group: AnyMUC) -> None: 

506 await group.on_leave() 

507 await session.bookmarks.remove(group, reason="You left this group via slidge.") 

508 

509 

510class InviteInGroups(Command[AnySession]): 

511 NAME = "💌 Re-invite me in my groups" 

512 HELP = "Ask the gateway to send invitations for all your private groups" 

513 CHAT_COMMAND = "re-invite" 

514 NODE = GROUPS.node + "/" + CHAT_COMMAND 

515 ACCESS = CommandAccess.USER_LOGGED 

516 CATEGORY = GROUPS 

517 

518 async def run(self, session: "AnySession | None", _ifrom: JID, *_: str) -> None: 

519 assert session is not None 

520 await session.ready 

521 for muc in session.bookmarks: 

522 if muc.type == MucType.GROUP: 

523 session.send_gateway_invite( 

524 muc, reason="You asked to be re-invited in all groups." 

525 ) 

526 

527 

528async def _get_updated_spaces(session: AnySession) -> list[Space]: 

529 await session.ready 

530 with session.xmpp.store.session() as orm: 

531 spaces = list(session.xmpp.store.spaces.get_all(orm, session.user_pk)) 

532 updated_spaces: list[Space] = [] 

533 for space in spaces: 

534 try: 

535 updated_spaces.append(await session.bookmarks.update_space_if_needed(space)) 

536 except Exception: 

537 session.log.exception( 

538 "Something went wrong trying to update space '%r'", space 

539 ) 

540 

541 return sorted(updated_spaces, key=lambda s: s.name or str(s.legacy_id))