Coverage for slidge/group/room.py: 89%

709 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2025-11-26 19:34 +0000

1import json 

2import logging 

3import re 

4import string 

5import warnings 

6from asyncio import Lock 

7from contextlib import asynccontextmanager 

8from copy import copy 

9from datetime import datetime, timedelta, timezone 

10from typing import ( 

11 TYPE_CHECKING, 

12 AsyncIterator, 

13 Generic, 

14 Literal, 

15 Optional, 

16 Type, 

17 Union, 

18 overload, 

19) 

20from uuid import uuid4 

21 

22import sqlalchemy as sa 

23from slixmpp import JID, Iq, Message, Presence 

24from slixmpp.exceptions import IqError, IqTimeout, XMPPError 

25from slixmpp.plugins.xep_0004 import Form 

26from slixmpp.plugins.xep_0060.stanza import Item 

27from slixmpp.plugins.xep_0082 import parse as str_to_datetime 

28from slixmpp.plugins.xep_0469.stanza import NS as PINNING_NS 

29from slixmpp.plugins.xep_0492.stanza import NS as NOTIFY_NS 

30from slixmpp.plugins.xep_0492.stanza import WhenLiteral 

31from slixmpp.xmlstream import ET 

32from sqlalchemy.orm import Session as OrmSession 

33 

34from ..contact.contact import LegacyContact 

35from ..contact.roster import ContactIsUser 

36from ..core.mixins.avatar import AvatarMixin 

37from ..core.mixins.disco import ChatterDiscoMixin 

38from ..core.mixins.recipient import ReactionRecipientMixin, ThreadRecipientMixin 

39from ..db.models import Participant, Room 

40from ..util.jid_escaping import unescape_node 

41from ..util.types import ( 

42 HoleBound, 

43 LegacyGroupIdType, 

44 LegacyMessageType, 

45 LegacyParticipantType, 

46 LegacyThreadType, 

47 LegacyUserIdType, 

48 Mention, 

49 MucAffiliation, 

50 MucType, 

51) 

52from ..util.util import SubclassableOnce, deprecated, timeit 

53from .archive import MessageArchive 

54from .participant import LegacyParticipant, escape_nickname 

55 

56if TYPE_CHECKING: 

57 from ..core.session import BaseSession 

58 

59ADMIN_NS = "http://jabber.org/protocol/muc#admin" 

60 

61SubjectSetterType = Union[str, None, "LegacyContact", "LegacyParticipant"] 

62 

63 

64class LegacyMUC( 

65 Generic[ 

66 LegacyGroupIdType, LegacyMessageType, LegacyParticipantType, LegacyUserIdType 

67 ], 

68 AvatarMixin, 

69 ChatterDiscoMixin, 

70 ReactionRecipientMixin, 

71 ThreadRecipientMixin, 

72 metaclass=SubclassableOnce, 

73): 

74 """ 

75 A room, a.k.a. a Multi-User Chat. 

76 

77 MUC instances are obtained by calling :py:meth:`slidge.group.bookmarks.LegacyBookmarks` 

78 on the user's :py:class:`slidge.core.session.BaseSession`. 

79 """ 

80 

81 max_history_fetch = 100 

82 

83 is_group = True 

84 

85 DISCO_TYPE = "text" 

86 DISCO_CATEGORY = "conference" 

87 

88 STABLE_ARCHIVE = False 

89 """ 

90 Because legacy events like reactions, editions, etc. don't all map to a stanza 

91 with a proper legacy ID, slidge usually cannot guarantee the stability of the archive 

92 across restarts. 

93 

94 Set this to True if you know what you're doing, but realistically, this can't 

95 be set to True until archive is permanently stored on disk by slidge. 

96 

97 This is just a flag on archive responses that most clients ignore anyway. 

98 """ 

99 

100 _ALL_INFO_FILLED_ON_STARTUP = False 

101 """ 

102 Set this to true if the fill_participants() / fill_participants() design does not 

103 fit the legacy API, ie, no lazy loading of the participant list and history. 

104 """ 

105 

106 HAS_DESCRIPTION = True 

107 """ 

108 Set this to false if the legacy network does not allow setting a description 

109 for the group. In this case the description field will not be present in the 

110 room configuration form. 

111 """ 

112 

113 HAS_SUBJECT = True 

114 """ 

115 Set this to false if the legacy network does not allow setting a subject 

116 (sometimes also called topic) for the group. In this case, as a subject is 

117 recommended by :xep:`0045` ("SHALL"), the description (or the group name as 

118 ultimate fallback) will be used as the room subject. 

119 By setting this to false, an error will be returned when the :term:`User` 

120 tries to set the room subject. 

121 """ 

122 

123 archive: MessageArchive 

124 session: "BaseSession" 

125 

126 stored: Room 

127 

128 _participant_cls: Type[LegacyParticipantType] 

129 

130 def __init__(self, session: "BaseSession", stored: Room) -> None: 

131 self.session = session 

132 self.xmpp = session.xmpp 

133 self.stored = stored 

134 self._set_logger() 

135 super().__init__() 

136 

137 self.archive = MessageArchive(stored, self.xmpp.store.mam) 

138 

139 def participant_from_store( 

140 self, stored: Participant, contact: LegacyContact | None = None 

141 ) -> LegacyParticipantType: 

142 if contact is None and stored.contact is not None: 

143 contact = self.session.contacts.from_store(stored.contact) 

144 return self._participant_cls(self, stored=stored, contact=contact) 

145 

146 @property 

147 def jid(self) -> JID: 

148 return self.stored.jid 

149 

150 @jid.setter 

151 def jid(self, x: JID): 

152 # FIXME: without this, mypy yields 

153 # "Cannot override writeable attribute with read-only property" 

154 # But it does not happen for LegacyContact. WTF? 

155 raise RuntimeError 

156 

157 @property 

158 def legacy_id(self): 

159 return self.xmpp.LEGACY_ROOM_ID_TYPE(self.stored.legacy_id) 

160 

161 def orm(self) -> OrmSession: 

162 return self.xmpp.store.session() 

163 

164 @property 

165 def type(self) -> MucType: 

166 return self.stored.muc_type 

167 

168 @type.setter 

169 def type(self, type_: MucType) -> None: 

170 if self.type == type_: 

171 return 

172 self.update_stored_attribute(muc_type=type_) 

173 

174 @property 

175 def n_participants(self): 

