slidge 0.1.0rc1__py3-none-any.whl → 0.1.2__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (164) hide show
  1. slidge/__init__.py +54 -31
  2. slidge/__main__.py +51 -5
  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 +2 -0
  15. slidge/core/cache.py +121 -39
  16. slidge/core/config.py +116 -11
  17. slidge/core/gateway/__init__.py +3 -0
  18. slidge/core/gateway/base.py +895 -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 +795 -0
  29. slidge/core/gateway/vcard_temp.py +130 -0
  30. slidge/core/mixins/__init__.py +9 -1
  31. slidge/core/mixins/attachment.py +506 -0
  32. slidge/core/mixins/avatar.py +167 -0
  33. slidge/core/mixins/base.py +6 -19
  34. slidge/core/mixins/disco.py +66 -15
  35. slidge/core/mixins/lock.py +31 -0
  36. slidge/core/mixins/message.py +254 -252
  37. slidge/core/mixins/message_maker.py +154 -0
  38. slidge/core/mixins/presence.py +128 -31
  39. slidge/core/mixins/recipient.py +43 -0
  40. slidge/core/pubsub.py +275 -116
  41. slidge/core/session.py +586 -518
  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 +458 -0
  46. slidge/group/room.py +1103 -0
  47. slidge/migration.py +18 -0
  48. slidge/slixfix/__init__.py +68 -0
  49. slidge/{util/xep_0050 → slixfix/link_preview}/__init__.py +4 -5
  50. slidge/slixfix/link_preview/link_preview.py +17 -0
  51. slidge/slixfix/link_preview/stanza.py +99 -0
  52. slidge/slixfix/roster.py +60 -0
  53. slidge/{util → slixfix}/xep_0077/register.py +1 -2
  54. slidge/slixfix/xep_0077/stanza.py +104 -0
  55. slidge/{util → slixfix}/xep_0100/gateway.py +17 -12
  56. slidge/slixfix/xep_0153/__init__.py +10 -0
  57. slidge/slixfix/xep_0153/stanza.py +25 -0
  58. slidge/slixfix/xep_0153/vcard_avatar.py +23 -0
  59. slidge/slixfix/xep_0264/__init__.py +5 -0
  60. slidge/slixfix/xep_0264/stanza.py +36 -0
  61. slidge/slixfix/xep_0264/thumbnail.py +23 -0
  62. slidge/slixfix/xep_0292/__init__.py +5 -0
  63. slidge/slixfix/xep_0292/vcard4.py +100 -0
  64. slidge/slixfix/xep_0313/__init__.py +12 -0
  65. slidge/slixfix/xep_0313/mam.py +262 -0
  66. slidge/slixfix/xep_0313/stanza.py +359 -0
  67. slidge/slixfix/xep_0317/__init__.py +5 -0
  68. slidge/slixfix/xep_0317/hats.py +17 -0
  69. slidge/slixfix/xep_0317/stanza.py +28 -0
  70. slidge/{util → slixfix}/xep_0356_old/privilege.py +9 -7
  71. slidge/slixfix/xep_0424/__init__.py +9 -0
  72. slidge/slixfix/xep_0424/retraction.py +77 -0
  73. slidge/slixfix/xep_0424/stanza.py +28 -0
  74. slidge/slixfix/xep_0490/__init__.py +8 -0
  75. slidge/slixfix/xep_0490/mds.py +47 -0
  76. slidge/slixfix/xep_0490/stanza.py +17 -0
  77. slidge/util/__init__.py +4 -6
  78. slidge/util/archive_msg.py +61 -0
  79. slidge/util/conf.py +25 -4
  80. slidge/util/db.py +23 -69
  81. slidge/util/schema.sql +126 -0
  82. slidge/util/sql.py +508 -0
  83. slidge/util/test.py +136 -86
  84. slidge/util/types.py +155 -14
  85. slidge/util/util.py +225 -51
  86. slidge-0.1.2.dist-info/METADATA +111 -0
  87. slidge-0.1.2.dist-info/RECORD +96 -0
  88. {slidge-0.1.0rc1.dist-info → slidge-0.1.2.dist-info}/WHEEL +1 -1
  89. slidge/core/adhoc.py +0 -492
  90. slidge/core/chat_command.py +0 -197
  91. slidge/core/contact.py +0 -441
  92. slidge/core/disco.py +0 -59
  93. slidge/core/gateway.py +0 -899
  94. slidge/core/muc/__init__.py +0 -3
  95. slidge/core/muc/bookmarks.py +0 -74
  96. slidge/core/muc/participant.py +0 -152
  97. slidge/core/muc/room.py +0 -348
  98. slidge/plugins/discord/__init__.py +0 -121
  99. slidge/plugins/discord/client.py +0 -121
  100. slidge/plugins/discord/session.py +0 -172
  101. slidge/plugins/dummy.py +0 -334
  102. slidge/plugins/facebook.py +0 -591
  103. slidge/plugins/hackernews.py +0 -209
  104. slidge/plugins/mattermost/__init__.py +0 -1
  105. slidge/plugins/mattermost/api.py +0 -288
  106. slidge/plugins/mattermost/gateway.py +0 -417
  107. slidge/plugins/mattermost/websocket.py +0 -248
  108. slidge/plugins/signal/__init__.py +0 -4
  109. slidge/plugins/signal/config.py +0 -4
  110. slidge/plugins/signal/contact.py +0 -104
  111. slidge/plugins/signal/gateway.py +0 -379
  112. slidge/plugins/signal/group.py +0 -76
  113. slidge/plugins/signal/session.py +0 -515
  114. slidge/plugins/signal/txt.py +0 -13
  115. slidge/plugins/signal/util.py +0 -32
  116. slidge/plugins/skype.py +0 -310
  117. slidge/plugins/steam.py +0 -400
  118. slidge/plugins/telegram/__init__.py +0 -6
  119. slidge/plugins/telegram/client.py +0 -325
  120. slidge/plugins/telegram/config.py +0 -21
  121. slidge/plugins/telegram/contact.py +0 -154
  122. slidge/plugins/telegram/gateway.py +0 -182
  123. slidge/plugins/telegram/group.py +0 -184
  124. slidge/plugins/telegram/session.py +0 -275
  125. slidge/plugins/telegram/util.py +0 -153
  126. slidge/plugins/whatsapp/__init__.py +0 -6
  127. slidge/plugins/whatsapp/config.py +0 -17
  128. slidge/plugins/whatsapp/contact.py +0 -33
  129. slidge/plugins/whatsapp/event.go +0 -455
  130. slidge/plugins/whatsapp/gateway.go +0 -156
  131. slidge/plugins/whatsapp/gateway.py +0 -69
  132. slidge/plugins/whatsapp/go.mod +0 -17
  133. slidge/plugins/whatsapp/go.sum +0 -22
  134. slidge/plugins/whatsapp/session.go +0 -371
  135. slidge/plugins/whatsapp/session.py +0 -370
  136. slidge/util/xep_0030/__init__.py +0 -13
  137. slidge/util/xep_0030/disco.py +0 -811
  138. slidge/util/xep_0030/stanza/__init__.py +0 -7
  139. slidge/util/xep_0030/stanza/info.py +0 -270
  140. slidge/util/xep_0030/stanza/items.py +0 -147
  141. slidge/util/xep_0030/static.py +0 -467
  142. slidge/util/xep_0050/adhoc.py +0 -631
  143. slidge/util/xep_0050/stanza.py +0 -180
  144. slidge/util/xep_0077/stanza.py +0 -71
  145. slidge/util/xep_0292/__init__.py +0 -1
  146. slidge/util/xep_0292/stanza.py +0 -167
  147. slidge/util/xep_0292/vcard4.py +0 -74
  148. slidge/util/xep_0356/__init__.py +0 -7
  149. slidge/util/xep_0356/permissions.py +0 -35
  150. slidge/util/xep_0356/privilege.py +0 -160
  151. slidge/util/xep_0356/stanza.py +0 -44
  152. slidge/util/xep_0461/__init__.py +0 -6
  153. slidge/util/xep_0461/reply.py +0 -48
  154. slidge/util/xep_0461/stanza.py +0 -80
  155. slidge-0.1.0rc1.dist-info/METADATA +0 -171
  156. slidge-0.1.0rc1.dist-info/RECORD +0 -99
  157. /slidge/{plugins/__init__.py → py.typed} +0 -0
  158. /slidge/{util → slixfix}/xep_0077/__init__.py +0 -0
  159. /slidge/{util → slixfix}/xep_0100/__init__.py +0 -0
  160. /slidge/{util → slixfix}/xep_0100/stanza.py +0 -0
  161. /slidge/{util → slixfix}/xep_0356_old/__init__.py +0 -0
  162. /slidge/{util → slixfix}/xep_0356_old/stanza.py +0 -0
  163. {slidge-0.1.0rc1.dist-info → slidge-0.1.2.dist-info}/LICENSE +0 -0
  164. {slidge-0.1.0rc1.dist-info → slidge-0.1.2.dist-info}/entry_points.txt +0 -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)
@@ -1,4 +1,9 @@
1
- from .disco import BaseDiscoMixin, ChatterDiscoMixin
1
+ """
2
+ Mixins
3
+ """
4
+
5
+ from .avatar import AvatarMixin
6
+ from .disco import ChatterDiscoMixin
2
7
  from .message import MessageCarbonMixin, MessageMixin
3
8
  from .presence import PresenceMixin
4
9
 
@@ -9,3 +14,6 @@ class FullMixin(ChatterDiscoMixin, MessageMixin, PresenceMixin):
9
14
 
10
15
  class FullCarbonMixin(ChatterDiscoMixin, MessageCarbonMixin, PresenceMixin):
11
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__)