Mercurial > libervia-backend
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 ) |