Mercurial > libervia-backend
comparison sat/plugins/plugin_xep_0084.py @ 3820:88e332cec47b
plugin XEP-0084: "User Avatar" implementation:
rel 368
author | Goffi <goffi@goffi.org> |
---|---|
date | Wed, 29 Jun 2022 11:59:07 +0200 |
parents | |
children | 524856bd7b19 |
comparison
equal
deleted
inserted
replaced
3819:4f02e339d184 | 3820:88e332cec47b |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # Libervia plugin for XEP-0084 | |
4 # Copyright (C) 2009-2022 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, Dict, Any | |
20 from pathlib import Path | |
21 from base64 import b64decode, b64encode | |
22 | |
23 from twisted.internet import defer | |
24 from twisted.words.protocols.jabber.xmlstream import XMPPHandler | |
25 from twisted.words.protocols.jabber import jid, error | |
26 from twisted.words.xish import domish | |
27 from zope.interface import implementer | |
28 from wokkel import disco, iwokkel, pubsub | |
29 | |
30 from sat.core.constants import Const as C | |
31 from sat.core.i18n import _ | |
32 from sat.core.log import getLogger | |
33 from sat.core.core_types import SatXMPPEntity | |
34 from sat.core import exceptions | |
35 | |
36 | |
37 log = getLogger(__name__) | |
38 | |
39 IMPORT_NAME = "XEP-0084" | |
40 | |
41 PLUGIN_INFO = { | |
42 C.PI_NAME: "User Avatar", | |
43 C.PI_IMPORT_NAME: IMPORT_NAME, | |
44 C.PI_TYPE: C.PLUG_TYPE_XEP, | |
45 C.PI_MODES: C.PLUG_MODE_BOTH, | |
46 C.PI_PROTOCOLS: ["XEP-0084"], | |
47 C.PI_DEPENDENCIES: ["IDENTITY", "XEP-0060", "XEP-0163"], | |
48 C.PI_MAIN: "XEP_0084", | |
49 C.PI_HANDLER: "yes", | |
50 C.PI_DESCRIPTION: _("""XEP-0084 (User Avatar) implementation"""), | |
51 } | |
52 | |
53 NS_AVATAR = "urn:xmpp:avatar" | |
54 NS_AVATAR_METADATA = f"{NS_AVATAR}:metadata" | |
55 NS_AVATAR_DATA = f"{NS_AVATAR}:data" | |
56 | |
57 | |
58 class XEP_0084: | |
59 namespace_metadata = NS_AVATAR_METADATA | |
60 namespace_data = NS_AVATAR_DATA | |
61 | |
62 def __init__(self, host): | |
63 log.info(_("XEP-0084 (User Avatar) plugin initialization")) | |
64 host.registerNamespace("avatar_metadata", NS_AVATAR_METADATA) | |
65 host.registerNamespace("avatar_data", NS_AVATAR_DATA) | |
66 self.host = host | |
67 self._p = host.plugins["XEP-0060"] | |
68 self._i = host.plugins['IDENTITY'] | |
69 self._i.register( | |
70 IMPORT_NAME, | |
71 "avatar", | |
72 self.getAvatar, | |
73 self.setAvatar, | |
74 priority=2000 | |
75 ) | |
76 host.plugins["XEP-0163"].addPEPEvent( | |
77 None, NS_AVATAR_METADATA, self._onMetadataUpdate | |
78 ) | |
79 | |
80 def getHandler(self, client): | |
81 return XEP_0084_Handler() | |
82 | |
83 def _onMetadataUpdate(self, itemsEvent, profile): | |
84 client = self.host.getClient(profile) | |
85 defer.ensureDeferred(self.onMetadataUpdate(client, itemsEvent)) | |
86 | |
87 async def onMetadataUpdate( | |
88 self, | |
89 client: SatXMPPEntity, | |
90 itemsEvent: pubsub.ItemsEvent | |
91 ) -> None: | |
92 entity = client.jid.userhostJID() | |
93 avatar_metadata = await self.getAvatar(client, entity) | |
94 await self._i.update(client, IMPORT_NAME, "avatar", avatar_metadata, entity) | |
95 | |
96 async def getAvatar( | |
97 self, | |
98 client: SatXMPPEntity, | |
99 entity_jid: jid.JID | |
100 ) -> Optional[dict]: | |
101 """Get avatar data | |
102 | |
103 @param entity: entity to get avatar from | |
104 @return: avatar metadata, or None if no avatar has been found | |
105 """ | |
106 service = entity_jid.userhostJID() | |
107 # metadata | |
108 try: | |
109 items, __ = await self._p.getItems( | |
110 client, | |
111 service, | |
112 NS_AVATAR_METADATA, | |
113 max_items=1 | |
114 ) | |
115 except exceptions.NotFound: | |
116 return None | |
117 | |
118 if not items: | |
119 return None | |
120 | |
121 item_elt = items[0] | |
122 try: | |
123 metadata_elt = next(item_elt.elements(NS_AVATAR_METADATA, "metadata")) | |
124 except StopIteration: | |
125 log.warning(f"missing metadata element: {item_elt.toXml()}") | |
126 return None | |
127 | |
128 for info_elt in metadata_elt.elements(NS_AVATAR_METADATA, "info"): | |
129 try: | |
130 metadata = { | |
131 "id": str(info_elt["id"]), | |
132 "size": int(info_elt["bytes"]), | |
133 "media_type": str(info_elt["type"]) | |
134 } | |
135 avatar_id = metadata["id"] | |
136 if not avatar_id: | |
137 raise ValueError | |
138 except (KeyError, ValueError): | |
139 log.warning(f"invalid <info> element: {item_elt.toXml()}") | |
140 return None | |
141 # FIXME: to simplify, we only handle image/png for now | |
142 if metadata["media_type"] == "image/png": | |
143 break | |
144 else: | |
145 # mandatory image/png is missing, or avatar is disabled | |
146 # (https://xmpp.org/extensions/xep-0084.html#pub-disable) | |
147 return None | |
148 | |
149 cache_data = self.host.common_cache.getMetadata(avatar_id) | |
150 if not cache_data: | |
151 try: | |
152 data_items, __ = await self._p.getItems( | |
153 client, | |
154 service, | |
155 NS_AVATAR_DATA, | |
156 item_ids=[avatar_id] | |
157 ) | |
158 data_item_elt = data_items[0] | |
159 except (error.StanzaError, IndexError) as e: | |
160 log.warning( | |
161 f"Can't retrieve avatar of {service.full()} with ID {avatar_id!r}: " | |
162 f"{e}" | |
163 ) | |
164 return None | |
165 try: | |
166 avatar_buf = b64decode( | |
167 str(next(data_item_elt.elements(NS_AVATAR_DATA, "data"))) | |
168 ) | |
169 except Exception as e: | |
170 log.warning( | |
171 f"invalid data element for {service.full()} with avatar ID " | |
172 f"{avatar_id!r}: {e}\n{data_item_elt.toXml()}" | |
173 ) | |
174 return None | |
175 with self.host.common_cache.cacheData( | |
176 IMPORT_NAME, | |
177 avatar_id, | |
178 metadata["media_type"] | |
179 ) as f: | |
180 f.write(avatar_buf) | |
181 cache_data = { | |
182 "path": Path(f.name), | |
183 "mime_type": metadata["media_type"] | |
184 } | |
185 | |
186 return self._i.avatarBuildMetadata( | |
187 cache_data['path'], cache_data['mime_type'], avatar_id | |
188 ) | |
189 | |
190 def buildItemDataElt(self, avatar_data: Dict[str, Any]) -> domish.Element: | |
191 """Generate the item for the data node | |
192 | |
193 @param avatar_data: data as build by identity plugin (need to be filled with | |
194 "cache_uid" and "base64" keys) | |
195 """ | |
196 data_elt = domish.Element((NS_AVATAR_DATA, "data")) | |
197 data_elt.addContent(avatar_data["base64"]) | |
198 return pubsub.Item(id=avatar_data["cache_uid"], payload=data_elt) | |
199 | |
200 def buildItemMetadataElt(self, avatar_data: Dict[str, Any]) -> domish.Element: | |
201 """Generate the item for the metadata node | |
202 | |
203 @param avatar_data: data as build by identity plugin (need to be filled with | |
204 "cache_uid", "path", and "media_type" keys) | |
205 """ | |
206 metadata_elt = domish.Element((NS_AVATAR_METADATA, "metadata")) | |
207 info_elt = metadata_elt.addElement("info") | |
208 # FIXME: we only fill required elements for now (see | |
209 # https://xmpp.org/extensions/xep-0084.html#table-1) | |
210 info_elt["id"] = avatar_data["cache_uid"] | |
211 info_elt["type"] = avatar_data["media_type"] | |
212 info_elt["bytes"] = str(avatar_data["path"].stat().st_size) | |
213 return pubsub.Item(id=self._p.ID_SINGLETON, payload=metadata_elt) | |
214 | |
215 async def setAvatar( | |
216 self, | |
217 client: SatXMPPEntity, | |
218 avatar_data: Dict[str, Any], | |
219 entity: jid.JID | |
220 ) -> None: | |
221 """Set avatar of the profile | |
222 | |
223 @param avatar_data(dict): data of the image to use as avatar, as built by | |
224 IDENTITY plugin. | |
225 @param entity(jid.JID): entity whose avatar must be changed | |
226 """ | |
227 service = entity.userhostJID() | |
228 | |
229 # Data | |
230 await self._p.createIfNewNode( | |
231 client, | |
232 service, | |
233 NS_AVATAR_DATA, | |
234 options={ | |
235 self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN, | |
236 self._p.OPT_PERSIST_ITEMS: 1, | |
237 self._p.OPT_MAX_ITEMS: 1, | |
238 } | |
239 ) | |
240 item_data_elt = self.buildItemDataElt(avatar_data) | |
241 await self._p.sendItems(client, service, NS_AVATAR_DATA, [item_data_elt]) | |
242 | |
243 # Metadata | |
244 await self._p.createIfNewNode( | |
245 client, | |
246 service, | |
247 NS_AVATAR_METADATA, | |
248 options={ | |
249 self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN, | |
250 self._p.OPT_PERSIST_ITEMS: 1, | |
251 self._p.OPT_MAX_ITEMS: 1, | |
252 } | |
253 ) | |
254 item_metadata_elt = self.buildItemMetadataElt(avatar_data) | |
255 await self._p.sendItems(client, service, NS_AVATAR_METADATA, [item_metadata_elt]) | |
256 | |
257 | |
258 @implementer(iwokkel.IDisco) | |
259 class XEP_0084_Handler(XMPPHandler): | |
260 | |
261 def getDiscoInfo(self, requestor, service, nodeIdentifier=""): | |
262 return [ | |
263 disco.DiscoFeature(NS_AVATAR_METADATA), | |
264 disco.DiscoFeature(NS_AVATAR_DATA) | |
265 ] | |
266 | |
267 def getDiscoItems(self, requestor, service, nodeIdentifier=""): | |
268 return [] |