176 return self.stored.n_participants 

177 

178 @n_participants.setter 

179 def n_participants(self, n_participants: Optional[int]) -> None: 

180 if self.stored.n_participants == n_participants: 

181 return 

182 self.update_stored_attribute(n_participants=n_participants) 

183 

184 @property 

185 def user_jid(self): 

186 return self.session.user_jid 

187 

188 def _set_logger(self) -> None: 

189 self.log = logging.getLogger(f"{self.user_jid}:muc:{self}") 

190 

191 def __repr__(self) -> str: 

192 return f"<MUC #{self.stored.id} '{self.name}' ({self.stored.legacy_id} - {self.jid.user})'>" 

193 

194 @property 

195 def subject_date(self) -> Optional[datetime]: 

196 if self.stored.subject_date is None: 

197 return None 

198 return self.stored.subject_date.replace(tzinfo=timezone.utc) 

199 

200 @subject_date.setter 

201 def subject_date(self, when: Optional[datetime]) -> None: 

202 if self.subject_date == when: 

203 return 

204 self.update_stored_attribute(subject_date=when) 

205 

206 def __send_configuration_change(self, codes) -> None: 

207 part = self.get_system_participant() 

208 part.send_configuration_change(codes) 

209 

210 @property 

211 def user_nick(self): 

212 return ( 

213 self.stored.user_nick 

214 or self.session.bookmarks.user_nick 

215 or self.user_jid.node 

216 ) 

217 

218 @user_nick.setter 

219 def user_nick(self, nick: str) -> None: 

220 if nick == self.user_nick: 

221 return 

222 self.update_stored_attribute(user_nick=nick) 

223 

224 def add_user_resource(self, resource: str) -> None: 

225 stored_set = self.get_user_resources() 

226 if resource in stored_set: 

227 return 

228 stored_set.add(resource) 

229 self.update_stored_attribute( 

230 user_resources=(json.dumps(list(stored_set)) if stored_set else None) 

231 ) 

232 

233 def get_user_resources(self) -> set[str]: 

234 stored_str = self.stored.user_resources 

235 if stored_str is None: 

236 return set() 

237 return set(json.loads(stored_str)) 

238 

239 def remove_user_resource(self, resource: str) -> None: 

240 stored_set = self.get_user_resources() 

241 if resource not in stored_set: 

242 return 

243 stored_set.remove(resource) 

244 self.update_stored_attribute( 

245 user_resources=(json.dumps(list(stored_set)) if stored_set else None) 

246 ) 

247 

248 @asynccontextmanager 

249 async def lock(self, id_: str) -> AsyncIterator[None]: 

250 async with self.session.lock((self.legacy_id, id_)): 

251 yield 

252 

253 def get_lock(self, id_: str) -> Lock | None: 

254 return self.session.get_lock((self.legacy_id, id_)) 

255 

256 async def __fill_participants(self) -> None: 

257 async with self.lock("fill participants"): 

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

259 orm.add(self.stored) 

260 with orm.no_autoflush: 

261 orm.refresh(self.stored, ["participants_filled"]) 

262 if self.participants_filled: 

263 return 

264 parts: list[Participant] = [] 

265 resources = set[str]() 

266 # During fill_participants(), self.get_participant*() methods may 

267 # return a participant with a conflicting nick/resource. 

268 async for participant in self.fill_participants(): 

269 if participant.stored.resource in resources: 

270 self.log.warning( 

271 "Participant '%s' was yielded more than once by fill_participants()", 

272 participant.stored.resource, 

273 ) 

274 continue 

275 parts.append(participant.stored) 

276 resources.add(participant.stored.resource) 

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

278 orm.add(self.stored) 

279 # because self.fill_participants() is async, self.stored may be stale at 

280 # this point, and the only thing we want to update is the participant list 

281 # and the participant_filled attribute. 

282 with orm.no_autoflush: 

283 orm.refresh(self.stored) 

284 for part in parts: 

285 orm.merge(part) 

286 self.stored.participants_filled = True 

287 orm.commit() 

288 

289 async def get_participants( 

290 self, affiliation: Optional[MucAffiliation] = None 

291 ) -> AsyncIterator[LegacyParticipantType]: 

292 await self.__fill_participants() 

293 with self.xmpp.store.session(expire_on_commit=False, autoflush=False) as orm: 

294 orm.add(self.stored) 

295 for db_participant in self.stored.participants: 

296 if ( 

297 affiliation is not None 

298 and db_participant.affiliation != affiliation 

299 ): 

300 continue 

301 yield self.participant_from_store(db_participant) 

302 

303 async def __fill_history(self) -> None: 

304 async with self.lock("fill history"): 

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

306 orm.add(self.stored) 

307 with orm.no_autoflush: 

308 orm.refresh(self.stored, ["history_filled"]) 

309 if self.stored.history_filled: 

310 self.log.debug("History has already been fetched.") 

311 return 

312 log.debug("Fetching history for %s", self) 

313 try: 

314 before, after = self.archive.get_hole_bounds() 

315 if before is not None: 

316 before = before._replace( 

317 id=self.xmpp.LEGACY_MSG_ID_TYPE(before.id) # type:ignore 

318 ) 

319 if after is not None: 

320 after = after._replace( 

321 id=self.xmpp.LEGACY_MSG_ID_TYPE(after.id) # type:ignore 

322 ) 

323 await self.backfill(before, after) 

324 except NotImplementedError: 

325 return 

326 except Exception as e: 

327 self.log.exception("Could not backfill", exc_info=e) 

328 

329 self.stored.history_filled = True 

330 self.commit(merge=True) 

331 

332 def _get_disco_name(self) -> str | None: 

333 return self.name 

334 

335 @property 

336 def name(self) -> str | None: 

337 return self.stored.name 

338 

339 @name.setter 

340 def name(self, n: str | None) -> None: 

341 if self.name == n: 

342 return 

343 self.update_stored_attribute(name=n) 

344 self._set_logger() 

345 self.__send_configuration_change((104,)) 

346 

347 @property 

348 def description(self): 

349 return self.stored.description or "" 

350 

351 @description.setter 

352 def description(self, d: str) -> None: 

353 if self.description == d: 

354 return 

355 self.update_stored_attribute(description=d) 

356 self.__send_configuration_change((104,)) 

357 

358 def on_presence_unavailable(self, p: Presence) -> None: 

