Implemented proper support for encrypted media and added download method

This commit is contained in:
Fabio Manganiello 2022-08-26 23:48:29 +02:00
parent e4eb4cd7dc
commit 48ec6ef68b
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
4 changed files with 330 additions and 67 deletions

View file

@ -66,28 +66,58 @@ class MatrixMessageEvent(MatrixEvent):
Event triggered when a message is received on a subscribed room. Event triggered when a message is received on a subscribed room.
""" """
def __init__(self, *args, body: str, **kwargs): def __init__(
self,
*args,
body: str = '',
url: str | None = None,
thumbnail_url: str | None = None,
mimetype: str | None = None,
formatted_body: str | None = None,
format: str | None = None,
**kwargs
):
""" """
:param body: The body of the message. :param body: The body of the message.
:param url: The URL of the media file, if the message includes media.
:param thumbnail_url: The URL of the thumbnail, if the message includes media.
:param mimetype: The MIME type of the media file, if the message includes media.
:param formatted_body: The formatted body, if ``format`` is specified.
:param format: The format of the message (e.g. ``html`` or ``markdown``).
""" """
super().__init__(*args, body=body, **kwargs) super().__init__(
*args,
body=body,
url=url,
thumbnail_url=thumbnail_url,
mimetype=mimetype,
formatted_body=formatted_body,
format=format,
**kwargs
)
class MatrixMediaMessageEvent(MatrixMessageEvent): class MatrixMessageImageEvent(MatrixEvent):
""" """
Event triggered when a media message is received on a subscribed room. Event triggered when a message containing an image is received.
""" """
def __init__(self, *args, url: str, **kwargs):
"""
:param url: The URL of the media file.
"""
super().__init__(*args, url=url, **kwargs)
class MatrixMessageFileEvent(MatrixEvent):
class MatrixStickerEvent(MatrixMediaMessageEvent):
""" """
Event triggered when a sticker is sent to a room. Event triggered when a message containing a generic file is received.
"""
class MatrixMessageAudioEvent(MatrixEvent):
"""
Event triggered when a message containing an audio file is received.
"""
class MatrixMessageVideoEvent(MatrixEvent):
"""
Event triggered when a message containing a video file is received.
""" """

View file

