comparison libervia/backend/plugins/plugin_exp_invitation.py @ 4071:4b842c1fb686

refactoring: renamed `sat` package to `libervia.backend`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 11:49:51 +0200
parents sat/plugins/plugin_exp_invitation.py@524856bd7b19
children 0d7bb4df2343
comparison
equal deleted inserted replaced
4070:d10748475025 4071:4b842c1fb686
1 #!/usr/bin/env python3
2
3 # SàT plugin to manage invitations
4 # Copyright (C) 2009-2021 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 Optional
20 from zope.interface import implementer
21 from twisted.internet import defer
22 from twisted.words.protocols.jabber import jid
23 from twisted.words.protocols.jabber.xmlstream import XMPPHandler
24 from wokkel import disco, iwokkel
25 from libervia.backend.core.i18n import _
26 from libervia.backend.core import exceptions
27 from libervia.backend.core.constants import Const as C
28 from libervia.backend.core.log import getLogger
29 from libervia.backend.core.xmpp import SatXMPPEntity
30 from libervia.backend.tools import utils
31
32 log = getLogger(__name__)
33
34
35 PLUGIN_INFO = {
36 C.PI_NAME: "Invitation",
37 C.PI_IMPORT_NAME: "INVITATION",
38 C.PI_TYPE: "EXP",
39 C.PI_PROTOCOLS: [],
40 C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0329", "XEP-0334", "LIST_INTEREST"],
41 C.PI_RECOMMENDATIONS: ["EMAIL_INVITATION"],
42 C.PI_MAIN: "Invitation",
43 C.PI_HANDLER: "yes",
44 C.PI_DESCRIPTION: _("Experimental handling of invitations"),
45 }
46
47 NS_INVITATION = "https://salut-a-toi/protocol/invitation:0"
48 INVITATION = '/message/invitation[@xmlns="{ns_invit}"]'.format(
49 ns_invit=NS_INVITATION
50 )
51 NS_INVITATION_LIST = NS_INVITATION + "#list"
52
53
54 class Invitation(object):
55
56 def __init__(self, host):
57 log.info(_("Invitation plugin initialization"))
58 self.host = host
59 self._p = self.host.plugins["XEP-0060"]
60 self._h = self.host.plugins["XEP-0334"]
61 # map from namespace of the invitation to callback handling it
62 self._ns_cb = {}
63
64 def get_handler(self, client):
65 return PubsubInvitationHandler(self)
66
67 def register_namespace(self, namespace, callback):
68 """Register a callback for a namespace
69
70 @param namespace(unicode): namespace handled
71 @param callback(callbable): method handling the invitation
72 For pubsub invitation, it will be called with following arguments:
73 - client
74 - name(unicode, None): name of the event
75 - extra(dict): extra data
76 - service(jid.JID): pubsub service jid
77 - node(unicode): pubsub node
78 - item_id(unicode, None): pubsub item id
79 - item_elt(domish.Element): item of the invitation
80 For file sharing invitation, it will be called with following arguments:
81 - client
82 - name(unicode, None): name of the repository
83 - extra(dict): extra data
84 - service(jid.JID): service jid of the file repository
85 - repos_type(unicode): type of the repository, can be:
86 - files: generic file sharing
87 - photos: photos album
88 - namespace(unicode, None): namespace of the repository
89 - path(unicode, None): path of the repository
90 @raise exceptions.ConflictError: this namespace is already registered
91 """
92 if namespace in self._ns_cb:
93 raise exceptions.ConflictError(
94 "invitation namespace {namespace} is already register with {callback}"
95 .format(namespace=namespace, callback=self._ns_cb[namespace]))
96 self._ns_cb[namespace] = callback
97
98 def _generate_base_invitation(self, client, invitee_jid, name, extra):
99 """Generate common mess_data end invitation_elt
100
101 @param invitee_jid(jid.JID): entitee to send invitation to
102 @param name(unicode, None): name of the shared repository
103 @param extra(dict, None): extra data, where key can be:
104 - thumb_url: URL of a thumbnail
105 @return (tuple[dict, domish.Element): mess_data and invitation_elt
106 """
107 mess_data = {
108 "from": client.jid,
109 "to": invitee_jid,
110 "uid": "",
111 "message": {},
112 "type": C.MESS_TYPE_CHAT,
113 "subject": {},
114 "extra": {},
115 }
116 client.generate_message_xml(mess_data)
117 self._h.add_hint_elements(mess_data["xml"], [self._h.HINT_STORE])
118 invitation_elt = mess_data["xml"].addElement("invitation", NS_INVITATION)
119 if name is not None:
120 invitation_elt["name"] = name
121 thumb_url = extra.get('thumb_url')
122 if thumb_url:
123 if not thumb_url.startswith('http'):
124 log.warning(
125 "only http URLs are allowed for thumbnails, got {url}, ignoring"
126 .format(url=thumb_url))
127 else:
128 invitation_elt['thumb_url'] = thumb_url
129 return mess_data, invitation_elt
130
131 def send_pubsub_invitation(
132 self,
133 client: SatXMPPEntity,
134 invitee_jid: jid.JID,
135 service: jid.JID,
136 node: str,
137 item_id: Optional[str],
138 name: Optional[str],
139 extra: Optional[dict]
140 ) -> None:
141 """Send an pubsub invitation in a <message> stanza
142
143 @param invitee_jid: entitee to send invitation to
144 @param service: pubsub service
145 @param node: pubsub node
146 @param item_id: pubsub id
147 None when the invitation is for a whole node
148 @param name: see [_generate_base_invitation]
149 @param extra: see [_generate_base_invitation]
150 """
151 if extra is None:
152 extra = {}
153 mess_data, invitation_elt = self._generate_base_invitation(
154 client, invitee_jid, name, extra)
155 pubsub_elt = invitation_elt.addElement("pubsub")
156 pubsub_elt["service"] = service.full()
157 pubsub_elt["node"] = node
158 if item_id is None:
159 try:
160 namespace = extra.pop("namespace")
161 except KeyError:
162 raise exceptions.DataError('"namespace" key is missing in "extra" data')
163 node_data_elt = pubsub_elt.addElement("node_data")
164 node_data_elt["namespace"] = namespace
165 try:
166 node_data_elt.addChild(extra["element"])
167 except KeyError:
168 pass
169 else:
170 pubsub_elt["item"] = item_id
171 if "element" in extra:
172 invitation_elt.addChild(extra.pop("element"))
173 client.send(mess_data["xml"])
174
175 async def send_file_sharing_invitation(
176 self, client, invitee_jid, service, repos_type=None, namespace=None, path=None,
177 name=None, extra=None
178 ):
179 """Send a file sharing invitation in a <message> stanza
180
181 @param invitee_jid(jid.JID): entitee to send invitation to
182 @param service(jid.JID): file sharing service
183 @param repos_type(unicode, None): type of files repository, can be:
184 - None, "files": files sharing
185 - "photos": photos album
186 @param namespace(unicode, None): namespace of the shared repository
187 @param path(unicode, None): path of the shared repository
188 @param name(unicode, None): see [_generate_base_invitation]
189 @param extra(dict, None): see [_generate_base_invitation]
190 """
191 if extra is None:
192 extra = {}
193 li_plg = self.host.plugins["LIST_INTEREST"]
194 li_plg.normalise_file_sharing_service(client, service)
195
196 # FIXME: not the best place to adapt permission, but it's necessary to check them
197 # for UX
198 try:
199 await self.host.plugins['XEP-0329'].affiliationsSet(
200 client, service, namespace, path, {invitee_jid: "member"}
201 )
202 except Exception as e:
203 log.warning(f"Can't set affiliation: {e}")
204
205 if "thumb_url" not in extra:
206 # we have no thumbnail, we check in our own list of interests if there is one
207 try:
208 item_id = li_plg.get_file_sharing_id(service, namespace, path)
209 own_interest = await li_plg.get(client, item_id)
210 except exceptions.NotFound:
211 log.debug(
212 "no thumbnail found for file sharing interest at "
213 f"[{service}/{namespace}]{path}"
214 )
215 else:
216 try:
217 extra['thumb_url'] = own_interest['thumb_url']
218 except KeyError:
219 pass
220
221 mess_data, invitation_elt = self._generate_base_invitation(
222 client, invitee_jid, name, extra)
223 file_sharing_elt = invitation_elt.addElement("file_sharing")
224 file_sharing_elt["service"] = service.full()
225 if repos_type is not None:
226 if repos_type not in ("files", "photos"):
227 msg = "unknown repository type: {repos_type}".format(
228 repos_type=repos_type)
229 log.warning(msg)
230 raise exceptions.DateError(msg)
231 file_sharing_elt["type"] = repos_type
232 if namespace is not None:
233 file_sharing_elt["namespace"] = namespace
234 if path is not None:
235 file_sharing_elt["path"] = path
236 client.send(mess_data["xml"])
237
238 async def _parse_pubsub_elt(self, client, pubsub_elt):
239 try:
240 service = jid.JID(pubsub_elt["service"])
241 node = pubsub_elt["node"]
242 except (RuntimeError, KeyError):
243 raise exceptions.DataError("Bad invitation, ignoring")
244
245 item_id = pubsub_elt.getAttribute("item")
246
247 if item_id is not None:
248 try:
249 items, metadata = await self._p.get_items(
250 client, service, node, item_ids=[item_id]
251 )
252 except Exception as e:
253 log.warning(_("Can't get item linked with invitation: {reason}").format(
254 reason=e))
255 try:
256 item_elt = items[0]
257 except IndexError:
258 log.warning(_("Invitation was linking to a non existing item"))
259 raise exceptions.DataError
260
261 try:
262 namespace = item_elt.firstChildElement().uri
263 except Exception as e:
264 log.warning(_("Can't retrieve namespace of invitation: {reason}").format(
265 reason = e))
266 raise exceptions.DataError
267
268 args = [service, node, item_id, item_elt]
269 else:
270 try:
271 node_data_elt = next(pubsub_elt.elements(NS_INVITATION, "node_data"))
272 except StopIteration:
273 raise exceptions.DataError("Bad invitation, ignoring")
274 namespace = node_data_elt['namespace']
275 args = [service, node, None, node_data_elt]
276
277 return namespace, args
278
279 async def _parse_file_sharing_elt(self, client, file_sharing_elt):
280 try:
281 service = jid.JID(file_sharing_elt["service"])
282 except (RuntimeError, KeyError):
283 log.warning(_("Bad invitation, ignoring"))
284 raise exceptions.DataError
285 repos_type = file_sharing_elt.getAttribute("type", "files")
286 sharing_ns = file_sharing_elt.getAttribute("namespace")
287 path = file_sharing_elt.getAttribute("path")
288 args = [service, repos_type, sharing_ns, path]
289 ns_fis = self.host.get_namespace("fis")
290 return ns_fis, args
291
292 async def on_invitation(self, message_elt, client):
293 log.debug("invitation received [{profile}]".format(profile=client.profile))
294 invitation_elt = message_elt.invitation
295
296 name = invitation_elt.getAttribute("name")
297 extra = {}
298 if invitation_elt.hasAttribute("thumb_url"):
299 extra['thumb_url'] = invitation_elt['thumb_url']
300
301 for elt in invitation_elt.elements():
302 if elt.uri != NS_INVITATION:
303 log.warning("unexpected element: {xml}".format(xml=elt.toXml()))
304 continue
305 if elt.name == "pubsub":
306 method = self._parse_pubsub_elt
307 elif elt.name == "file_sharing":
308 method = self._parse_file_sharing_elt
309 else:
310 log.warning("not implemented invitation element: {xml}".format(
311 xml = elt.toXml()))
312 continue
313 try:
314 namespace, args = await method(client, elt)
315 except exceptions.DataError:
316 log.warning("Can't parse invitation element: {xml}".format(
317 xml = elt.toXml()))
318 continue
319
320 try:
321 cb = self._ns_cb[namespace]
322 except KeyError:
323 log.warning(_(
324 'No handler for namespace "{namespace}", invitation ignored')
325 .format(namespace=namespace))
326 else:
327 await utils.as_deferred(cb, client, namespace, name, extra, *args)
328
329
330 @implementer(iwokkel.IDisco)
331 class PubsubInvitationHandler(XMPPHandler):
332
333 def __init__(self, plugin_parent):
334 self.plugin_parent = plugin_parent
335
336 def connectionInitialized(self):
337 self.xmlstream.addObserver(
338 INVITATION,
339 lambda message_elt: defer.ensureDeferred(
340 self.plugin_parent.on_invitation(message_elt, client=self.parent)
341 ),
342 )
343
344 def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
345 return [
346 disco.DiscoFeature(NS_INVITATION),
347 ]
348
349 def getDiscoItems(self, requestor, target, nodeIdentifier=""):
350 return []