slidge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. slidge/__init__.py +61 -0
  2. slidge/__main__.py +192 -0
  3. slidge/command/__init__.py +28 -0
  4. slidge/command/adhoc.py +258 -0
  5. slidge/command/admin.py +193 -0
  6. slidge/command/base.py +441 -0
  7. slidge/command/categories.py +3 -0
  8. slidge/command/chat_command.py +288 -0
  9. slidge/command/register.py +179 -0
  10. slidge/command/user.py +250 -0
  11. slidge/contact/__init__.py +8 -0
  12. slidge/contact/contact.py +452 -0
  13. slidge/contact/roster.py +192 -0
  14. slidge/core/__init__.py +3 -0
  15. slidge/core/cache.py +183 -0
  16. slidge/core/config.py +209 -0
  17. slidge/core/gateway/__init__.py +3 -0
  18. slidge/core/gateway/base.py +892 -0
  19. slidge/core/gateway/caps.py +63 -0
  20. slidge/core/gateway/delivery_receipt.py +52 -0
  21. slidge/core/gateway/disco.py +80 -0
  22. slidge/core/gateway/mam.py +75 -0
  23. slidge/core/gateway/muc_admin.py +35 -0
  24. slidge/core/gateway/ping.py +66 -0
  25. slidge/core/gateway/presence.py +95 -0
  26. slidge/core/gateway/registration.py +53 -0
  27. slidge/core/gateway/search.py +102 -0
  28. slidge/core/gateway/session_dispatcher.py +757 -0
  29. slidge/core/gateway/vcard_temp.py +130 -0
  30. slidge/core/mixins/__init__.py +19 -0
  31. slidge/core/mixins/attachment.py +506 -0
  32. slidge/core/mixins/avatar.py +167 -0
  33. slidge/core/mixins/base.py +31 -0
  34. slidge/core/mixins/disco.py +130 -0
  35. slidge/core/mixins/lock.py +31 -0
  36. slidge/core/mixins/message.py +398 -0
  37. slidge/core/mixins/message_maker.py +154 -0
  38. slidge/core/mixins/presence.py +217 -0
  39. slidge/core/mixins/recipient.py +43 -0
  40. slidge/core/pubsub.py +525 -0
  41. slidge/core/session.py +752 -0
  42. slidge/group/__init__.py +10 -0
  43. slidge/group/archive.py +125 -0
  44. slidge/group/bookmarks.py +163 -0
  45. slidge/group/participant.py +440 -0
  46. slidge/group/room.py +1095 -0
  47. slidge/migration.py +18 -0
  48. slidge/py.typed +0 -0
  49. slidge/slixfix/__init__.py +68 -0
  50. slidge/slixfix/link_preview/__init__.py +10 -0
  51. slidge/slixfix/link_preview/link_preview.py +17 -0
  52. slidge/slixfix/link_preview/stanza.py +99 -0
  53. slidge/slixfix/roster.py +60 -0
  54. slidge/slixfix/xep_0077/__init__.py +10 -0
  55. slidge/slixfix/xep_0077/register.py +289 -0
  56. slidge/slixfix/xep_0077/stanza.py +104 -0
  57. slidge/slixfix/xep_0100/__init__.py +5 -0
  58. slidge/slixfix/xep_0100/gateway.py +121 -0
  59. slidge/slixfix/xep_0100/stanza.py +9 -0
  60. slidge/slixfix/xep_0153/__init__.py +10 -0
  61. slidge/slixfix/xep_0153/stanza.py +25 -0
  62. slidge/slixfix/xep_0153/vcard_avatar.py +23 -0
  63. slidge/slixfix/xep_0264/__init__.py +5 -0
  64. slidge/slixfix/xep_0264/stanza.py +36 -0
  65. slidge/slixfix/xep_0264/thumbnail.py +23 -0
  66. slidge/slixfix/xep_0292/__init__.py +5 -0
  67. slidge/slixfix/xep_0292/vcard4.py +100 -0
  68. slidge/slixfix/xep_0313/__init__.py +12 -0
  69. slidge/slixfix/xep_0313/mam.py +262 -0
  70. slidge/slixfix/xep_0313/stanza.py +359 -0
  71. slidge/slixfix/xep_0317/__init__.py +5 -0
  72. slidge/slixfix/xep_0317/hats.py +17 -0
  73. slidge/slixfix/xep_0317/stanza.py +28 -0
  74. slidge/slixfix/xep_0356_old/__init__.py +7 -0
  75. slidge/slixfix/xep_0356_old/privilege.py +167 -0
  76. slidge/slixfix/xep_0356_old/stanza.py +44 -0
  77. slidge/slixfix/xep_0424/__init__.py +9 -0
  78. slidge/slixfix/xep_0424/retraction.py +77 -0
  79. slidge/slixfix/xep_0424/stanza.py +28 -0
  80. slidge/slixfix/xep_0490/__init__.py +8 -0
  81. slidge/slixfix/xep_0490/mds.py +47 -0
  82. slidge/slixfix/xep_0490/stanza.py +17 -0
  83. slidge/util/__init__.py +15 -0
  84. slidge/util/archive_msg.py +61 -0
  85. slidge/util/conf.py +206 -0
  86. slidge/util/db.py +229 -0
  87. slidge/util/schema.sql +126 -0
  88. slidge/util/sql.py +508 -0
  89. slidge/util/test.py +295 -0
  90. slidge/util/types.py +180 -0
  91. slidge/util/util.py +295 -0
  92. slidge-0.1.0.dist-info/LICENSE +661 -0
  93. slidge-0.1.0.dist-info/METADATA +109 -0
  94. slidge-0.1.0.dist-info/RECORD +96 -0
  95. slidge-0.1.0.dist-info/WHEEL +4 -0
  96. slidge-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,130 @@