359 pto = p.get_to() 

360 if pto.bare != self.jid.bare: 

361 return 

362 

363 pfrom = p.get_from() 

364 if pfrom.bare != self.user_jid.bare: 

365 return 

366 if (resource := pfrom.resource) in self.get_user_resources(): 

367 if pto.resource != self.user_nick: 

368 self.log.debug( 

369 "Received 'leave group' request but with wrong nickname. %s", p 

370 ) 

371 self.remove_user_resource(resource) 

372 else: 

373 self.log.debug( 

374 "Received 'leave group' request but resource was not listed. %s", p 

375 ) 

376 

377 async def update_info(self): 

378 """ 

379 Fetch information about this group from the legacy network 

380 

381 This is awaited on MUC instantiation, and should be overridden to 

382 update the attributes of the group chat, like title, subject, number 

383 of participants etc. 

384 

385 To take advantage of the slidge avatar cache, you can check the .avatar 

386 property to retrieve the "legacy file ID" of the cached avatar. If there 

387 is no change, you should not call 

388 :py:meth:`slidge.core.mixins.avatar.AvatarMixin.set_avatar()` or 

389 attempt to modify 

390 the :attr:.avatar property. 

391 """ 

392 raise NotImplementedError 

393 

394 async def backfill( 

395 self, 

396 after: Optional[HoleBound] = None, 

397 before: Optional[HoleBound] = None, 

398 ): 

399 """ 

400 Override this if the legacy network provide server-side group archives. 

401 

402 In it, send history messages using ``self.get_participant(xxx).send_xxxx``, 

403 with the ``archive_only=True`` kwarg. This is only called once per slidge 

404 run for a given group. 

405 

406 :param after: Fetch messages after this one. If ``None``, it's up to you 

407 to decide how far you want to go in the archive. If it's not ``None``, 

408 it means slidge has some messages in this archive and you should really try 

409 to complete it to avoid "holes" in the history of this group. 

410 :param before: Fetch messages before this one. If ``None``, fetch all messages 

411 up to the most recent one 

412 """ 

413 raise NotImplementedError 

414 

415 async def fill_participants(self) -> AsyncIterator[LegacyParticipantType]: 

416 """ 

417 This method should yield the list of all members of this group. 

418 

419 Typically, use ``participant = self.get_participant()``, self.get_participant_by_contact(), 

420 of self.get_user_participant(), and update their affiliation, hats, etc. 

421 before yielding them. 

422 """ 

423 return 

424 yield 

425 

426 @property 

427 def subject(self) -> str: 

428 return self.stored.subject or "" 

429 

430 @subject.setter 

431 def subject(self, s: str) -> None: 

432 if s == self.subject: 

433 return 

434 

435 self.update_stored_attribute(subject=s) 

436 self.__get_subject_setter_participant().set_room_subject( 

437 s, None, self.subject_date, False 

438 ) 

439 

440 @property 

441 def is_anonymous(self): 

442 return self.type == MucType.CHANNEL 

443 

444 @property 

445 def subject_setter(self) -> Optional[str]: 

446 return self.stored.subject_setter 

447 

448 @subject_setter.setter 

449 def subject_setter(self, subject_setter: SubjectSetterType) -> None: 

450 if isinstance(subject_setter, LegacyContact): 

451 subject_setter = subject_setter.name 

452 elif isinstance(subject_setter, LegacyParticipant): 

453 subject_setter = subject_setter.nickname 

454 

455 if subject_setter == self.subject_setter: 

456 return 

457 assert isinstance(subject_setter, str | None) 

458 self.update_stored_attribute(subject_setter=subject_setter) 

459 

460 def __get_subject_setter_participant(self) -> LegacyParticipant: 

461 if self.subject_setter is None: 

462 return self.get_system_participant() 

463 return self._participant_cls(self, Participant(nickname=self.subject_setter)) 

464 

465 def features(self): 

466 features = [ 

467 "http://jabber.org/protocol/muc", 

468 "http://jabber.org/protocol/muc#stable_id", 

469 "http://jabber.org/protocol/muc#self-ping-optimization", 

470 "urn:xmpp:mam:2", 

471 "urn:xmpp:mam:2#extended", 

472 "urn:xmpp:sid:0", 

473 "muc_persistent", 

474 "vcard-temp", 

475 "urn:xmpp:ping", 

476 "urn:xmpp:occupant-id:0", 

477 "jabber:iq:register", 

478 self.xmpp.plugin["xep_0425"].stanza.NS, 

479 ] 

480 if self.type == MucType.GROUP: 

481 features.extend(["muc_membersonly", "muc_nonanonymous", "muc_hidden"]) 

482 elif self.type == MucType.CHANNEL: 

483 features.extend(["muc_open", "muc_semianonymous", "muc_public"]) 

484 elif self.type == MucType.CHANNEL_NON_ANONYMOUS: 

485 features.extend(["muc_open", "muc_nonanonymous", "muc_public"]) 

486 return features 

487 

488 async def extended_features(self): 

489 is_group = self.type == MucType.GROUP 

490 

491 form = self.xmpp.plugin["xep_0004"].make_form(ftype="result") 

492 

493 form.add_field( 

494 "FORM_TYPE", "hidden", value="http://jabber.org/protocol/muc#roominfo" 

495 ) 

496 form.add_field("muc#roomconfig_persistentroom", "boolean", value=True) 

497 form.add_field("muc#roomconfig_changesubject", "boolean", value=False) 

498 form.add_field("muc#maxhistoryfetch", value=str(self.max_history_fetch)) 

499 form.add_field("muc#roominfo_subjectmod", "boolean", value=False) 

500 

501 if self._ALL_INFO_FILLED_ON_STARTUP or self.stored.participants_filled: 

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

503 n = orm.scalar( 

504 sa.select(sa.func.count(Participant.id)).filter_by(room=self.stored) 

505 ) 

506 else: 

507 n = self.n_participants 

508 if n is not None: 

509 form.add_field("muc#roominfo_occupants", value=str(n)) 

510 

511 if d := self.description: 

512 form.add_field("muc#roominfo_description", value=d) 

513 

514 if s := self.subject: 

515 form.add_field("muc#roominfo_subject", value=s) 

516 

517 if name := self.name: 

518 form.add_field("muc#roomconfig_roomname", value=name) 

519 

520 if self._set_avatar_task is not None: 

521 await self._set_avatar_task 