@ -5,21 +5,23 @@ import logging
import os import os
import pathlib import pathlib
import re import re
import threading
from dataclasses import dataclass from dataclasses import dataclass
from typing import Collection, Coroutine, Dict from typing import Collection, Coroutine, Dict
from urllib.parse import urlparse
from async_lru import alru_cache from async_lru import alru_cache
from nio import ( from nio import (
Api,
AsyncClient, AsyncClient,
AsyncClientConfig, AsyncClientConfig,
CallAnswerEvent, CallAnswerEvent,
CallHangupEvent, CallHangupEvent,
CallInviteEvent, CallInviteEvent,
DevicesError, ErrorResponse,
Event, Event,
InviteNameEvent, InviteNameEvent,
JoinedRoomsError,
KeyVerificationStart, KeyVerificationStart,
KeyVerificationAccept, KeyVerificationAccept,
KeyVerificationMac, KeyVerificationMac,
@ -31,12 +33,21 @@ from nio import (
MegolmEvent, MegolmEvent,
ProfileGetResponse, ProfileGetResponse,
RoomCreateEvent, RoomCreateEvent,
RoomEncryptedAudio,
RoomEncryptedFile,
RoomEncryptedImage,
RoomEncryptedMedia,
RoomEncryptedVideo,
RoomGetEventError, RoomGetEventError,
RoomGetStateError,
RoomGetStateResponse, RoomGetStateResponse,
RoomMemberEvent, RoomMemberEvent,
RoomMessageAudio,
RoomMessageFile,
RoomMessageFormatted,
RoomMessageText, RoomMessageText,
RoomMessageImage,
RoomMessageMedia, RoomMessageMedia,
RoomMessageVideo,
RoomTopicEvent, RoomTopicEvent,
RoomUpgradeEvent, RoomUpgradeEvent,
StickerEvent, StickerEvent,
@ -46,8 +57,10 @@ from nio import (
) )
from nio.client.async_client import client_session from nio.client.async_client import client_session
from nio.crypto import decrypt_attachment
from nio.crypto.device import OlmDevice from nio.crypto.device import OlmDevice
from nio.exceptions import OlmUnverifiedDeviceError from nio.exceptions import OlmUnverifiedDeviceError
from nio.responses import DownloadResponse
from platypush.config import Config from platypush.config import Config
from platypush.context import get_bus from platypush.context import get_bus
@ -56,21 +69,24 @@ from platypush.message.event.matrix import (
MatrixCallHangupEvent, MatrixCallHangupEvent,
MatrixCallInviteEvent, MatrixCallInviteEvent,
MatrixEncryptedMessageEvent, MatrixEncryptedMessageEvent,
MatrixMediaMessageEvent, MatrixMessageAudioEvent,
MatrixMessageEvent, MatrixMessageEvent,
MatrixMessageFileEvent,
MatrixMessageImageEvent,
MatrixMessageVideoEvent,
MatrixReactionEvent, MatrixReactionEvent,
MatrixRoomCreatedEvent, MatrixRoomCreatedEvent,
MatrixRoomInviteEvent, MatrixRoomInviteEvent,
MatrixRoomJoinEvent, MatrixRoomJoinEvent,
MatrixRoomLeaveEvent, MatrixRoomLeaveEvent,
MatrixRoomTopicChangedEvent, MatrixRoomTopicChangedEvent,
MatrixStickerEvent,
MatrixSyncEvent, MatrixSyncEvent,
) )
from platypush.plugins import AsyncRunnablePlugin, action from platypush.plugins import AsyncRunnablePlugin, action
from platypush.schemas.matrix import ( from platypush.schemas.matrix import (
MatrixDeviceSchema, MatrixDeviceSchema,
MatrixDownloadedFileSchema,
MatrixEventIdSchema, MatrixEventIdSchema,
MatrixMyDeviceSchema, MatrixMyDeviceSchema,
MatrixProfileSchema, MatrixProfileSchema,
@ -114,9 +130,11 @@ class MatrixClient(AsyncClient):
if not store_path: if not store_path:
store_path = os.path.join(Config.get('workdir'), 'matrix', 'store') # type: ignore store_path = os.path.join(Config.get('workdir'), 'matrix', 'store') # type: ignore
if store_path:
store_path = os.path.abspath(os.path.expanduser(store_path)) assert store_path
pathlib.Path(store_path).mkdir(exist_ok=True, parents=True) store_path = os.path.abspath(os.path.expanduser(store_path))
pathlib.Path(store_path).mkdir(exist_ok=True, parents=True)
if not config: if not config:
config = AsyncClientConfig( config = AsyncClientConfig(
max_limit_exceeded=0, max_limit_exceeded=0,
@ -135,6 +153,27 @@ class MatrixClient(AsyncClient):
self._autotrust_users_whitelist = autotrust_users_whitelist or set() self._autotrust_users_whitelist = autotrust_users_whitelist or set()
self._first_sync_performed = asyncio.Event() self._first_sync_performed = asyncio.Event()
self._encrypted_attachments_keystore_path = os.path.join(
store_path, 'attachment_keys.json'
)
self._encrypted_attachments_keystore = {}
self._sync_store_timer: threading.Timer | None = None
keystore = {}
try:
with open(self._encrypted_attachments_keystore_path, 'r') as f:
keystore = json.load(f)
except (ValueError, OSError):
with open(self._encrypted_attachments_keystore_path, 'w') as f:
f.write(json.dumps({}))
pathlib.Path(self._encrypted_attachments_keystore_path).touch(
mode=0o600, exist_ok=True
)
self._encrypted_attachments_keystore = {
tuple(key.split('|')): data for key, data in keystore.items()
}
async def _autojoin_room_callback(self, room: MatrixRoom, *_): async def _autojoin_room_callback(self, room: MatrixRoom, *_):
await self.join(room.room_id) # type: ignore await self.join(room.room_id) # type: ignore
@ -217,12 +256,12 @@ class MatrixClient(AsyncClient):
self.logger.info('Synchronizing state') self.logger.info('Synchronizing state')
self._first_sync_performed.clear() self._first_sync_performed.clear()
self._add_callbacks()
sync_token = self.loaded_sync_token sync_token = self.loaded_sync_token
self.loaded_sync_token = '' self.loaded_sync_token = ''
await self.sync(sync_filter={'room': {'timeline': {'limit': 1}}}) await self.sync(sync_filter={'room': {'timeline': {'limit': 1}}})
self.loaded_sync_token = sync_token self.loaded_sync_token = sync_token
self._add_callbacks()
self._sync_devices_trust() self._sync_devices_trust()
self._first_sync_performed.set() self._first_sync_performed.set()
@ -316,14 +355,15 @@ class MatrixClient(AsyncClient):
def _add_callbacks(self): def _add_callbacks(self):
self.add_event_callback(self._event_catch_all, Event) self.add_event_callback(self._event_catch_all, Event)
self.add_event_callback(self._on_invite, InviteNameEvent) # type: ignore self.add_event_callback(self._on_invite, InviteNameEvent) # type: ignore
self.add_event_callback(self._on_room_message, RoomMessageText) # type: ignore self.add_event_callback(self._on_message, RoomMessageText) # type: ignore
self.add_event_callback(self._on_media_message, RoomMessageMedia) # type: ignore self.add_event_callback(self._on_message, RoomMessageMedia) # type: ignore
self.add_event_callback(self._on_message, RoomEncryptedMedia) # type: ignore
self.add_event_callback(self._on_message, StickerEvent) # type: ignore
self.add_event_callback(self._on_room_member, RoomMemberEvent) # type: ignore self.add_event_callback(self._on_room_member, RoomMemberEvent) # type: ignore
self.add_event_callback(self._on_room_topic_changed, RoomTopicEvent) # type: ignore self.add_event_callback(self._on_room_topic_changed, RoomTopicEvent) # type: ignore
self.add_event_callback(self._on_call_invite, CallInviteEvent) # type: ignore self.add_event_callback(self._on_call_invite, CallInviteEvent) # type: ignore
self.add_event_callback(self._on_call_answer, CallAnswerEvent) # type: ignore self.add_event_callback(self._on_call_answer, CallAnswerEvent) # type: ignore
self.add_event_callback(self._on_call_hangup, CallHangupEvent) # type: ignore self.add_event_callback(self._on_call_hangup, CallHangupEvent) # type: ignore
self.add_event_callback(self._on_sticker_message, StickerEvent) # type: ignore
self.add_event_callback(self._on_unknown_event, UnknownEvent) # type: ignore self.add_event_callback(self._on_unknown_event, UnknownEvent) # type: ignore
self.add_event_callback(self._on_unknown_encrypted_event, UnknownEncryptedEvent) # type: ignore self.add_event_callback(self._on_unknown_encrypted_event, UnknownEncryptedEvent) # type: ignore
self.add_event_callback(self._on_unknown_encrypted_event, MegolmEvent) # type: ignore self.add_event_callback(self._on_unknown_encrypted_event, MegolmEvent) # type: ignore
@ -336,6 +376,24 @@ class MatrixClient(AsyncClient):
if self._autojoin_on_invite: if self._autojoin_on_invite:
self.add_event_callback(self._autojoin_room_callback, InviteNameEvent) # type: ignore self.add_event_callback(self._autojoin_room_callback, InviteNameEvent) # type: ignore
def _sync_store(self):
self.logger.info('Synchronizing keystore')
serialized_keystore = json.dumps(
{
f'{server}|{media_id}': data
for (
server,
media_id,
), data in self._encrypted_attachments_keystore.items()
}
)
try:
with open(self._encrypted_attachments_keystore_path, 'w') as f:
f.write(serialized_keystore)
finally:
self._sync_store_timer = None
@alru_cache(maxsize=500) @alru_cache(maxsize=500)
@client_session @client_session
async def get_profile(self, user_id: str | None = None) -> ProfileGetResponse: async def get_profile(self, user_id: str | None = None) -> ProfileGetResponse:
@ -360,6 +418,39 @@ class MatrixClient(AsyncClient):
), f'Could not retrieve profile for room {room_id}: {ret.message}' ), f'Could not retrieve profile for room {room_id}: {ret.message}'
return ret return ret
async def download(
self,
server_name: str,
media_id: str,
filename: str | None = None,
allow_remote: bool = True,
):
response = await super().download(
server_name, media_id, filename, allow_remote=allow_remote
)
assert isinstance(
response, DownloadResponse
), f'Could not download media {media_id}: {response}'
encryption_data = self._encrypted_attachments_keystore.get(
(server_name, media_id)
)
if encryption_data:
self.logger.info('Decrypting media %s using the available keys', media_id)
response.filename = encryption_data.get('body', response.filename)
response.content_type = encryption_data.get(
'mimetype', response.content_type
)
response.body = decrypt_attachment(
response.body,
key=encryption_data.get('key'),
hash=encryption_data.get('hash'),
iv=encryption_data.get('iv'),
)
return response
async def _event_base_args( async def _event_base_args(
self, room: MatrixRoom, event: Event | None = None self, room: MatrixRoom, event: Event | None = None
) -> dict: ) -> dict:
@ -393,14 +484,60 @@ class MatrixClient(AsyncClient):
) )
) )
async def _on_room_message(self, room: MatrixRoom, event: RoomMessageText): async def _on_message(
self,
room: MatrixRoom,
event: RoomMessageText | RoomMessageMedia | RoomEncryptedMedia | StickerEvent,
):
if self._first_sync_performed.is_set(): if self._first_sync_performed.is_set():
get_bus().post( evt_type = MatrixMessageEvent
MatrixMessageEvent( evt_args = {
**(await self._event_base_args(room, event)), 'body': event.body,
body=event.body, 'url': getattr(event, 'url', None),
) **(await self._event_base_args(room, event)),
) }
if isinstance(event, (RoomMessageMedia, RoomEncryptedMedia, StickerEvent)):
evt_args['url'] = event.url
if isinstance(event, RoomEncryptedMedia):
evt_args['thumbnail_url'] = event.thumbnail_url
evt_args['mimetype'] = event.mimetype
self._store_encrypted_media_keys(event)
if isinstance(event, RoomMessageFormatted):
evt_args['format'] = event.format
evt_args['formatted_body'] = event.formatted_body
if isinstance(event, (RoomMessageImage, RoomEncryptedImage)):
evt_type = MatrixMessageImageEvent
elif isinstance(event, (RoomMessageAudio, RoomEncryptedAudio)):
evt_type = MatrixMessageAudioEvent
elif isinstance(event, (RoomMessageVideo, RoomEncryptedVideo)):
evt_type = MatrixMessageVideoEvent
elif isinstance(event, (RoomMessageFile, RoomEncryptedFile)):
evt_type = MatrixMessageFileEvent
get_bus().post(evt_type(**evt_args))
def _store_encrypted_media_keys(self, event: RoomEncryptedMedia):
url = event.url.strip('/')
parsed_url = urlparse(url)
homeserver = parsed_url.netloc.strip('/')
media_key = (homeserver, parsed_url.path.strip('/'))
self._encrypted_attachments_keystore[media_key] = {
'url': url,
'body': event.body,
'key': event.key['k'],
'hash': event.hashes['sha256'],
'iv': event.iv,
'homeserver': homeserver,
'mimetype': event.mimetype,
}
if not self._sync_store_timer:
self._sync_store_timer = threading.Timer(5, self._sync_store)
self._sync_store_timer.start()
async def _on_room_member(self, room: MatrixRoom, event: RoomMemberEvent): async def _on_room_member(self, room: MatrixRoom, event: RoomMemberEvent):
evt_type = None evt_type = None
@ -465,24 +602,6 @@ class MatrixClient(AsyncClient):
) )
) )
async def _on_media_message(self, room: MatrixRoom, event: RoomMessageMedia):
if self._first_sync_performed.is_set():
get_bus().post(
MatrixMediaMessageEvent(
url=event.url,
**(await self._event_base_args(room, event)),
)
)
async def _on_sticker_message(self, room: MatrixRoom, event: StickerEvent):
if self._first_sync_performed.is_set():
get_bus().post(
MatrixStickerEvent(
url=event.url,
**(await self._event_base_args(room, event)),
)
)
def _get_sas(self, event): def _get_sas(self, event):
sas = self.key_verifications.get(event.transaction_id) sas = self.key_verifications.get(event.transaction_id)
if not sas: if not sas:
@ -650,12 +769,23 @@ class MatrixPlugin(AsyncRunnablePlugin):
the same list. Then confirm that you see the same emojis, and your the same list. Then confirm that you see the same emojis, and your
device will be automatically marked as trusted. device will be automatically marked as trusted.
All the URLs returned by actions and events on this plugin are in the
``mxc://<server>/<media_id>`` format. You can either convert them to HTTP
through the :meth:`.mxc_to_http` method, or download them through the
:meth:`.download` method.
Triggers: Triggers:
* :class:`platypush.message.event.matrix.MatrixMessageEvent`: when a * :class:`platypush.message.event.matrix.MatrixMessageEvent`: when a
message is received. message is received.
* :class:`platypush.message.event.matrix.MatrixMediaMessageEvent`: when * :class:`platypush.message.event.matrix.MatrixMessageImageEvent`: when a
a media message is received. message containing an image is received.
* :class:`platypush.message.event.matrix.MatrixMessageAudioEvent`: when a
message containing an audio file is received.
* :class:`platypush.message.event.matrix.MatrixMessageVideoEvent`: when a
message containing a video file is received.
* :class:`platypush.message.event.matrix.MatrixMessageFileEvent`: when a
message containing a generic file is received.
* :class:`platypush.message.event.matrix.MatrixSyncEvent`: when the * :class:`platypush.message.event.matrix.MatrixSyncEvent`: when the
startup synchronization has been completed and the plugin is ready to startup synchronization has been completed and the plugin is ready to
use. use.
@ -675,8 +805,6 @@ class MatrixPlugin(AsyncRunnablePlugin):
called user wishes to pick the call. called user wishes to pick the call.
* :class:`platypush.message.event.matrix.MatrixCallHangupEvent`: when a * :class:`platypush.message.event.matrix.MatrixCallHangupEvent`: when a
called user exits the call. called user exits the call.
* :class:`platypush.message.event.matrix.MatrixStickerEvent`: when a
sticker is sent to a room.
* :class:`platypush.message.event.matrix.MatrixEncryptedMessageEvent`: * :class:`platypush.message.event.matrix.MatrixEncryptedMessageEvent`:
when a message is received but the client doesn't have the E2E keys when a message is received but the client doesn't have the E2E keys
to decrypt it, or encryption has not been enabled. to decrypt it, or encryption has not been enabled.
@ -691,6 +819,7 @@ class MatrixPlugin(AsyncRunnablePlugin):
access_token: str | None = None, access_token: str | None = None,
device_name: str | None = 'platypush', device_name: str | None = 'platypush',
device_id: str | None = None, device_id: str | None = None,
download_path: str | None = None,
autojoin_on_invite: bool = True, autojoin_on_invite: bool = True,
autotrust_devices: bool = False, autotrust_devices: bool = False,
autotrust_devices_whitelist: Collection[str] | None = None, autotrust_devices_whitelist: Collection[str] | None = None,
@ -717,6 +846,8 @@ class MatrixPlugin(AsyncRunnablePlugin):
:param access_token: User access token. :param access_token: User access token.
:param device_name: The name of this device/connection (default: ``platypush``). :param device_name: The name of this device/connection (default: ``platypush``).
:param device_id: Use an existing ``device_id`` for the sessions. :param device_id: Use an existing ``device_id`` for the sessions.
:param download_path: The folder where downloaded media will be saved
(default: ``~/Downloads``).
:param autojoin_on_invite: Whether the account should automatically :param autojoin_on_invite: Whether the account should automatically
join rooms upon invite. If false, then you may want to implement your join rooms upon invite. If false, then you may want to implement your
own logic in an event hook when a own logic in an event hook when a
@ -750,6 +881,10 @@ class MatrixPlugin(AsyncRunnablePlugin):
self._access_token = access_token self._access_token = access_token
self._device_name = device_name self._device_name = device_name
self._device_id = device_id self._device_id = device_id
self._download_path = download_path or os.path.join(
os.path.expanduser('~'), 'Downloads'
)
self._autojoin_on_invite = autojoin_on_invite self._autojoin_on_invite = autojoin_on_invite
self._autotrust_devices = autotrust_devices self._autotrust_devices = autotrust_devices
self._autotrust_devices_whitelist = set(autotrust_devices_whitelist or []) self._autotrust_devices_whitelist = set(autotrust_devices_whitelist or [])
@ -797,6 +932,8 @@ class MatrixPlugin(AsyncRunnablePlugin):
await self.client.sync_forever(timeout=30000, full_state=True) await self.client.sync_forever(timeout=30000, full_state=True)
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
except Exception as e:
self.logger.exception(e)
finally: finally:
try: try:
await self.client.close() await self.client.close()
@ -811,6 +948,7 @@ class MatrixPlugin(AsyncRunnablePlugin):
except OlmUnverifiedDeviceError as e: except OlmUnverifiedDeviceError as e:
raise AssertionError(str(e)) raise AssertionError(str(e))
assert not isinstance(ret, ErrorResponse), ret.message
if hasattr(ret, 'transport_response'): if hasattr(ret, 'transport_response'):
response = ret.transport_response response = ret.transport_response
assert response.ok, f'{coro} failed with status {response.status}' assert response.ok, f'{coro} failed with status {response.status}'
@ -853,11 +991,7 @@ class MatrixPlugin(AsyncRunnablePlugin):
) )
) )
assert self._loop ret = self._loop_execute(ret.transport_response.json())
ret = asyncio.run_coroutine_threadsafe(
ret.transport_response.json(), self._loop
).result()
return MatrixEventIdSchema().dump(ret) return MatrixEventIdSchema().dump(ret)
@action @action
@ -881,8 +1015,6 @@ class MatrixPlugin(AsyncRunnablePlugin):
:return: .. schema:: matrix.MatrixRoomSchema :return: .. schema:: matrix.MatrixRoomSchema
""" """
response = self._loop_execute(self.client.room_get_state(room_id)) # type: ignore response = self._loop_execute(self.client.room_get_state(room_id)) # type: ignore
assert not isinstance(response, RoomGetStateError), response.message
room_args = {'room_id': room_id, 'own_user_id': None, 'encrypted': False} room_args = {'room_id': room_id, 'own_user_id': None, 'encrypted': False}
room_params = {} room_params = {}
@ -909,7 +1041,6 @@ class MatrixPlugin(AsyncRunnablePlugin):
:return: .. schema:: matrix.MatrixMyDeviceSchema(many=True) :return: .. schema:: matrix.MatrixMyDeviceSchema(many=True)
""" """
response = self._loop_execute(self.client.devices()) response = self._loop_execute(self.client.devices())
assert not isinstance(response, DevicesError), response.message
return MatrixMyDeviceSchema().dump(response.devices, many=True) return MatrixMyDeviceSchema().dump(response.devices, many=True)
@action @action
@ -927,7 +1058,6 @@ class MatrixPlugin(AsyncRunnablePlugin):
Retrieve the rooms that the user has joined. Retrieve the rooms that the user has joined.
""" """
response = self._loop_execute(self.client.joined_rooms()) response = self._loop_execute(self.client.joined_rooms())
assert not isinstance(response, JoinedRoomsError), response.message
return [self.get_room(room_id).output for room_id in response.rooms] # type: ignore return [self.get_room(room_id).output for room_id in response.rooms] # type: ignore
@action @action
@ -937,7 +1067,7 @@ class MatrixPlugin(AsyncRunnablePlugin):
""" """
self._loop_execute(self.client.keys_upload()) self._loop_execute(self.client.keys_upload())
def _get_device(self, device_id: str): def _get_device(self, device_id: str) -> OlmDevice:
device = self.client.get_device(device_id) device = self.client.get_device(device_id)
assert device, f'No such device_id: {device_id}' assert device, f'No such device_id: {device_id}'
return device return device
@ -962,5 +1092,72 @@ class MatrixPlugin(AsyncRunnablePlugin):
device = self._get_device(device_id) device = self._get_device(device_id)
self.client.unverify_device(device) self.client.unverify_device(device)
@action
def mxc_to_http(self, url: str, homeserver: str | None = None) -> str:
"""
Convert a Matrix URL (in the format ``mxc://server/media_id``) to an
HTTP URL.
Note that invoking this function on a URL containing encrypted content
(i.e. a URL containing media sent to an encrypted room) will provide a
URL that points to encrypted content. The best way to deal with
encrypted media is by using :meth:`.download` to download the media
locally.
:param url: The MXC URL to be converted.
:param homeserver: The hosting homeserver (default: the same as the URL).
:return: The converted HTTP(s) URL.
"""
http_url = Api.mxc_to_http(url, homeserver=homeserver)
assert http_url, f'Could not convert URL {url}'
return http_url
@action
def download(
self,
url: str,
download_path: str | None = None,
filename: str | None = None,
allow_remote=True,
):
"""
Download a file given its Matrix URL.
Note that URLs that point to encrypted resources will be automatically
decrypted only if they were received on a room joined by this account.
:param url: Matrix URL, in the format
:return: .. schema:: matrix.MatrixDownloadedFileSchema
"""
parsed_url = urlparse(url)
server = parsed_url.netloc.strip('/')
media_id = parsed_url.path.strip('/')
response = self._loop_execute(
self.client.download(
server, media_id, filename=filename, allow_remote=allow_remote
)
)
if not download_path:
download_path = self._download_path
if not filename:
filename = response.filename or media_id
outfile = os.path.join(str(download_path), str(filename))
pathlib.Path(download_path).mkdir(parents=True, exist_ok=True)
with open(outfile, 'wb') as f:
f.write(response.body)
return MatrixDownloadedFileSchema().dump(
{
'url': url,
'path': outfile,
'size': len(response.body),
'content_type': response.content_type,
}
)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -1,7 +1,14 @@
manifest: manifest:
events: events:
platypush.message.event.matrix.MatrixMessageEvent: when a message is received. platypush.message.event.matrix.MatrixMessageEvent: when a message is received.
platypush.message.event.matrix.MatrixMediaMessageEvent: when a media message is received. platypush.message.event.matrix.MatrixMessageImageEvent: when a message
containing an image is received.
platypush.message.event.matrix.MatrixMessageAudioEvent: when a message
containing an audio file is received.
platypush.message.event.matrix.MatrixMessageVideoEvent: when a message
containing a video file is received.
platypush.message.event.matrix.MatrixMessageFileEvent: when a message
containing a generic file is received.
platypush.message.event.matrix.MatrixSyncEvent: when the startup platypush.message.event.matrix.MatrixSyncEvent: when the startup
synchronization has been completed and the plugin is ready to use. synchronization has been completed and the plugin is ready to use.
platypush.message.event.matrix.MatrixRoomCreatedEvent: when a room is created. platypush.message.event.matrix.MatrixRoomCreatedEvent: when a room is created.
@ -12,7 +19,6 @@ manifest:
platypush.message.event.matrix.MatrixCallInviteEvent: when the user is invited to a call. platypush.message.event.matrix.MatrixCallInviteEvent: when the user is invited to a call.
platypush.message.event.matrix.MatrixCallAnswerEvent: when a called user wishes to pick the call. platypush.message.event.matrix.MatrixCallAnswerEvent: when a called user wishes to pick the call.
platypush.message.event.matrix.MatrixCallHangupEvent: when a called user exits the call. platypush.message.event.matrix.MatrixCallHangupEvent: when a called user exits the call.
platypush.message.event.matrix.MatrixStickerEvent: when a sticker is sent to a room.
platypush.message.event.matrix.MatrixEncryptedMessageEvent: | platypush.message.event.matrix.MatrixEncryptedMessageEvent: |
when a message is received but the client doesn't when a message is received but the client doesn't
have the E2E keys to decrypt it, or encryption has not been enabled. have the E2E keys to decrypt it, or encryption has not been enabled.