1
+ from copy import copy
2
+ from typing import TYPE_CHECKING
3
+
4
+ from slixmpp import CoroutineCallback, Iq, StanzaPath, register_stanza_plugin
5
+ from slixmpp.exceptions import XMPPError
6
+ from slixmpp.plugins.xep_0084 import MetaData
7
+ from slixmpp.plugins.xep_0292.stanza import NS as VCard4NS
8
+
9
+ from ...contact import LegacyContact
10
+ from ...core.session import BaseSession
11
+ from ...group import LegacyParticipant
12
+
13
+ if TYPE_CHECKING:
14
+ from .base import BaseGateway
15
+
16
+
17
+ class VCardTemp:
18
+ def __init__(self, xmpp: "BaseGateway"):
19
+ self.xmpp = xmpp
20
+ # remove slixmpp's default handler to replace with our own
21
+ self.xmpp.remove_handler("VCardTemp")
22
+ xmpp.register_handler(
23
+ CoroutineCallback(
24
+ "VCardTemp",
25
+ StanzaPath("iq/vcard_temp"),
26
+ self.__handler, # type:ignore
27
+ )
28
+ )
29
+ # TODO: MR to slixmpp adding this to XEP-0084
30
+ register_stanza_plugin(
31
+ self.xmpp.plugin["xep_0060"].stanza.Item,
32
+ MetaData,
33
+ )
34
+
35
+ async def __handler(self, iq: Iq):
36
+ if iq["type"] == "get":
37
+ return await self.__handle_get_vcard_temp(iq)
38
+
39
+ if iq["type"] == "set":
40
+ return await self.__handle_set_vcard_temp(iq)
41
+
42
+ async def __fetch_user_avatar(self, session: BaseSession):
43
+ hash_ = session.avatar_hash
44
+ if not hash_:
45
+ raise XMPPError("item-not-found", "This participant has no contact")
46
+ meta_iq = await self.xmpp.plugin["xep_0060"].get_item(
47
+ session.user.jid,
48
+ MetaData.namespace,
49
+ hash_,
50
+ ifrom=self.xmpp.boundjid.bare,
51
+ )
52
+ info = meta_iq["pubsub"]["items"]["item"]["avatar_metadata"]["info"]
53
+ type_ = info["type"]
54
+ data_iq = await self.xmpp.plugin["xep_0084"].retrieve_avatar(
55
+ session.user.jid, hash_, ifrom=self.xmpp.boundjid.bare
56
+ )
57
+ bytes_ = data_iq["pubsub"]["items"]["item"]["avatar_data"]["value"]
58
+ return bytes_, type_
59
+
60
+ async def __handle_get_vcard_temp(self, iq: Iq):
61
+ session = self.xmpp.get_session_from_stanza(iq)
62
+ entity = await session.get_contact_or_group_or_participant(iq.get_to())
63
+ if not entity:
64
+ raise XMPPError("item-not-found")
65
+
66
+ bytes_ = None
67
+ if isinstance(entity, LegacyParticipant):
68
+ if entity.is_user:
69
+ bytes_, type_ = await self.__fetch_user_avatar(session)
70
+ if not bytes_:
71
+ raise XMPPError(
72
+ "internal-server-error",
73
+ "Could not fetch the slidge user's avatar",
74
+ )
75
+ avatar = None
76
+ vcard = None
77
+ elif not (contact := entity.contact):
78
+ raise XMPPError("item-not-found", "This participant has no contact")
79
+ else:
80
+ vcard = await self.xmpp.vcard.get_vcard(contact.jid, iq.get_from())
81
+ avatar = contact.get_avatar()
82
+ type_ = "image/png"
83
+ else:
84
+ avatar = entity.get_avatar()
85
+ type_ = "image/png"
86
+ if isinstance(entity, LegacyContact):
87
+ vcard = await self.xmpp.vcard.get_vcard(entity.jid, iq.get_from())
88
+ else:
89
+ vcard = None
90
+ v = self.xmpp.plugin["xep_0054"].make_vcard()
91
+ if avatar is not None and avatar.data:
92
+ bytes_ = avatar.data.get_value()
93
+ if bytes_:
94
+ v["PHOTO"]["BINVAL"] = bytes_
95
+ v["PHOTO"]["TYPE"] = type_
96
+ if vcard:
97
+ for el in vcard.xml:
98
+ new = copy(el)
99
+ new.tag = el.tag.replace(f"{{{VCard4NS}}}", "")
100
+ v.append(new)
101
+ reply = iq.reply()
102
+ reply.append(v)
103
+ reply.send()
104
+
105
+ async def __handle_set_vcard_temp(self, iq: Iq):
106
+ muc = await self.xmpp.get_muc_from_stanza(iq)
107
+ to = iq.get_to()
108
+
109
+ if to.resource:
110
+ raise XMPPError("bad-request", "You cannot set participants avatars")
111
+
112
+ data = iq["vcard_temp"]["PHOTO"]["BINVAL"] or None
113
+ try:
114
+ legacy_id = await muc.on_avatar(
115
+ data, iq["vcard_temp"]["PHOTO"]["TYPE"] or None
116
+ )
117
+ except XMPPError:
118
+ raise
119
+ except Exception as e:
120
+ raise XMPPError("internal-server-error", str(e))
121
+ reply = iq.reply(clear=True)
122
+ reply.enable("vcard_temp")
123
+ reply.send()
124
+
125
+ if not data:
126
+ await muc.set_avatar(None, blocking=True)
127
+ return
128
+
129
+ if legacy_id:
130
+ await muc.set_avatar(data, legacy_id, blocking=True)
@@ -0,0 +1,19 @@
1
+ """
2
+ Mixins
3
+ """
4
+
5
+ from .avatar import AvatarMixin
6
+ from .disco import ChatterDiscoMixin
7
+ from .message import MessageCarbonMixin, MessageMixin
8
+ from .presence import PresenceMixin
9
+
10
+
11
+ class FullMixin(ChatterDiscoMixin, MessageMixin, PresenceMixin):
12
+ pass
13
+
14
+
15
+ class FullCarbonMixin(ChatterDiscoMixin, MessageCarbonMixin, PresenceMixin):
16
+ pass
17
+
18
+
19
+ __all__ = ("AvatarMixin",)
@@ -0,0 +1,506 @@
1
+ import functools
2
+ import logging
3
+ import os
4
+ import re
5
+ import shutil
6
+ import stat
7
+ import tempfile
8
+ import warnings
9
+ from datetime import datetime
10
+ from mimetypes import guess_type
11
+ from pathlib import Path
12
+ from typing import IO, Collection, Optional, Sequence, Union
13
+ from urllib.parse import quote as urlquote
14
+ from uuid import uuid4
15
+ from xml.etree import ElementTree as ET
16
+
17
+ import blurhash
18
+ from PIL import Image
19
+ from slixmpp import JID, Message
20
+ from slixmpp.exceptions import IqError
21
+ from slixmpp.plugins.xep_0363 import FileUploadError
22
+ from slixmpp.plugins.xep_0385.stanza import Sims
23
+ from slixmpp.plugins.xep_0447.stanza import StatelessFileSharing
24
+
25
+ from ...util.sql import db
26
+ from ...util.types import (
27
+ LegacyAttachment,
28
+ LegacyMessageType,
29
+ LegacyThreadType,
30
+ MessageReference,
31
+ )
32
+ from ...util.util import fix_suffix
33
+ from .. import config
34
+ from ..cache import avatar_cache
35
+ from .message_maker import MessageMaker
36
+
37
+
38
+ class AttachmentMixin(MessageMaker):
39
+ def send_text(self, *_, **k) -> Optional[Message]:
40
+ raise NotImplementedError
41
+
42
+ async def __upload(
43
+ self,
44
+ file_path: Path,
45
+ file_name: Optional[str] = None,
46
+ content_type: Optional[str] = None,
47
+ ):
48
+ if file_name and file_path.name != file_name:
49
+ d = Path(tempfile.mkdtemp())
50
+ temp = d / file_name
51
+ temp.symlink_to(file_path)
52
+ file_path = temp
53
+ else:
54
+ d = None
55
+ if config.UPLOAD_SERVICE:
56
+ domain = None
57
+ else:
58
+ domain = re.sub(r"^.*?\.", "", self.xmpp.boundjid.bare)
59
+ try:
60
+ new_url = await self.xmpp.plugin["xep_0363"].upload_file(
61
+ filename=file_path,
62
+ content_type=content_type,
63
+ ifrom=config.UPLOAD_REQUESTER or self.xmpp.boundjid,
64
+ domain=JID(domain),
65
+ )
66
+ except (FileUploadError, IqError) as e:
67
+ warnings.warn(f"Something is wrong with the upload service: {e!r}")
68
+ return None
69
+ finally:
70
+ if d is not None:
71
+ file_path.unlink()
72
+ d.rmdir()
73
+
74
+ return new_url
75
+
76
+ @staticmethod
77
+ async def __no_upload(
78
+ file_path: Path,
79
+ file_name: Optional[str] = None,
80
+ legacy_file_id: Optional[Union[str, int]] = None,
81
+ ):
82
+ file_id = str(uuid4()) if legacy_file_id is None else str(legacy_file_id)
83
+ assert config.NO_UPLOAD_PATH is not None
84
+ assert config.NO_UPLOAD_URL_PREFIX is not None
85
+ destination_dir = Path(config.NO_UPLOAD_PATH) / file_id
86
+
87
+ if destination_dir.exists():
88
+ log.debug("Dest dir exists: %s", destination_dir)
89
+ files = list(f for f in destination_dir.glob("**/*") if f.is_file())
90
+ if len(files) == 1:
91
+ log.debug(
92
+ "Found the legacy attachment '%s' at '%s'",
93
+ legacy_file_id,
94
+ files[0],
95
+ )
96
+ name = files[0].name
97
+ uu = files[0].parent.name # anti-obvious url trick, see below
98
+ return files[0], "/".join([file_id, uu, name])
99
+ else:
100
+ log.warning(
101
+ (
102
+ "There are several or zero files in %s, "
103
+ "slidge doesn't know which one to pick among %s. "
104
+ "Removing the dir."
105
+ ),
106
+ destination_dir,
107
+ files,
108
+ )
109
+ shutil.rmtree(destination_dir)
110
+
111
+ log.debug("Did not find a file in: %s", destination_dir)
112
+ # let's use a UUID to avoid URLs being too obvious
113
+ uu = str(uuid4())
114
+ destination_dir = destination_dir / uu
115
+ destination_dir.mkdir(parents=True)
116
+
117
+ name = file_name or file_path.name
118
+ destination = destination_dir / name
119
+ method = config.NO_UPLOAD_METHOD
120
+ if method == "copy":
121
+ shutil.copy2(file_path, destination)
122
+ elif method == "hardlink":
123
+ os.link(file_path, destination)
124
+ elif method == "symlink":
125
+ os.symlink(file_path, destination, target_is_directory=True)
126
+ elif method == "move":
127
+ shutil.move(file_path, destination)
128
+ else:
129
+ raise RuntimeError("No upload method not recognized", method)
130
+
131
+ if config.NO_UPLOAD_FILE_READ_OTHERS:
132
+ log.debug("Changing perms of %s", destination)
133
+ destination.chmod(destination.stat().st_mode | stat.S_IROTH)
134
+ uploaded_url = "/".join([file_id, uu, name])
135
+
136
+ return destination, uploaded_url
137
+
138
+ async def __get_url(
139
+ self,
140
+ file_path: Optional[Path] = None,
141
+ data_stream: Optional[IO[bytes]] = None,
142
+ data: Optional[bytes] = None,
143
+ file_url: Optional[str] = None,
144
+ file_name: Optional[str] = None,
145
+ content_type: Optional[str] = None,
146
+ legacy_file_id: Optional[Union[str, int]] = None,
147
+ ) -> tuple[bool, Optional[Path], str]:
148
+ if legacy_file_id:
149
+ cache = db.attachment_get_url(legacy_file_id)
150
+ if cache is not None:
151
+ async with self.session.http.head(cache) as r:
152
+ if r.status < 400:
153
+ return False, None, cache
154
+ else:
155
+ db.attachment_remove(legacy_file_id)
156
+
157
+ if file_url and config.USE_ATTACHMENT_ORIGINAL_URLS:
158
+ return False, None, file_url
159
+
160
+ if file_name and len(file_name) > config.ATTACHMENT_MAXIMUM_FILE_NAME_LENGTH:
161
+ log.debug("Trimming long filename: %s", file_name)
162
+ base, ext = os.path.splitext(file_name)
163
+ file_name = (
164
+ base[: config.ATTACHMENT_MAXIMUM_FILE_NAME_LENGTH - len(ext)] + ext
165
+ )
166
+
167
+ if file_path is None:
168
+ file_name = str(uuid4()) if file_name is None else file_name
169
+ temp_dir = Path(tempfile.mkdtemp())
170
+ file_path = temp_dir / file_name
171
+ if file_url:
172
+ async with self.session.http.get(file_url) as r:
173
+ with file_path.open("wb") as f:
174
+ f.write(await r.read())
175
+
176
+ else:
177
+ if data_stream is not None:
178
+ data = data_stream.read()
179
+ if data is None:
180
+ raise RuntimeError
181
+
182
+ with file_path.open("wb") as f:
183
+ f.write(data)
184
+
185
+ is_temp = not bool(config.NO_UPLOAD_PATH)
186
+ else:
187
+ is_temp = False
188
+
189
+ if config.FIX_FILENAME_SUFFIX_MIME_TYPE:
190
+ file_name = str(fix_suffix(file_path, content_type, file_name))
191
+
192
+ if config.NO_UPLOAD_PATH:
193
+ local_path, new_url = await self.__no_upload(
194
+ file_path, file_name, legacy_file_id
195
+ )
196
+ new_url = (config.NO_UPLOAD_URL_PREFIX or "") + "/" + urlquote(new_url)
197
+ else:
198
+ local_path = file_path
199
+ new_url = await self.__upload(file_path, file_name, content_type)
200
+ if legacy_file_id:
201
+ db.attachment_store_url(legacy_file_id, new_url)
202
+
203
+ return is_temp, local_path, new_url
204
+
205
+ async def __set_sims(
206
+ self,
207
+ msg: Message,
208
+ uploaded_url: str,
209
+ path: Optional[Path],
210
+ content_type: Optional[str] = None,
211
+ caption: Optional[str] = None,
212
+ file_name: Optional[str] = None,
213
+ ):
214
+ cache = db.attachment_get_sims(uploaded_url)
215
+ if cache:
216
+ msg.append(Sims(xml=ET.fromstring(cache)))
217
+ return
218
+
219
+ if not path:
220
+ return
221
+
222
+ sims = self.xmpp["xep_0385"].get_sims(
223
+ path, [uploaded_url], content_type, caption
224
+ )
225
+ if file_name:
226
+ sims["sims"]["file"]["name"] = file_name
227
+ if content_type is not None and content_type.startswith("image"):
228
+ try:
229
+ h, x, y = await self.xmpp.loop.run_in_executor(
230
+ avatar_cache._thread_pool, get_blurhash, path
231
+ )
232
+ except Exception as e:
233
+ log.debug("Could not generate a blurhash", exc_info=e)
234
+ else:
235
+ thumbnail = sims["sims"]["file"]["thumbnail"]
236
+ thumbnail["width"] = x
237
+ thumbnail["height"] = y
238
+ thumbnail["media-type"] = "image/blurhash"
239
+ thumbnail["uri"] = "data:image/blurhash," + urlquote(h)
240
+
241
+ db.attachment_store_sims(uploaded_url, str(sims))
242
+
243
+ msg.append(sims)
244
+
245
+ def __set_sfs(
246
+ self,
247
+ msg: Message,
248
+ uploaded_url: str,
249
+ path: Optional[Path],
250
+ content_type: Optional[str] = None,
251
+ caption: Optional[str] = None,
252
+ file_name: Optional[str] = None,
253
+ ):
254
+ cache = db.attachment_get_sfs(uploaded_url)
255
+ if cache:
256
+ msg.append(StatelessFileSharing(xml=ET.fromstring(cache)))
257
+ return
258
+
259
+ if not path:
260
+ return
261
+
262
+ sfs = self.xmpp["xep_0447"].get_sfs(path, [uploaded_url], content_type, caption)
263
+ if file_name:
264
+ sfs["file"]["name"] = file_name
265
+ db.attachment_store_sfs(uploaded_url, str(sfs))
266
+
267
+ msg.append(sfs)
268
+
269
+ def __send_url(
270
+ self,
271
+ msg: Message,
272
+ legacy_msg_id: LegacyMessageType,
273
+ uploaded_url: str,
274
+ caption: Optional[str] = None,
275
+ carbon=False,
276
+ when: Optional[datetime] = None,
277
+ **kwargs,
278
+ ) -> list[Message]:
279
+ msg["oob"]["url"] = uploaded_url
280
+ msg["body"] = uploaded_url
281
+ if caption:
282
+ m1 = self._send(msg, carbon=carbon, **kwargs)
283
+ m2 = self.send_text(
284
+ caption, legacy_msg_id=legacy_msg_id, when=when, carbon=carbon, **kwargs
285
+ )
286
+ return [m1, m2] if m2 else [m1]
287
+ else:
288
+ self._set_msg_id(msg, legacy_msg_id)
289
+ return [self._send(msg, carbon=carbon, **kwargs)]
290
+
291
+ async def send_file(
292
+ self,
293
+ file_path: Optional[Union[Path, str]] = None,
294
+ legacy_msg_id: Optional[LegacyMessageType] = None,
295
+ *,
296
+ data_stream: Optional[IO[bytes]] = None,
297
+ data: Optional[bytes] = None,
298
+ file_url: Optional[str] = None,
299
+ file_name: Optional[str] = None,
300
+ content_type: Optional[str] = None,
301
+ reply_to: Optional[MessageReference] = None,
302
+ when: Optional[datetime] = None,
303
+ caption: Optional[str] = None,
304
+ legacy_file_id: Optional[Union[str, int]] = None,
305
+ thread: Optional[LegacyThreadType] = None,
306
+ **kwargs,
307
+ ) -> tuple[Optional[str], list[Message]]:
308
+ """
309
+ Send a single file from this :term:`XMPP Entity`.
310
+
311
+ :param file_path: Path to the attachment
312
+ :param data_stream: Alternatively, a stream of bytes (such as a File object)
313
+ :param data: Alternatively, a bytes object
314
+ :param file_url: Alternatively, a URL
315
+ :param file_name: How the file should be named.
316
+ :param content_type: MIME type, inferred from filename if not given
317
+ :param legacy_msg_id: If you want to be able to transport read markers from the gateway
318
+ user to the legacy network, specify this
319
+ :param reply_to: Quote another message (:xep:`0461`)
320
+ :param when: when the file was sent, for a "delay" tag (:xep:`0203`)
321
+ :param caption: an optional text that is linked to the file
322
+ :param legacy_file_id: A unique identifier for the file on the legacy network.
323
+ Plugins should try their best to provide it, to avoid duplicates.
324
+ :param thread:
325
+ """
326
+ carbon = kwargs.pop("carbon", False)
327
+ mto = kwargs.pop("mto", None)
328
+ store_multi = kwargs.pop("store_multi", True)
329
+ msg = self._make_message(
330
+ when=when,
331
+ reply_to=reply_to,
332
+ carbon=carbon,
333
+ mto=mto,
334
+ thread=thread,
335
+ )
336
+
337
+ if content_type is None and (name := (file_name or file_path or file_url)):
338
+ content_type, _ = guess_type(name)
339
+
340
+ is_temp, local_path, new_url = await self.__get_url(
341
+ Path(file_path) if file_path else None,
342
+ data_stream,
343
+ data,
344
+ file_url,
345
+ file_name,
346
+ content_type,
347
+ legacy_file_id,
348
+ )
349
+
350
+ if new_url is None:
351
+ msg["body"] = (
352
+ "I tried to send a file, but something went wrong. "
353
+ "Tell your slidge admin to check the logs."
354
+ )
355
+ self._set_msg_id(msg, legacy_msg_id)
356
+ return None, [self._send(msg, **kwargs)]
357
+
358
+ await self.__set_sims(
359
+ msg, new_url, local_path, content_type, caption, file_name
360
+ )
361
+ self.__set_sfs(msg, new_url, local_path, content_type, caption, file_name)
362
+ if is_temp and isinstance(local_path, Path):
363
+ local_path.unlink()
364
+ local_path.parent.rmdir()
365
+
366
+ msgs = self.__send_url(
367
+ msg, legacy_msg_id, new_url, caption, carbon, when, **kwargs
368
+ )
369
+ if store_multi:
370
+ self.__store_multi(legacy_msg_id, msgs)
371
+ return new_url, msgs
372
+
373
+ def __send_body(
374
+ self,
375
+ body: Optional[str] = None,
376
+ legacy_msg_id: Optional[LegacyMessageType] = None,
377
+ reply_to: Optional[MessageReference] = None,
378
+ when: Optional[datetime] = None,
379
+ thread: Optional[LegacyThreadType] = None,
380
+ **kwargs,
381
+ ) -> Optional[Message]:
382
+ if body:
383
+ return self.send_text(
384
+ body,
385
+ legacy_msg_id,
386
+ reply_to=reply_to,
387
+ when=when,
388
+ thread=thread,
389
+ **kwargs,
390
+ )
391
+ else:
392
+ return None
393
+
394
+ async def send_files(
395
+ self,
396
+ attachments: Collection[LegacyAttachment],
397
+ legacy_msg_id: Optional[LegacyMessageType] = None,
398
+ body: Optional[str] = None,
399
+ *,
400
+ reply_to: Optional[MessageReference] = None,
401
+ when: Optional[datetime] = None,
402
+ thread: Optional[LegacyThreadType] = None,
403
+ body_first=False,
404
+ correction=False,
405
+ correction_event_id: Optional[LegacyMessageType] = None,
406
+ **kwargs,
407
+ ):
408
+ # TODO: once the epic XEP-0385 vs XEP-0447 battle is over, pick
409
+ # one and stop sending several attachments this way
410
+ # we attach the legacy_message ID to the last message we send, because
411
+ # we don't want several messages with the same ID (especially for MUC MAM)
412
+ # TODO: refactor this so we limit the number of SQL calls, ie, if
413
+ # the legacy file ID is known, only fetch the row once, and if it
414
+ # is new, write it all in a single call
415
+ if not attachments and not body:
416
+ # ignoring empty message
417
+ return
418
+ send_body = functools.partial(
419
+ self.__send_body,
420
+ body=body,
421
+ reply_to=reply_to,
422
+ when=when,
423
+ thread=thread,
424
+ correction=correction,
425
+ legacy_msg_id=legacy_msg_id,
426
+ correction_event_id=correction_event_id,
427
+ **kwargs,
428
+ )
429
+ all_msgs = []
430
+ if body_first:
431
+ all_msgs.append(send_body())
432
+ last_attachment_i = len(attachments) - 1
433
+ for i, attachment in enumerate(attachments):
434
+ last = i == last_attachment_i
435
+ if last and not body:
436
+ legacy = legacy_msg_id
437
+ else:
438
+ legacy = None
439
+ _url, msgs = await self.send_file(
440
+ file_path=attachment.path,
441
+ legacy_msg_id=legacy,
442
+ file_url=attachment.url,
443
+ data_stream=attachment.stream,
444
+ data=attachment.data,
445
+ reply_to=reply_to,
446
+ when=when,
447
+ thread=thread,
448
+ file_name=attachment.name,
449
+ content_type=attachment.content_type,
450
+ legacy_file_id=attachment.legacy_file_id,
451
+ caption=attachment.caption,
452
+ store_multi=False,
453
+ **kwargs,
454
+ )
455
+ all_msgs.extend(msgs)
456
+ if not body_first:
457
+ all_msgs.append(send_body())
458
+ self.__store_multi(legacy_msg_id, all_msgs)
459
+
460
+ def __store_multi(
461
+ self,
462
+ legacy_msg_id: Optional[LegacyMessageType],
463
+ all_msgs: Sequence[Optional[Message]],
464
+ ):
465
+ if legacy_msg_id is None:
466
+ return
467
+ ids = []
468
+ for msg in all_msgs:
469
+ if not msg:
470
+ continue
471
+ if stanza_id := msg.get_plugin("stanza_id", check=True):
472
+ ids.append(stanza_id["id"])
473
+ else:
474
+ ids.append(msg.get_id())
475
+ db.attachment_store_legacy_to_multi_xmpp_msg_ids(legacy_msg_id, ids)
476
+
477
+
478
+ def get_blurhash(path: Path, n=9) -> tuple[str, int, int]:
479
+ img = Image.open(path)
480
+ width, height = img.size
481
+ n = min(width, height, n)
482
+ if width == height:
483
+ x = y = n
484
+ elif width > height:
485
+ x = n
486
+ y = round(n * height / width)
487
+ else:
488
+ x = round(n * width / height)
489
+ y = n
490
+ # There are 2 blurhash-python packages:
491
+ # https://github.com/woltapp/blurhash-python
492
+ # https://github.com/halcy/blurhash-python
493
+ # With this hack we're compatible with both, which is useful for packaging
494
+ # without using pyproject.toml, as most distro do
495
+ try:
496
+ hash_ = blurhash.encode(img, x, y)
497
+ except TypeError:
498
+ # We are using halcy's blurhash which expects
499
+ # the 1st argument to be a 3-dimensional array
500
+ import numpy # type:ignore
501
+
502
+ hash_ = blurhash.encode(numpy.array(img.convert("RGB")), x, y)
503
+ return hash_, width, height
504
+
505
+
506
+ log = logging.getLogger(__name__)