522 avatar = self.get_avatar() 

523 if avatar and (h := avatar.id): 

524 form.add_field( 

525 "{http://modules.prosody.im/mod_vcard_muc}avatar#sha1", value=h 

526 ) 

527 form.add_field("muc#roominfo_avatarhash", "text-multi", value=[h]) 

528 

529 form.add_field("muc#roomconfig_membersonly", "boolean", value=is_group) 

530 form.add_field( 

531 "muc#roomconfig_whois", 

532 "list-single", 

533 value="moderators" if self.is_anonymous else "anyone", 

534 ) 

535 form.add_field("muc#roomconfig_publicroom", "boolean", value=not is_group) 

536 form.add_field("muc#roomconfig_allowpm", "boolean", value=False) 

537 

538 r = [form] 

539 

540 if reaction_form := await self.restricted_emoji_extended_feature(): 

541 r.append(reaction_form) 

542 

543 return r 

544 

545 def shutdown(self) -> None: 

546 _, user_jid = escape_nickname(self.jid, self.user_nick) 

547 for user_full_jid in self.user_full_jids(): 

548 presence = self.xmpp.make_presence( 

549 pfrom=user_jid, pto=user_full_jid, ptype="unavailable" 

550 ) 

551 presence["muc"]["affiliation"] = "none" 

552 presence["muc"]["role"] = "none" 

553 presence["muc"]["status_codes"] = {110, 332} 

554 presence.send() 

555 

556 def user_full_jids(self): 

557 for r in self.get_user_resources(): 

558 j = JID(self.user_jid) 

559 j.resource = r 

560 yield j 

561 

562 @property 

563 def user_muc_jid(self): 

564 _, user_muc_jid = escape_nickname(self.jid, self.user_nick) 

565 return user_muc_jid 

566 

567 async def echo( 

568 self, msg: Message, legacy_msg_id: Optional[LegacyMessageType] = None 

569 ) -> str: 

570 origin_id = msg.get_origin_id() 

571 

572 msg.set_from(self.user_muc_jid) 

573 msg.set_id(msg.get_id()) 

574 if origin_id: 

575 # because of slixmpp internal magic, we need to do this to ensure the origin_id 

576 # is present 

577 set_origin_id(msg, origin_id) 

578 if legacy_msg_id: 

579 msg["stanza_id"]["id"] = self.session.legacy_to_xmpp_msg_id(legacy_msg_id) 

580 else: 

581 msg["stanza_id"]["id"] = str(uuid4()) 

582 msg["stanza_id"]["by"] = self.jid 

583 msg["occupant-id"]["id"] = "slidge-user" 

584 

585 self.archive.add(msg, await self.get_user_participant()) 

586 

587 for user_full_jid in self.user_full_jids(): 

588 self.log.debug("Echoing to %s", user_full_jid) 

589 msg = copy(msg) 

590 msg.set_to(user_full_jid) 

591 

592 msg.send() 

593 

594 return msg["stanza_id"]["id"] 

595 

596 def _post_avatar_update(self, cached_avatar) -> None: 

597 self.__send_configuration_change((104,)) 

598 self._send_room_presence() 

599 

600 def _send_room_presence(self, user_full_jid: Optional[JID] = None) -> None: 

601 if user_full_jid is None: 

602 tos = self.user_full_jids() 

603 else: 

604 tos = [user_full_jid] 

605 for to in tos: 

606 p = self.xmpp.make_presence(pfrom=self.jid, pto=to) 

607 if (avatar := self.get_avatar()) and (h := avatar.id): 

608 p["vcard_temp_update"]["photo"] = h 

609 else: 

610 p["vcard_temp_update"]["photo"] = "" 

611 p.send() 

612 

613 @timeit 

614 async def join(self, join_presence: Presence): 

615 user_full_jid = join_presence.get_from() 

616 requested_nickname = join_presence.get_to().resource 

617 client_resource = user_full_jid.resource 

618 

619 if client_resource in self.get_user_resources(): 

620 self.log.debug("Received join from a resource that is already joined.") 

621 

622 if not requested_nickname or not client_resource: 

623 raise XMPPError("jid-malformed", by=self.jid) 

624 

625 self.add_user_resource(client_resource) 

626 

627 self.log.debug( 

628 "Resource %s of %s wants to join room %s with nickname %s", 

629 client_resource, 

630 self.user_jid, 

631 self.legacy_id, 

632 requested_nickname, 

633 ) 

634 

635 user_nick = self.user_nick 

636 user_participant = None 

637 async for participant in self.get_participants(): 

638 if participant.is_user: 

639 user_participant = participant 

640 continue 

641 participant.send_initial_presence(full_jid=user_full_jid) 

642 

643 if user_participant is None: 

644 user_participant = await self.get_user_participant() 

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

646 orm.add(self.stored) 

647 with orm.no_autoflush: 

648 orm.refresh(self.stored, ["participants"]) 

649 if not user_participant.is_user: 

650 self.log.warning("is_user flag not set on user_participant") 

651 user_participant.is_user = True 

652 user_participant.send_initial_presence( 

653 user_full_jid, 

654 presence_id=join_presence["id"], 

655 nick_change=user_nick != requested_nickname, 

656 ) 

657 

658 history_params = join_presence["muc_join"]["history"] 

659 maxchars = int_or_none(history_params["maxchars"]) 

660 maxstanzas = int_or_none(history_params["maxstanzas"]) 

661 seconds = int_or_none(history_params["seconds"]) 

662 try: 

663 since = self.xmpp.plugin["xep_0082"].parse(history_params["since"]) 

664 except ValueError: 

665 since = None 

666 if seconds is not None: 

667 since = datetime.now() - timedelta(seconds=seconds) 

668 if equals_zero(maxchars) or equals_zero(maxstanzas): 

669 log.debug("Joining client does not want any old-school MUC history-on-join") 

670 else: 

671 self.log.debug("Old school history fill") 

672 await self.__fill_history() 

673 await self.__old_school_history( 

674 user_full_jid, 

675 maxchars=maxchars, 

676 maxstanzas=maxstanzas, 

677 since=since, 

678 ) 

679 if self.HAS_SUBJECT: 

680 subject = self.subject or "" 

681 else: 

682 subject = self.description or self.name or "" 

683 self.__get_subject_setter_participant().set_room_subject( 

684 subject, 

685 user_full_jid, 

686 self.subject_date, 

687 ) 

