comparison libervia/backend/plugins/plugin_xep_0402.py @ 4327:554a87ae17a6

plugin XEP-0048, XEP-0402; CLI (bookmarks): implement XEP-0402 (PEP Native Bookmarks): - Former bookmarks implementation is now labeled as "legacy". - XEP-0402 is now used for bookmarks when relevant namespaces are found, and it fallbacks to legacy XEP-0048/XEP-0049 bookmarks otherwise. - CLI legacy bookmark commands have been moved to `bookmarks legacy` - CLI bookmarks commands now use the new XEP-0402 (with fallback to legacy one automatically used if necessary).
author Goffi <goffi@goffi.org>
date Wed, 20 Nov 2024 11:43:27 +0100
parents
children
comparison
equal deleted inserted replaced
4326:5fd6a4dc2122 4327:554a87ae17a6
1 #!/usr/bin/env python3
2
3 # Libervia plugin to handle chat room bookmarks via PEP
4 # Copyright (C) 2009-2024 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 from typing import Iterator, Self, cast
20
21 from pydantic import BaseModel, Field, RootModel
22 from twisted.internet import defer
23 from twisted.words.protocols.jabber import jid
24 from twisted.words.xish import domish
25 from wokkel import pubsub
26
27 from libervia.backend.core import exceptions
28 from libervia.backend.core.constants import Const as C
29 from libervia.backend.core.core_types import SatXMPPEntity
30 from libervia.backend.core.i18n import _
31 from libervia.backend.core.log import getLogger
32 from libervia.backend.models.types import DomishElementType, JIDType
33 from libervia.backend.plugins.plugin_xep_0045 import XEP_0045
34 from libervia.backend.plugins.plugin_xep_0048 import XEP_0048
35 from libervia.backend.plugins.plugin_xep_0060 import XEP_0060
36 from libervia.backend.tools import utils
37 from libervia.backend.tools.common import data_format
38
39 log = getLogger(__name__)
40
41
42 PLUGIN_INFO = {
43 C.PI_NAME: "PEP Native Bookmarks",
44 C.PI_IMPORT_NAME: "XEP-0402",
45 C.PI_TYPE: "XEP",
46 C.PI_MODES: C.PLUG_MODE_BOTH,
47 C.PI_PROTOCOLS: [],
48 C.PI_DEPENDENCIES: ["XEP-0048", "XEP-0060", "XEP-0163", "XEP-0045"],
49 C.PI_RECOMMENDATIONS: [],
50 C.PI_MAIN: "XEP_0402",
51 C.PI_HANDLER: "no",
52 C.PI_DESCRIPTION: _("""Bookmark chat rooms, and handle their joined state."""),
53 }
54
55 NS_BOOKMARKS2 = "urn:xmpp:bookmarks:1"
56 NS_BOOKMARKS2_COMPAT = f"{NS_BOOKMARKS2}#compat"
57
58
59 class Conference(BaseModel):
60 """
61 Model for conference data.
62 """
63
64 autojoin: bool = Field(
65 False,
66 description="Whether the client should automatically join the conference room on "
67 "login.",
68 )
69 name: str | None = Field(
70 None, description="A friendly name for the bookmark, specified by the user."
71 )
72 nick: str | None = Field(
73 None, description="The user's preferred roomnick for the chatroom."
74 )
75 password: str | None = Field(
76 None, description="A password used to access the chatroom."
77 )
78 extensions: list[DomishElementType] = Field(
79 default_factory=list,
80 description="A set of child elements (of potentially any namespace).",
81 )
82
83 def set_attributes(self, conference_elt: domish.Element) -> None:
84 """Set <conference> element attributes from this instance's data."""
85 if self.autojoin:
86 conference_elt["autojoin"] = "true" if self.autojoin else "false"
87 if self.name:
88 conference_elt["name"] = self.name
89
90 def set_children(self, conference_elt: domish.Element) -> None:
91 """Set <conference> element children from this instance's data."""
92 if self.nick:
93 nick_elt = conference_elt.addElement((NS_BOOKMARKS2, "nick"))
94 nick_elt.addContent(self.nick)
95 if self.password:
96 password_elt = conference_elt.addElement((NS_BOOKMARKS2, "password"))
97 password_elt.addContent(self.password)
98 for extension in self.extensions:
99 conference_elt.addChild(extension)
100
101 @classmethod
102 def from_element(cls, conference_elt: domish.Element) -> Self:
103 """
104 Create a Conference instance from a <conference> element or its parent.
105
106 @param conference_elt: The <conference> element or a parent element.
107 @return: Conference instance.
108 @raise exceptions.NotFound: If the <conference> element is not found.
109 """
110 if conference_elt.uri != NS_BOOKMARKS2 or conference_elt.name != "conference":
111 child_conference_elt = next(
112 conference_elt.elements(NS_BOOKMARKS2, "conference"), None
113 )
114 if child_conference_elt is None:
115 raise exceptions.NotFound("<conference> element not found")
116 else:
117 conference_elt = child_conference_elt
118 kwargs = {}
119 if conference_elt.hasAttribute("autojoin"):
120 kwargs["autojoin"] = conference_elt["autojoin"] == "true"
121 if conference_elt.hasAttribute("name"):
122 kwargs["name"] = conference_elt["name"]
123 nick_elt = next(conference_elt.elements(NS_BOOKMARKS2, "nick"), None)
124 if nick_elt:
125 kwargs["nick"] = str(nick_elt)
126 password_elt = next(conference_elt.elements(NS_BOOKMARKS2, "password"), None)
127 if password_elt:
128 kwargs["password"] = str(password_elt)
129 kwargs["extensions"] = [
130 child for child in conference_elt.elements() if child.uri != NS_BOOKMARKS2
131 ]
132 return cls(**kwargs)
133
134 def to_element(self) -> domish.Element:
135 """Build the <conference> element from this instance's data.
136
137 @return: <conference> element.
138 """
139 conference_elt = domish.Element((NS_BOOKMARKS2, "conference"))
140 self.set_attributes(conference_elt)
141 self.set_children(conference_elt)
142 return conference_elt
143
144
145 class Bookmarks(RootModel):
146 root: dict[JIDType, Conference]
147
148 def items(self):
149 return self.root.items()
150
151 def __dict__(self) -> dict[JIDType, Conference]: # type: ignore
152 return self.root
153
154 def __iter__(self) -> Iterator[JIDType]: # type: ignore
155 return iter(self.root)
156
157 def __getitem__(self, item):
158 return self.root[item]
159
160 @classmethod
161 def from_elements(cls, items_elt: list[domish.Element]) -> Self:
162 """Convert list of items to instance of Bookmarks.
163
164 @param items_elt: list of <item> elements from boorkmarks node.
165 """
166 bookmarks = {}
167
168 for item_elt in items_elt:
169 try:
170 bookmark_jid = jid.JID(item_elt["id"])
171 except RuntimeError as e:
172 log.warning(f"Can't parse bookmark jid {e}: {item_elt.toXml()}")
173 continue
174 try:
175 conference = Conference.from_element(item_elt)
176 except exceptions.NotFound:
177 log.warning(f"Can't find conference data in bookmark: {item_elt}")
178 else:
179 bookmarks[bookmark_jid] = conference
180
181 return cls(bookmarks)
182
183
184 class XEP_0402:
185 namespace = NS_BOOKMARKS2
186
187 def __init__(self, host):
188 log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
189 self.host = host
190 self._legacy = cast(XEP_0048, host.plugins["XEP-0048"])
191 self._p = cast(XEP_0060, host.plugins["XEP-0060"])
192 self._muc = cast(XEP_0045, host.plugins["XEP-0045"])
193 host.bridge.add_method(
194 "bookmark_get",
195 ".plugin",
196 in_sign="ss",
197 out_sign="s",
198 method=self._bookmark_get,
199 async_=True,
200 )
201 host.bridge.add_method(
202 "bookmarks_list",
203 ".plugin",
204 in_sign="ss",
205 out_sign="s",
206 method=self._bookmarks_list,
207 async_=True,
208 )
209 host.bridge.add_method(
210 "bookmark_remove",
211 ".plugin",
212 in_sign="ss",
213 out_sign="",
214 method=self._bookmark_remove,
215 async_=True,
216 )
217 host.bridge.add_method(
218 "bookmarks_set",
219 ".plugin",
220 in_sign="ss",
221 out_sign="",
222 method=self._bookmarks_set,
223 async_=True,
224 )
225 host.plugins["XEP-0163"].add_pep_event(
226 None, NS_BOOKMARKS2, self._on_bookmark_event
227 )
228 host.register_namespace("bookmarks2", NS_BOOKMARKS2)
229
230 async def _on_bookmark_event(
231 self, items_events: pubsub.ItemsEvent, profile: str
232 ) -> None:
233 client = self.host.get_client(profile)
234 if items_events.sender != client.jid.userhostJID():
235 log.warning(
236 "Bookmark event Unexpectedly send for another account "
237 f"({items_events.sender})."
238 )
239 else:
240 for item_elt in items_events.items:
241 try:
242 room_jid = jid.JID(item_elt["id"])
243 except Exception as e:
244 log.warning(
245 f'Ignoring bookmark due to invalid JID in "id" ({e}): '
246 f"{item_elt.toXml()}"
247 )
248 continue
249 try:
250 conference = Conference.from_element(item_elt)
251 except exceptions.NotFound:
252 log.warning("Ignoring invalid bookmark element: {item_elt.toXml()}")
253 except Exception:
254 log.exception("Can't parse bookmark item: {item_elt.toXml()}")
255 else:
256 if conference.autojoin:
257 await self._muc.join(
258 client,
259 room_jid,
260 conference.nick,
261 {"password": conference.password},
262 )
263 else:
264 await self._muc.leave(client, room_jid)
265
266 @utils.ensure_deferred
267 async def _bookmark_get(self, bookmark_jid: str, profile: str) -> str:
268 """List current boorkmarks.
269
270 @param extra_s: Serialised extra.
271 Reserved for future use.
272 @param profile: Profile to use.
273 """
274 client = self.host.get_client(profile)
275 conference = await self.get(client, jid.JID(bookmark_jid))
276 return conference.model_dump_json(exclude_none=True)
277
278 async def get(self, client: SatXMPPEntity, bookmark_jid: jid.JID) -> Conference:
279 """Helper method to get a single bookmark.
280
281 @param client: profile session.
282 @bookmark_jid: JID of the boorkmark to get.
283 @return: Conference instance.
284 """
285 pep_jid = client.jid.userhostJID()
286 if await self.host.memory.disco.has_feature(
287 client, NS_BOOKMARKS2_COMPAT, pep_jid
288 ):
289 items, __ = await self._p.get_items(
290 client,
291 client.jid.userhostJID(),
292 NS_BOOKMARKS2,
293 item_ids=[bookmark_jid.full()],
294 )
295 return Conference.from_element(items[0])
296 else:
297 # No compatibility layer, we use legacy bookmarks.
298 bookmarks = await self.list(client)
299 return bookmarks[bookmark_jid]
300
301 def _bookmark_remove(self, bookmark_jid: str, profile: str) -> defer.Deferred[None]:
302 d = defer.ensureDeferred(
303 self.remove(self.host.get_client(profile), jid.JID(bookmark_jid))
304 )
305 return d
306
307 async def remove(self, client: SatXMPPEntity, bookmark_jid: jid.JID) -> None:
308 """Helper method to delete an existing bookmark.
309
310 @param client: Profile session.
311 @param bookmark_jid: Bookmark to delete.
312 """
313 pep_jid = client.jid.userhostJID()
314
315 if await self.host.memory.disco.has_feature(
316 client, NS_BOOKMARKS2_COMPAT, pep_jid
317 ):
318 await self._p.retract_items(
319 client, client.jid.userhostJID(), NS_BOOKMARKS2, [bookmark_jid.full()]
320 )
321 else:
322 log.debug(
323 f"[{client.profile}] No compatibility layer found, we use legacy "
324 "bookmarks."
325 )
326 await self._legacy.remove_bookmark(
327 self._legacy.MUC_TYPE, bookmark_jid, "private", client.profile
328 )
329
330 @utils.ensure_deferred
331 async def _bookmarks_list(self, extra_s: str, profile: str) -> str:
332 """List current boorkmarks.
333
334 @param extra_s: Serialised extra.
335 Reserved for future use.
336 @param profile: Profile to use.
337 """
338 client = self.host.get_client(profile)
339 extra = data_format.deserialise(extra_s)
340 bookmarks = await self.list(client, extra)
341 return bookmarks.model_dump_json(exclude_none=True)
342
343 async def list(self, client: SatXMPPEntity, extra: dict | None = None) -> Bookmarks:
344 """List bookmarks.
345
346 If there is a compatibility layer announced, Bookmarks2 will be used, otherwise
347 legacy bookmarks will be used.
348 @param client: Client session.
349 @param extra: extra dat)
350 @return: bookmarks.
351 """
352 pep_jid = client.jid.userhostJID()
353
354 if await self.host.memory.disco.has_feature(
355 client, NS_BOOKMARKS2_COMPAT, pep_jid
356 ):
357 items, __ = await self._p.get_items(
358 client, client.jid.userhostJID(), NS_BOOKMARKS2
359 )
360 return Bookmarks.from_elements(items)
361 else:
362 # There is no compatibility layer on the PEP server, so we use legacy
363 # bookmarks as recommended at
364 # https://docs.modernxmpp.org/client/groupchat/#bookmarks
365 log.debug(
366 f"[{client.profile}] No compatibility layer found, we use legacy "
367 "bookmarks."
368 )
369 legacy_data = await self._legacy.bookmarks_list(
370 self._legacy.MUC_TYPE, "private", client.profile
371 )
372 private_bookmarks = legacy_data["private"]
373 bookmarks_dict = {}
374 for jid, bookmark_data in private_bookmarks.items():
375 autojoin = C.bool(bookmark_data.get("autojoin", C.BOOL_FALSE))
376 name = bookmark_data.get("name")
377 nick = bookmark_data.get("nick")
378 password = bookmark_data.get("password")
379 conference = Conference(
380 autojoin=autojoin, name=name, nick=nick, password=password
381 )
382 bookmarks_dict[jid] = conference
383 return Bookmarks(bookmarks_dict)
384
385 @utils.ensure_deferred
386 async def _bookmarks_set(self, bookmarks_raw: str, profile: str) -> None:
387 """Add or update one or more bookmarks.
388
389 @param bookmarks_raw: serialised bookmark.
390 It must deserialise to a dict mapping from bookmark JID to Conference data.
391 @param profile: Profile to use.
392 """
393 client = self.host.get_client(profile)
394 bookmarks = Bookmarks.model_validate_json(bookmarks_raw)
395 pep_jid = client.jid.userhostJID()
396
397 if await self.host.memory.disco.has_feature(
398 client, NS_BOOKMARKS2_COMPAT, pep_jid
399 ):
400 bookmark_items = []
401 for bookmark_jid, conference in bookmarks.items():
402 item_elt = domish.Element((pubsub.NS_PUBSUB, "item"))
403 item_elt["id"] = bookmark_jid.full()
404 item_elt.addChild(conference.to_element())
405 bookmark_items.append(item_elt)
406
407 await self._p.send_items(
408 client,
409 None,
410 NS_BOOKMARKS2,
411 bookmark_items,
412 extra={
413 self._p.EXTRA_PUBLISH_OPTIONS: {
414 "pubsub#access_model": self._p.ACCESS_WHITELIST
415 },
416 self._p.EXTRA_AUTOCREATE: True,
417 },
418 )
419 else:
420 log.debug(
421 f"[{client.profile}] No compatibility layer found, we use legacy "
422 "bookmarks."
423 )
424 # XXX: We add every bookmark one by one, which is inefficient. The legacy
425 # plugin likely implemented this way because end-users typically add bookmarks
426 # individually. Nowadays, very few servers, if any, still implement XEP-0048
427 # without the XEP-0402 compatibility layer, so it's not worth spending time to
428 # improve the legacy XEP-0048 plugin.
429 for bookmark_jid, conference in bookmarks.items():
430 bookmark_data = {}
431 if conference.autojoin:
432 bookmark_data["autojoin"] = C.BOOL_TRUE
433 for attribute in ("name", "nick", "password"):
434 value = getattr(conference, attribute)
435 if value:
436 bookmark_data[attribute] = value
437 await self._legacy.add_bookmark(
438 self._legacy.MUC_TYPE,
439 bookmark_jid,
440 bookmark_data,
441 storage_type="private",
442 profile_key=client.profile,
443 )