View file

@ -34,7 +34,7 @@ class MatrixProfileSchema(Schema):
avatar_url = fields.URL( avatar_url = fields.URL(
metadata={ metadata={
'description': 'User avatar URL', 'description': 'User avatar URL',
'example': 'mxc://matrix.platypush.tech/AbCdEfG0123456789', 'example': 'mxc://matrix.example.org/AbCdEfG0123456789',
} }
) )
@ -73,7 +73,7 @@ class MatrixRoomSchema(Schema):
attribute='room_avatar_url', attribute='room_avatar_url',
metadata={ metadata={
'description': 'Room avatar URL', 'description': 'Room avatar URL',
'example': 'mxc://matrix.platypush.tech/AbCdEfG0123456789', 'example': 'mxc://matrix.example.org/AbCdEfG0123456789',
}, },
) )
@ -157,3 +157,33 @@ class MatrixMyDeviceSchema(Schema):
'example': '2022-07-23T17:20:01.254223', 'example': '2022-07-23T17:20:01.254223',
} }
) )
class MatrixDownloadedFileSchema(Schema):
url = fields.String(
metadata={
'description': 'Matrix URL of the original resource',
'example': 'mxc://matrix.example.org/YhQycHvFOvtiDDbEeWWtEhXx',
},
)
path = fields.String(
metadata={
'description': 'Local path where the file has been saved',
'example': '/home/user/Downloads/image.png',
}
)
content_type = fields.String(
metadata={
'description': 'Content type of the downloaded file',
'example': 'image/png',
}
)
size = fields.Int(
metadata={
'description': 'Length in bytes of the output file',
'example': 1024,
}
)