688 if t := self._set_avatar_task: 

689 await t 

690 self._send_room_presence(user_full_jid) 

691 

692 async def get_user_participant(self, **kwargs) -> "LegacyParticipantType": 

693 """ 

694 Get the participant representing the gateway user 

695 

696 :param kwargs: additional parameters for the :class:`.Participant` 

697 construction (optional) 

698 :return: 

699 """ 

700 p = await self.get_participant(self.user_nick, is_user=True, **kwargs) 

701 self.__store_participant(p) 

702 return p 

703 

704 def __store_participant(self, p: "LegacyParticipantType") -> None: 

705 if self.get_lock("fill participants"): 

706 return 

707 p.commit(merge=True) 

708 

709 async def get_participant( 

710 self, 

711 nickname: str, 

712 raise_if_not_found: bool = False, 

713 fill_first: bool = False, 

714 store: bool = True, 

715 is_user: bool = False, 

716 ) -> "LegacyParticipantType": 

717 """ 

718 Get a participant by their nickname. 

719 

720 In non-anonymous groups, you probably want to use 

721 :meth:`.LegacyMUC.get_participant_by_contact` instead. 

722 

723 :param nickname: Nickname of the participant (used as resource part in the MUC) 

724 :param raise_if_not_found: Raise XMPPError("item-not-found") if they are not 

725 in the participant list (internal use by slidge, plugins should not 

726 need that) 

727 :param fill_first: Ensure :meth:`.LegacyMUC.fill_participants()` has been called first 

728 (internal use by slidge, plugins should not need that) 

729 :param store: persistently store the user in the list of MUC participants 

730 :return: 

731 """ 

732 if fill_first: 

733 await self.__fill_participants() 

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

735 stored = ( 

736 orm.query(Participant) 

737 .filter( 

738 Participant.room == self.stored, 

739 (Participant.nickname == nickname) 

740 | (Participant.resource == nickname), 

741 ) 

742 .one_or_none() 

743 ) 

744 if stored is not None: 

745 return self.participant_from_store(stored) 

746 

747 if raise_if_not_found: 

748 raise XMPPError("item-not-found") 

749 p = self._participant_cls( 

750 self, Participant(room=self.stored, nickname=nickname, is_user=is_user) 

751 ) 

752 if store: 

753 self.__store_participant(p) 

754 if ( 

755 not self.get_lock("fill participants") 

756 and not self.get_lock("fill history") 

757 and self.stored.participants_filled 

758 and not p.is_user 

759 and not p.is_system 

760 ): 

761 p.send_affiliation_change() 

762 return p 

763 

764 def get_system_participant(self) -> "LegacyParticipantType": 

765 """ 

766 Get a pseudo-participant, representing the room itself 

767 

768 Can be useful for events that cannot be mapped to a participant, 

769 e.g. anonymous moderation events, or announces from the legacy 

770 service 

771 :return: 

772 """ 

773 return self._participant_cls(self, Participant(), is_system=True) 

774 

775 @overload 

776 async def get_participant_by_contact( 

777 self, c: "LegacyContact" 

778 ) -> "LegacyParticipantType": ... 

779 

780 @overload 

781 async def get_participant_by_contact( 

782 self, c: "LegacyContact", create: Literal[False] 

783 ) -> "LegacyParticipantType | None": ... 

784 

785 @overload 

786 async def get_participant_by_contact( 

787 self, c: "LegacyContact", create: Literal[True] 

788 ) -> "LegacyParticipantType": ... 

789 

790 async def get_participant_by_contact( 

791 self, c: "LegacyContact", create: bool = True 

792 ) -> "LegacyParticipantType | None": 

793 """ 

794 Get a non-anonymous participant. 

795 

796 This is what should be used in non-anonymous groups ideally, to ensure 

797 that the Contact jid is associated to this participant 

798 

799 :param c: The :class:`.LegacyContact` instance corresponding to this contact 

800 :param create: Creates the participant if it does not exist. 

801 :return: 

802 """ 

803 await self.session.contacts.ready 

804 

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

806 self.stored = orm.merge(self.stored) 

807 stored = ( 

808 orm.query(Participant) 

809 .filter_by(contact=c.stored, room=self.stored) 

810 .one_or_none() 

811 ) 

812 if stored is None: 

813 if not create: 

814 return None 

815 else: 

816 return self.participant_from_store(stored=stored, contact=c) 

817 

818 nickname = c.name or unescape_node(c.jid.node) 

819 

820 if self.stored.id is None: 

821 nick_available = True 

822 else: 

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

824 nick_available = ( 

825 orm.query(Participant.id).filter_by( 

826 room=self.stored, nickname=nickname 

827 ) 

828 ).one_or_none() is None 

829 

830 if not nick_available: 

831 self.log.debug("Nickname conflict") 

832 nickname = f"{nickname} ({c.jid.node})" 

833 p = self._participant_cls( 

834 self, Participant(nickname=nickname, room=self.stored), contact=c 

835 ) 

836 

837 self.__store_participant(p) 

838 # FIXME: this is not great but given the current design, 

839 # during participants fill and history backfill we do not 

840 # want to send presence, because we might :update affiliation 

841 # and role afterwards. 

842 # We need a refactor of the MUC class… later™ 

843 if ( 

844 self.stored.participants_filled 

845 and not self.get_lock("fill participants") 

846 and not self.get_lock("fill history") 

847 ): 

848 p.send_last_presence(force=True, no_cache_online=True) 

849 return p 

850 

851 async def get_participant_by_legacy_id( 

852 self, legacy_id: LegacyUserIdType 

853 ) -> "LegacyParticipantType": 

854 try: 

855 c = await self.session.contacts.by_legacy_id(legacy_id) 

856 except ContactIsUser: 

857 return await self.get_user_participant() 

858 return await self.get_participant_by_contact(c) 

859 

860 def remove_participant( 

861 self, 

862 p: "LegacyParticipantType", 

863 kick: bool = False, 

864 ban: bool = False, 

865 reason: str | None = None, 

866 ): 

867 """ 

868 Call this when a participant leaves the room 

869 

870 :param p: The participant 

871 :param kick: Whether the participant left because they were kicked 

872 :param ban: Whether the participant left because they were banned 

873 :param reason: Optionally, a reason why the participant was removed. 

874 """ 

875 if kick and ban: 

876 raise TypeError("Either kick or ban") 

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

878 orm.delete(p.stored) 

879 orm.commit() 

880 if kick: 

881 codes = {307} 

882 elif ban: 

883 codes = {301} 

884 else: 

885 codes = None 

886 presence = p._make_presence(ptype="unavailable", status_codes=codes) 

887 p.stored.affiliation = "outcast" if ban else "none" 

888 p.stored.role = "none" 

889 if reason: 

890 presence["muc"].set_item_attr("reason", reason) 

891 p._send(presence) 

892 

893 def rename_participant(self, old_nickname: str, new_nickname: str) -> None: 

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

895 stored = ( 

896 orm.query(Participant) 

897 .filter_by(room=self.stored, nickname=old_nickname) 

898 .one_or_none() 

899 ) 

900 if stored is None: 

901 self.log.debug("Tried to rename a participant that we didn't know") 

902 return 

903 p = self.participant_from_store(stored) 

904 if p.nickname == old_nickname: 

905 p.nickname = new_nickname 

906 

907 async def __old_school_history( 

908 self, 

909 full_jid: JID, 

910 maxchars: Optional[int] = None, 

911 maxstanzas: Optional[int] = None, 

912 seconds: Optional[int] = None, 

913 since: Optional[datetime] = None, 

914 ) -> None: 

915 """ 

916 Old-style history join (internal slidge use) 

917 

918 :param full_jid: 

919 :param maxchars: 

920 :param maxstanzas: 

921 :param seconds: 

922 :param since: 

923 :return: 

924 """ 

925 if since is None: 

926 if seconds is None: 

927 start_date = datetime.now(tz=timezone.utc) - timedelta(days=1) 

928 else: 

929 start_date = datetime.now(tz=timezone.utc) - timedelta(seconds=seconds) 

930 else: 

931 start_date = since or datetime.now(tz=timezone.utc) - timedelta(days=1) 

932 

933 for h_msg in self.archive.get_all( 

934 start_date=start_date, end_date=None, last_page_n=maxstanzas 

935 ): 

936 msg = h_msg.stanza_component_ns 

937 msg["delay"]["stamp"] = h_msg.when 

938 msg.set_to(full_jid) 

939 self.xmpp.send(msg, False) 

940 

941 async def send_mam(self, iq: Iq) -> None: 

942 await self.__fill_history() 

943 

944 form_values = iq["mam"]["form"].get_values() 

945 

946 start_date = str_to_datetime_or_none(form_values.get("start")) 

947 end_date = str_to_datetime_or_none(form_values.get("end")) 

948 

949 after_id = form_values.get("after-id") 

950 before_id = form_values.get("before-id") 

951 

952 sender = form_values.get("with") 

953 

954 ids = form_values.get("ids") or () 

955 

956 if max_str := iq["mam"]["rsm"]["max"]: 

957 try: 

958 max_results = int(max_str) 

959 except ValueError: 

960 max_results = None 

961 else: 

962 max_results = None 

963 

964 after_id_rsm = iq["mam"]["rsm"]["after"] 

965 after_id = after_id_rsm or after_id 

966 

967 before_rsm = iq["mam"]["rsm"]["before"] 

968 if before_rsm is not None and max_results is not None: 

969 last_page_n = max_results 

970 # - before_rsm is True means the empty element <before />, which means 

971 # "last page in chronological order", cf https://xmpp.org/extensions/xep-0059.html#backwards 

972 # - before_rsm == "an ID" means <before>an ID</before> 

973 if before_rsm is not True: 

974 before_id = before_rsm 

975 else: 

976 last_page_n = None 

977 

978 first = None 

979 last = None 

980 count = 0 

981 

982 it = self.archive.get_all( 

983 start_date, 

984 end_date, 

985 before_id, 

986 after_id, 

987 ids, 

988 last_page_n, 

989 sender, 

990 bool(iq["mam"]["flip_page"]), 

991 ) 

992 

993 for history_msg in it: 

994 last = xmpp_id = history_msg.id 

995 if first is None: 

996 first = xmpp_id 

997 

998 wrapper_msg = self.xmpp.make_message(mfrom=self.jid, mto=iq.get_from()) 

999 wrapper_msg["mam_result"]["queryid"] = iq["mam"]["queryid"] 

1000 wrapper_msg["mam_result"]["id"] = xmpp_id 

1001 wrapper_msg["mam_result"].append(history_msg.forwarded()) 

1002 

1003 wrapper_msg.send() 

1004 count += 1 

1005 

1006 if max_results and count == max_results: 

1007 break 

1008 

1009 if max_results: 

1010 try: 

1011 next(it) 

1012 except StopIteration: 

1013 complete = True 

1014 else: 

1015 complete = False 

1016 else: 

1017 complete = True 

1018 

1019 reply = iq.reply() 

1020 if not self.STABLE_ARCHIVE: 

1021 reply["mam_fin"]["stable"] = "false" 

1022 if complete: 

1023 reply["mam_fin"]["complete"] = "true" 

1024 reply["mam_fin"]["rsm"]["first"] = first 

1025 reply["mam_fin"]["rsm"]["last"] = last 

1026 reply["mam_fin"]["rsm"]["count"] = str(count) 

1027 reply.send() 

1028 

1029 async def send_mam_metadata(self, iq: Iq) -> None: 

1030 await self.__fill_history() 

1031 await self.archive.send_metadata(iq) 

1032 

1033 async def kick_resource(self, r: str) -> None: 

1034 """ 

1035 Kick a XMPP client of the user. (slidge internal use) 

1036 

1037 :param r: The resource to kick 

1038 """ 

1039 pto = JID(self.user_jid) 

1040 pto.resource = r 

1041 p = self.xmpp.make_presence( 

1042 pfrom=(await self.get_user_participant()).jid, pto=pto 

1043 ) 

1044 p["type"] = "unavailable" 

1045 p["muc"]["affiliation"] = "none" 

1046 p["muc"]["role"] = "none" 

1047 p["muc"]["status_codes"] = {110, 333} 

1048 p.send() 

1049 

1050 async def __get_bookmark(self) -> Item | None: 

1051 item = Item() 

1052 item["id"] = self.jid 

1053 

1054 iq = Iq(stype="get", sfrom=self.user_jid, sto=self.user_jid) 

1055 iq["pubsub"]["items"]["node"] = self.xmpp["xep_0402"].stanza.NS 

1056 iq["pubsub"]["items"].append(item) 

1057 

1058 try: 

1059 ans = await self.xmpp["xep_0356"].send_privileged_iq(iq) 

1060 if len(ans["pubsub"]["items"]) != 1: 

1061 return None 

1062 # this below creates the item if it wasn't here already 

1063 # (slixmpp annoying magic) 

1064 item = ans["pubsub"]["items"]["item"] 

1065 item["id"] = self.jid 

1066 return item 

1067 except IqTimeout as exc: 

1068 warnings.warn(f"Cannot fetch bookmark for {self.user_jid}: timeout") 

1069 return None 

1070 except IqError as exc: 

1071 warnings.warn(f"Cannot fetch bookmark for {self.user_jid}: {exc}") 

1072 return None 

1073 except PermissionError: 

1074 warnings.warn( 

1075 "IQ privileges (XEP0356) are not set, we cannot fetch the user bookmarks" 

1076 ) 

1077 return None 

1078 

1079 async def add_to_bookmarks( 

1080 self, 

1081 auto_join: bool = True, 

1082 preserve: bool = True, 

1083 pin: bool | None = None, 

1084 notify: WhenLiteral | None = None, 

1085 ) -> None: 

1086 """ 

1087 Add the MUC to the user's XMPP bookmarks (:xep:`0402') 

1088 

1089 This requires that slidge has the IQ privileged set correctly 

1090 on the XMPP server 

1091 

1092 :param auto_join: whether XMPP clients should automatically join 

1093 this MUC on startup. In theory, XMPP clients will receive 

1094 a "push" notification when this is called, and they will 

1095 join if they are online. 

1096 :param preserve: preserve auto-join and bookmarks extensions 

1097 set by the user outside slidge 

1098 :param pin: Pin the group chat bookmark :xep:`0469`. Requires privileged entity. 

1099 If set to ``None`` (default), the bookmark pinning status will be untouched. 

1100 :param notify: Chat notification setting: :xep:`0492`. Requires privileged entity. 

1101 If set to ``None`` (default), the setting will be untouched. Only the "global" 

1102 notification setting is supported (ie, per client type is not possible). 

1103 """ 

1104 existing = await self.__get_bookmark() if preserve else None 

1105 

1106 new = Item() 

1107 new["id"] = self.jid 

1108 new["conference"]["nick"] = self.user_nick 

1109 

1110 if existing is None: 

1111 change = True 

1112 new["conference"]["autojoin"] = auto_join 

1113 else: 

1114 change = False 

1115 new["conference"]["autojoin"] = existing["conference"]["autojoin"] 

1116 

1117 existing_extensions = existing is not None and existing[ 

1118 "conference" 

1119 ].get_plugin("extensions", check=True) 

1120 

1121 # preserving extensions we don't know about is a MUST 

1122 if existing_extensions: 

1123 assert existing is not None 

1124 for el in existing["conference"]["extensions"].xml: 

1125 if el.tag.startswith(f"{{{NOTIFY_NS}}}"): 

1126 if notify is not None: 

1127 continue 

1128 if el.tag.startswith(f"{{{PINNING_NS}}}"): 

1129 if pin is not None: 

1130 continue 

1131 new["conference"]["extensions"].append(el) 

1132 

1133 if pin is not None: 

1134 if existing_extensions: 

1135 assert existing is not None 

1136 existing_pin = ( 

1137 existing["conference"]["extensions"].get_plugin( 

1138 "pinned", check=True 

1139 ) 

1140 is not None 

1141 ) 

1142 if existing_pin != pin: 

1143 change = True 

1144 new["conference"]["extensions"]["pinned"] = pin 

1145 

1146 if notify is not None: 

1147 new["conference"]["extensions"].enable("notify") 

1148 if existing_extensions: 

1149 assert existing is not None 

1150 existing_notify = existing["conference"]["extensions"].get_plugin( 

1151 "notify", check=True 

1152 ) 

1153 if existing_notify is None: 

1154 change = True 

1155 else: 

1156 if existing_notify.get_config() != notify: 

1157 change = True 

1158 for el in existing_notify: 

1159 new["conference"]["extensions"]["notify"].append(el) 

1160 new["conference"]["extensions"]["notify"].configure(notify) 

1161 

1162 if change: 

1163 iq = Iq(stype="set", sfrom=self.user_jid, sto=self.user_jid) 

1164 iq["pubsub"]["publish"]["node"] = self.xmpp["xep_0402"].stanza.NS 

1165 iq["pubsub"]["publish"].append(new) 

1166 

1167 iq["pubsub"]["publish_options"] = _BOOKMARKS_OPTIONS 

1168 

1169 try: 

1170 await self.xmpp["xep_0356"].send_privileged_iq(iq) 

1171 except PermissionError: 

1172 warnings.warn( 

1173 "IQ privileges (XEP0356) are not set, we cannot add bookmarks for the user" 

1174 ) 

1175 # fallback by forcing invitation 

1176 bookmark_add_fail = True 

1177 except IqError as e: 

1178 warnings.warn( 

1179 f"Something went wrong while trying to set the bookmarks: {e}" 

1180 ) 

1181 # fallback by forcing invitation 

1182 bookmark_add_fail = True 

1183 else: 

1184 bookmark_add_fail = False 

1185 else: 

1186 self.log.debug("Bookmark does not need updating.") 

1187 return 

1188 

1189 if bookmark_add_fail: 

1190 self.session.send_gateway_invite( 

1191 self, 

1192 reason="This group could not be added automatically for you, most" 

1193 "likely because this gateway is not configured as a privileged entity. " 

1194 "Contact your administrator.", 

1195 ) 

1196 elif existing is None and self.session.user.preferences.get( 

1197 "always_invite_when_adding_bookmarks", True 

1198 ): 

1199 self.session.send_gateway_invite( 

1200 self, 

1201 reason="The gateway is configured to always send invitations for groups.", 

1202 ) 

1203 

1204 async def on_avatar( 

1205 self, data: Optional[bytes], mime: Optional[str] 

1206 ) -> Optional[Union[int, str]]: 

1207 """ 

1208 Called when the user tries to set the avatar of the room from an XMPP 

1209 client. 

1210 

1211 If the set avatar operation is completed, should return a legacy image 

1212 unique identifier. In this case the MUC avatar will be immediately 

1213 updated on the XMPP side. 

1214 

1215 If data is not None and this method returns None, then we assume that 

1216 self.set_avatar() will be called elsewhere, eg triggered by a legacy 

1217 room update event. 

1218 

1219 :param data: image data or None if the user meant to remove the avatar 

1220 :param mime: the mime type of the image. Since this is provided by 

1221 the XMPP client, there is no guarantee that this is valid or 

1222 correct. 

1223 :return: A unique avatar identifier, which will trigger 

1224 :py:meth:`slidge.group.room.LegacyMUC.set_avatar`. Alternatively, None, if 

1225 :py:meth:`.LegacyMUC.set_avatar` is meant to be awaited somewhere else. 

1226 """ 

1227 raise NotImplementedError 

1228 

1229 admin_set_avatar = deprecated("LegacyMUC.on_avatar", on_avatar) 

1230 

1231 async def on_set_affiliation( 

1232 self, 

1233 contact: "LegacyContact", 

1234 affiliation: MucAffiliation, 

1235 reason: Optional[str], 

1236 nickname: Optional[str], 

1237 ): 

1238 """ 

1239 Triggered when the user requests changing the affiliation of a contact 

1240 for this group. 

1241 

1242 Examples: promotion them to moderator, ban (affiliation=outcast). 

1243 

1244 :param contact: The contact whose affiliation change is requested 

1245 :param affiliation: The new affiliation 

1246 :param reason: A reason for this affiliation change 

1247 :param nickname: 

1248 """ 

1249 raise NotImplementedError 

1250 

1251 async def on_kick(self, contact: "LegacyContact", reason: Optional[str]): 

1252 """ 

1253 Triggered when the user requests changing the role of a contact 

1254 to "none" for this group. Action commonly known as "kick". 

1255 

1256 :param contact: Contact to be kicked 

1257 :param reason: A reason for this kick 

1258 """ 

1259 raise NotImplementedError 

1260 

1261 async def on_set_config( 

1262 self, 

1263 name: Optional[str], 

1264 description: Optional[str], 

1265 ): 

1266 """ 

1267 Triggered when the user requests changing the room configuration. 

1268 Only title and description can be changed at the moment. 

1269 

1270 The legacy module is responsible for updating :attr:`.title` and/or 

1271 :attr:`.description` of this instance. 

1272 

1273 If :attr:`.HAS_DESCRIPTION` is set to False, description will always 

1274 be ``None``. 

1275 

1276 :param name: The new name of the room. 

1277 :param description: The new description of the room. 

1278 """ 

1279 raise NotImplementedError 

1280 

1281 async def on_destroy_request(self, reason: Optional[str]): 

1282 """ 

1283 Triggered when the user requests room destruction. 

1284 

1285 :param reason: Optionally, a reason for the destruction 

1286 """ 

1287 raise NotImplementedError 

1288 

1289 async def parse_mentions(self, text: str) -> list[Mention]: 

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

1291 await self.__fill_participants() 

1292 orm.add(self.stored) 

1293 participants = {p.nickname: p for p in self.stored.participants} 

1294 

1295 if len(participants) == 0: 

1296 return [] 

1297 

1298 result = [] 

1299 for match in re.finditer( 

1300 "|".join( 

1301 sorted( 

1302 [re.escape(nick) for nick in participants.keys()], 

1303 key=lambda nick: len(nick), 

1304 reverse=True, 

1305 ) 

1306 ), 

1307 text, 

1308 ): 

1309 span = match.span() 

1310 nick = match.group() 

1311 if span[0] != 0 and text[span[0] - 1] not in _WHITESPACE_OR_PUNCTUATION: 

1312 continue 

1313 if span[1] == len(text) or text[span[1]] in _WHITESPACE_OR_PUNCTUATION: 

1314 participant = self.participant_from_store( 

1315 stored=participants[nick], 

1316 ) 

1317 if contact := participant.contact: 

1318 result.append( 

1319 Mention(contact=contact, start=span[0], end=span[1]) 

1320 ) 

1321 return result 

1322 

1323 async def on_set_subject(self, subject: str) -> None: 

1324 """ 

1325 Triggered when the user requests changing the room subject. 

1326 

1327 The legacy module is responsible for updating :attr:`.subject` of this 

1328 instance. 

1329 

1330 :param subject: The new subject for this room. 

1331 """ 

1332 raise NotImplementedError 

1333 

1334 async def on_set_thread_subject( 

1335 self, thread: LegacyThreadType, subject: str 

1336 ) -> None: 

1337 """ 

1338 Triggered when the user requests changing the subject of a specific thread. 

1339 

1340 :param thread: Legacy identifier of the thread 

1341 :param subject: The new subject for this thread. 

1342 """ 

1343 raise NotImplementedError 

1344 

1345 @property 

1346 def participants_filled(self) -> bool: 

1347 return self.stored.participants_filled 

1348 

1349 

1350def set_origin_id(msg: Message, origin_id: str) -> None: 

1351 sub = ET.Element("{urn:xmpp:sid:0}origin-id") 

1352 sub.attrib["id"] = origin_id 

1353 msg.xml.append(sub) 

1354 

1355 

1356def int_or_none(x): 

1357 try: 

1358 return int(x) 

1359 except ValueError: 

1360 return None 

1361 

1362 

1363def equals_zero(x): 

1364 if x is None: 

1365 return False 

1366 else: 

1367 return x == 0 

1368 

1369 

1370def str_to_datetime_or_none(date: Optional[str]): 

1371 if date is None: 

1372 return 

1373 try: 

1374 return str_to_datetime(date) 

1375 except ValueError: 

1376 return None 

1377 

1378 

1379def bookmarks_form(): 

1380 form = Form() 

1381 form["type"] = "submit" 

1382 form.add_field( 

1383 "FORM_TYPE", 

1384 value="http://jabber.org/protocol/pubsub#publish-options", 

1385 ftype="hidden", 

1386 ) 

1387 form.add_field("pubsub#persist_items", value="1") 

1388 form.add_field("pubsub#max_items", value="max") 

1389 form.add_field("pubsub#send_last_published_item", value="never") 

1390 form.add_field("pubsub#access_model", value="whitelist") 

1391 return form 

1392 

1393 

1394_BOOKMARKS_OPTIONS = bookmarks_form() 

1395_WHITESPACE_OR_PUNCTUATION = string.whitespace + string.punctuation 

1396 

1397log = logging.getLogger(__name__)