diff --git a/alembic/versions/24f31fc8a72b_add_encrypted_field_for_portals.py b/alembic/versions/24f31fc8a72b_add_encrypted_field_for_portals.py
new file mode 100644
index 00000000..8cd1ba59
--- /dev/null
+++ b/alembic/versions/24f31fc8a72b_add_encrypted_field_for_portals.py
@@ -0,0 +1,27 @@
+"""Add encrypted field for portals
+
+Revision ID: 24f31fc8a72b
+Revises: a7c04a56041b
+Create Date: 2020-03-28 20:14:29.046699
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "24f31fc8a72b"
+down_revision = "a7c04a56041b"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ with op.batch_alter_table("portal") as batch_op:
+ batch_op.add_column(sa.Column("encrypted", sa.Boolean(), nullable=False,
+ server_default=sa.sql.expression.false()))
+
+
+def downgrade():
+ with op.batch_alter_table("portal") as batch_op:
+ batch_op.drop_column("encrypted")
diff --git a/mautrix_telegram/config.py b/mautrix_telegram/config.py
index a1ca0182..904f3a43 100644
--- a/mautrix_telegram/config.py
+++ b/mautrix_telegram/config.py
@@ -118,6 +118,8 @@ class Config(BaseBridgeConfig):
copy("bridge.federate_rooms")
copy("bridge.animated_sticker.target")
copy("bridge.animated_sticker.args")
+ copy("bridge.encryption.allow")
+ copy("bridge.encryption.default")
copy("bridge.initial_power_level_overrides.group")
copy("bridge.initial_power_level_overrides.user")
diff --git a/mautrix_telegram/db/portal.py b/mautrix_telegram/db/portal.py
index bae8c76a..8112e9ad 100644
--- a/mautrix_telegram/db/portal.py
+++ b/mautrix_telegram/db/portal.py
@@ -15,7 +15,7 @@
# along with this program. If not, see .
from typing import Optional
-from sqlalchemy import Column, Integer, String, Boolean, Text, func
+from sqlalchemy import Column, Integer, String, Boolean, Text, func, sql
from mautrix.types import RoomID
from mautrix.util.db import Base
@@ -34,6 +34,7 @@ class Portal(Base):
# Matrix portal information
mxid: RoomID = Column(String, unique=True, nullable=True)
+ encrypted: bool = Column(Boolean, nullable=False, server_default=sql.expression.false())
config: str = Column(Text, nullable=True)
diff --git a/mautrix_telegram/e2ee.py b/mautrix_telegram/e2ee.py
new file mode 100644
index 00000000..cb9d6895
--- /dev/null
+++ b/mautrix_telegram/e2ee.py
@@ -0,0 +1,112 @@
+# mautrix-telegram - A Matrix-Telegram puppeting bridge
+# Copyright (C) 2019 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+from typing import Tuple, Union
+import logging
+import asyncio
+import hashlib
+import hmac
+
+from nio import AsyncClient, Event as NioEvent, GroupEncryptionError, LoginError
+
+from mautrix.appservice import AppService
+from mautrix.types import (Filter, RoomFilter, EventFilter, RoomEventFilter, StateFilter,
+ EventType, RoomID, Serializable, JSON, MessageEvent, Event)
+
+from .context import Context
+
+
+class EncryptionManager:
+ loop: asyncio.AbstractEventLoop
+ log: logging.Logger = logging.getLogger("mau.e2ee")
+ client: AsyncClient
+ az: AppService
+
+ login_shared_secret: bytes
+
+ sync_task: asyncio.Task
+
+ def __init__(self, context: 'Context') -> None:
+ self.loop = context.loop
+ self.az = context.az
+ self.config = context.config
+ lss: str = self.config["bridge.login_shared_secret"]
+ if not lss:
+ raise ValueError("login_shared_secret must be set to enable encryption")
+ self.login_shared_secret = lss.encode("utf-8")
+ self.client = AsyncClient(homeserver=self.config["homeserver.address"],
+ user=self.az.bot_mxid, device_id="Telegram bridge",
+ store_path="nio_store")
+
+ async def encrypt(self, room_id: RoomID, event_type: EventType,
+ content: Union[Serializable, JSON]) -> Tuple[EventType, JSON]:
+ serialized = content.serialize() if isinstance(content, Serializable) else content
+ type_str = str(event_type)
+ retries = 0
+ while True:
+ try:
+ type_str, encrypted = self.client.encrypt(room_id, type_str, serialized)
+ break
+ except GroupEncryptionError:
+ if retries > 3:
+ self.log.error("Got GroupEncryptionError again, giving up")
+ raise
+ retries += 1
+ self.log.debug("Got GroupEncryptionError, sharing group session and trying again")
+ await self.client.share_group_session(room_id, ignore_unverified_devices=True)
+ event_type = EventType.find(type_str)
+ try:
+ encrypted["m.relates_to"] = serialized["m.relates_to"]
+ except KeyError:
+ pass
+ return event_type, encrypted
+
+ def decrypt(self, event: MessageEvent) -> MessageEvent:
+ serialized = event.serialize()
+ event = self.client.decrypt_event(NioEvent.parse_encrypted_event(serialized))
+ try:
+ event.source["content"]["m.relates_to"] = serialized["content"]["m.relates_to"]
+ except KeyError:
+ pass
+ return Event.deserialize(event.source)
+
+ async def start(self) -> None:
+ self.log.debug("Logging in with bridge bot user")
+ password = hmac.new(self.login_shared_secret, self.az.bot_mxid.encode("utf-8"),
+ hashlib.sha512).hexdigest()
+ resp = await self.client.login(password, device_name="Telegram bridge")
+ if isinstance(resp, LoginError):
+ raise resp
+ self.sync_task = self.loop.create_task(self.client.sync_forever(
+ timeout=30000, sync_filter=self._filter.serialize()))
+ self.log.info("End-to-bridge encryption support is enabled")
+
+ def stop(self) -> None:
+ self.sync_task.cancel()
+
+ @property
+ def _filter(self) -> Filter:
+ all_events = EventType.find("*")
+ return Filter(
+ account_data=EventFilter(types=[all_events]),
+ presence=EventFilter(not_types=[all_events]),
+ room=RoomFilter(
+ include_leave=False,
+ state=StateFilter(types=[EventType.ROOM_MEMBER, EventType.ROOM_ENCRYPTION]),
+ timeline=RoomEventFilter(types=[EventType.ROOM_MEMBER, EventType.ROOM_ENCRYPTION]),
+ account_data=RoomEventFilter(not_types=[all_events]),
+ ephemeral=RoomEventFilter(not_types=[all_events]),
+ ),
+ )
diff --git a/mautrix_telegram/example-config.yaml b/mautrix_telegram/example-config.yaml
index 4ab42797..26c59793 100644
--- a/mautrix_telegram/example-config.yaml
+++ b/mautrix_telegram/example-config.yaml
@@ -191,6 +191,17 @@ bridge:
height: 256
background: "020202" # only for gif
fps: 30 # only for webm
+ # End-to-bridge encryption support options. These require matrix-nio to be installed with pip
+ # and login_shared_secret to be configured in order to get a device for the bridge bot.
+ #
+ # Additionally, https://github.com/matrix-org/synapse/pull/5758 is required if using a normal
+ # application service.
+ encryption:
+ # Allow encryption, work in group chat rooms with e2ee enabled
+ allow: false
+ # Default to encryption, force-enable encryption in all portals the bridge creates
+ # This will cause the bridge bot to be in private chats for the encryption to work properly.
+ default: false
# Overrides for base power levels.
initial_power_level_overrides:
diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py
index 27d6f164..3163e679 100644
--- a/mautrix_telegram/matrix.py
+++ b/mautrix_telegram/matrix.py
@@ -13,14 +13,14 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Dict, Set, Tuple, Union, Iterable, TYPE_CHECKING
+from typing import Dict, Set, Tuple, Union, Iterable, Optional, TYPE_CHECKING
from mautrix.bridge import BaseMatrixHandler
from mautrix.types import (Event, EventType, RoomID, UserID, EventID, ReceiptEvent, ReceiptType,
ReceiptEventContent, PresenceEvent, PresenceState, TypingEvent,
MessageEvent, StateEvent, RedactionEvent, RoomNameStateEventContent,
RoomAvatarStateEventContent, RoomTopicStateEventContent,
- MemberStateEventContent)
+ MemberStateEventContent, EncryptedEvent)
from mautrix.errors import MatrixError
from . import user as u, portal as po, puppet as pu, commands as com
@@ -37,6 +37,11 @@ except ImportError:
Histogram = None
EVENT_TIME = None
+try:
+ from .e2ee import EncryptionManager
+except ImportError:
+ EncryptionManager = None
+
RoomMetaStateEventContent = Union[RoomNameStateEventContent, RoomAvatarStateEventContent,
RoomTopicStateEventContent]
@@ -44,14 +49,26 @@ RoomMetaStateEventContent = Union[RoomNameStateEventContent, RoomAvatarStateEven
class MatrixHandler(BaseMatrixHandler):
bot: 'Bot'
commands: 'com.CommandProcessor'
+ e2ee: Optional[EncryptionManager]
previously_typing: Dict[RoomID, Set[UserID]]
def __init__(self, context: 'Context') -> None:
super(MatrixHandler, self).__init__(context.az, context.config, loop=context.loop,
command_processor=com.CommandProcessor(context))
+ self.e2ee = None
+ if self.config["bridge.encryption.allow"]:
+ if EncryptionManager:
+ self.e2ee = EncryptionManager(context)
+ else:
+ self.log.warning("Encryption enabled in config, but dependencies not installed.")
self.bot = context.bot
self.previously_typing = {}
+ async def init_as_bot(self) -> None:
+ await super().init_as_bot()
+ if self.e2ee:
+ await self.e2ee.start()
+
async def get_user(self, user_id: UserID) -> 'u.User':
return await u.User.get_by_mxid(user_id).ensure_started()
@@ -355,7 +372,7 @@ class MatrixHandler(BaseMatrixHandler):
self.previously_typing[room_id] = now_typing
def filter_matrix_event(self, evt: Event) -> bool:
- if not isinstance(evt, (RedactionEvent, MessageEvent, StateEvent)):
+ if not isinstance(evt, (RedactionEvent, MessageEvent, StateEvent, EncryptedEvent)):
return True
return evt.sender and (evt.sender == self.az.bot_mxid
or pu.Puppet.get_id_from_mxid(evt.sender) is not None)
@@ -372,6 +389,8 @@ class MatrixHandler(BaseMatrixHandler):
async def handle_event(self, evt: Event) -> None:
if evt.type == EventType.ROOM_REDACTION:
await self.handle_redaction(evt)
+ elif evt.type == EventType.ROOM_ENCRYPTED and self.e2ee:
+ await self.int_handle_event(self.e2ee.decrypt(evt))
async def handle_state_event(self, evt: StateEvent) -> None:
if evt.type == EventType.ROOM_POWER_LEVELS:
@@ -387,6 +406,11 @@ class MatrixHandler(BaseMatrixHandler):
await self.handle_room_pin(evt.room_id, evt.sender, new_events, old_events)
elif evt.type == EventType.ROOM_TOMBSTONE:
await self.handle_room_upgrade(evt.room_id, evt.sender, evt.content.replacement_room)
+ elif evt.type == EventType.ROOM_ENCRYPTION:
+ portal = po.Portal.get_by_mxid(evt.room_id)
+ if portal:
+ portal.encrypted = True
+ portal.save()
async def log_event_handle_duration(self, evt: Event, duration: float) -> None:
if EVENT_TIME:
diff --git a/mautrix_telegram/portal/base.py b/mautrix_telegram/portal/base.py
index c1332b0e..1d2d6d71 100644
--- a/mautrix_telegram/portal/base.py
+++ b/mautrix_telegram/portal/base.py
@@ -44,6 +44,7 @@ if TYPE_CHECKING:
from ..bot import Bot
from ..abstract_user import AbstractUser
from ..config import Config
+ from ..matrix import MatrixHandler
from . import Portal
TypeParticipant = Union[TypeChatParticipant, TypeChannelParticipant]
@@ -58,6 +59,7 @@ class BasePortal(ABC):
az: AppService = None
bot: 'Bot' = None
loop: asyncio.AbstractEventLoop = None
+ matrix: 'MatrixHandler' = None
# Config cache
filter_mode: str = None
@@ -85,6 +87,7 @@ class BasePortal(ABC):
about: Optional[str]
photo_id: Optional[str]
local_config: Dict[str, Any]
+ encrypted: bool
deleted: bool
backfilling: bool
backfill_leave: Optional[Set[IntentAPI]]
@@ -102,7 +105,8 @@ class BasePortal(ABC):
mxid: Optional[RoomID] = None, username: Optional[str] = None,
megagroup: Optional[bool] = False, title: Optional[str] = None,
about: Optional[str] = None, photo_id: Optional[str] = None,
- local_config: Optional[str] = None, db_instance: DBPortal = None) -> None:
+ local_config: Optional[str] = None, encrypted: Optional[bool] = False,
+ db_instance: DBPortal = None) -> None:
self.mxid = mxid
self.tgid = tgid
self.tg_receiver = tg_receiver or tgid
@@ -113,6 +117,7 @@ class BasePortal(ABC):
self.about = about
self.photo_id = photo_id
self.local_config = json.loads(local_config or "{}")
+ self.encrypted = encrypted
self._db_instance = db_instance
self._main_intent = None
self.deleted = False
@@ -328,12 +333,12 @@ class BasePortal(ABC):
return DBPortal(tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type,
mxid=self.mxid, username=self.username, megagroup=self.megagroup,
title=self.title, about=self.about, photo_id=self.photo_id,
- config=json.dumps(self.local_config))
+ config=json.dumps(self.local_config), encrypted=self.encrypted)
def save(self) -> None:
self.db_instance.edit(mxid=self.mxid, username=self.username, title=self.title,
about=self.about, photo_id=self.photo_id, megagroup=self.megagroup,
- config=json.dumps(self.local_config))
+ config=json.dumps(self.local_config), encrypted=self.encrypted)
def delete(self) -> None:
try:
@@ -352,10 +357,10 @@ class BasePortal(ABC):
@classmethod
def from_db(cls, db_portal: DBPortal) -> 'Portal':
return cls(tgid=db_portal.tgid, tg_receiver=db_portal.tg_receiver,
- peer_type=db_portal.peer_type, mxid=db_portal.mxid,
- username=db_portal.username, megagroup=db_portal.megagroup,
- title=db_portal.title, about=db_portal.about, photo_id=db_portal.photo_id,
- local_config=db_portal.config, db_instance=db_portal)
+ peer_type=db_portal.peer_type, mxid=db_portal.mxid, username=db_portal.username,
+ megagroup=db_portal.megagroup, title=db_portal.title, about=db_portal.about,
+ photo_id=db_portal.photo_id, local_config=db_portal.config,
+ encrypted=db_portal.encrypted, db_instance=db_portal)
# endregion
# region Class instance lookup
@@ -506,6 +511,7 @@ class BasePortal(ABC):
def init(context: Context) -> None:
global config
BasePortal.az, config, BasePortal.loop, BasePortal.bot = context.core
+ BasePortal.matrix = context.mx
BasePortal.max_initial_member_sync = config["bridge.max_initial_member_sync"]
BasePortal.sync_channel_members = config["bridge.sync_channel_members"]
BasePortal.sync_matrix_state = config["bridge.sync_matrix_state"]
diff --git a/mautrix_telegram/portal/metadata.py b/mautrix_telegram/portal/metadata.py
index 7c6ef837..4d4d491f 100644
--- a/mautrix_telegram/portal/metadata.py
+++ b/mautrix_telegram/portal/metadata.py
@@ -362,7 +362,7 @@ class PortalMetadata(BasePortal, ABC):
levels.kick = overrides.get("kick", 50)
levels.redact = overrides.get("redact", 50)
levels.invite = overrides.get("invite", 50 if dbr.invite_users else 0)
- levels.events[EventType.ROOM_ENCRYPTED] = 99
+ levels.events[EventType.ROOM_ENCRYPTION] = 99
levels.events[EventType.ROOM_TOMBSTONE] = 99
levels.events[EventType.ROOM_NAME] = 50 if dbr.change_info else 0
levels.events[EventType.ROOM_AVATAR] = 50 if dbr.change_info else 0
diff --git a/mautrix_telegram/portal/telegram.py b/mautrix_telegram/portal/telegram.py
index 35c992bb..61f655fd 100644
--- a/mautrix_telegram/portal/telegram.py
+++ b/mautrix_telegram/portal/telegram.py
@@ -38,7 +38,7 @@ from telethon.tl.types import (
from mautrix.appservice import IntentAPI
from mautrix.types import (EventID, UserID, ImageInfo, ThumbnailInfo, RelatesTo, MessageType,
EventType, MediaMessageEventContent, TextMessageEventContent,
- LocationMessageEventContent, Format)
+ LocationMessageEventContent, Format, MessageEventContent)
from ..types import TelegramID
from ..db import Message as DBMessage, TelegramFile as DBTelegramFile
@@ -71,6 +71,12 @@ class PortalTelegram(BasePortal, ABC):
return f"https://t.me/c/{self.tgid}/{evt.id}"
return None
+ async def _send_message(self, intent: IntentAPI, content: MessageEventContent,
+ event_type: EventType = EventType.ROOM_MESSAGE, **kwargs) -> EventID:
+ if self.encrypted and self.matrix.e2ee:
+ event_type, content = await self.matrix.e2ee.encrypt(self.mxid, event_type, content)
+ return await intent.send_message_event(self.mxid, event_type, content, **kwargs)
+
async def handle_telegram_photo(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
relates_to: Dict = None) -> Optional[EventID]:
loc, largest_size = self._get_largest_photo_size(evt.media.photo)
@@ -85,7 +91,7 @@ class PortalTelegram(BasePortal, ABC):
prefix_text="Inline image: ")
content.external_url = self._get_external_url(evt)
await intent.set_typing(self.mxid, is_typing=False)
- return await intent.send_message(self.mxid, content, timestamp=evt.date)
+ return await self._send_message(intent, content, timestamp=evt.date)
info = ImageInfo(
height=largest_size.h, width=largest_size.w, orientation=0, mimetype=file.mime_type,
size=(len(largest_size.bytes) if (isinstance(largest_size, PhotoCachedSize))
@@ -95,12 +101,12 @@ class PortalTelegram(BasePortal, ABC):
content = MediaMessageEventContent(url=file.mxc, msgtype=MessageType.IMAGE, info=info,
body=name, relates_to=relates_to,
external_url=self._get_external_url(evt))
- result = await intent.send_message(self.mxid, content, timestamp=evt.date)
+ result = await self._send_message(intent, content, timestamp=evt.date)
if evt.message:
caption_content = await formatter.telegram_to_matrix(evt, source, self.main_intent,
no_reply_fallback=True)
caption_content.external_url = content.external_url
- result = await intent.send_message(self.mxid, caption_content, timestamp=evt.date)
+ result = await self._send_message(intent, caption_content, timestamp=evt.date)
return result
@staticmethod
@@ -168,6 +174,7 @@ class PortalTelegram(BasePortal, ABC):
if document.size > config["bridge.max_document_size"] * 1000 ** 2:
name = attrs.name or ""
caption = f"\n{evt.message}" if evt.message else ""
+ # TODO encrypt
return await intent.send_notice(self.mxid, f"Too large file {name}{caption}")
thumb_loc, thumb_size = self._get_largest_photo_size(document)
@@ -199,7 +206,7 @@ class PortalTelegram(BasePortal, ABC):
"audio/": MessageType.AUDIO,
"image/": MessageType.IMAGE,
}.get(info.mimetype[:6], MessageType.FILE))
- return await intent.send_message_event(self.mxid, event_type, content, timestamp=evt.date)
+ return await self._send_message(intent, content, event_type=event_type, timestamp=evt.date)
def handle_telegram_location(self, _: 'AbstractUser', intent: IntentAPI, evt: Message,
relates_to: dict = None) -> Awaitable[EventID]:
@@ -218,7 +225,7 @@ class PortalTelegram(BasePortal, ABC):
content["format"] = str(Format.HTML)
content["formatted_body"] = f"Location: {body}"
- return intent.send_message(self.mxid, content, timestamp=evt.date)
+ return self._send_message(intent, content, timestamp=evt.date)
async def handle_telegram_text(self, source: 'AbstractUser', intent: IntentAPI, is_bot: bool,
evt: Message) -> EventID:
@@ -228,7 +235,7 @@ class PortalTelegram(BasePortal, ABC):
if is_bot and self.get_config("bot_messages_as_notices"):
content.msgtype = MessageType.NOTICE
await intent.set_typing(self.mxid, is_typing=False)
- return await intent.send_message(self.mxid, content, timestamp=evt.date)
+ return await self._send_message(intent, content, timestamp=evt.date)
async def handle_telegram_unsupported(self, source: 'AbstractUser', intent: IntentAPI,
evt: Message, relates_to: dict = None) -> EventID:
@@ -241,7 +248,7 @@ class PortalTelegram(BasePortal, ABC):
content.external_url = self._get_external_url(evt)
content["net.maunium.telegram.unsupported"] = True
await intent.set_typing(self.mxid, is_typing=False)
- return await intent.send_message(self.mxid, content, timestamp=evt.date)
+ return await self._send_message(intent, content, timestamp=evt.date)
async def handle_telegram_poll(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
relates_to: RelatesTo) -> EventID:
@@ -267,7 +274,7 @@ class PortalTelegram(BasePortal, ABC):
relates_to=relates_to, external_url=self._get_external_url(evt))
await intent.set_typing(self.mxid, is_typing=False)
- return await intent.send_message(self.mxid, content, timestamp=evt.date)
+ return await self._send_message(intent, content, timestamp=evt.date)
@staticmethod
def _int_to_bytes(i: int) -> bytes:
@@ -309,7 +316,7 @@ class PortalTelegram(BasePortal, ABC):
content["net.maunium.telegram.game"] = play_id
await intent.set_typing(self.mxid, is_typing=False)
- return await intent.send_message(self.mxid, content, timestamp=evt.date)
+ return await self._send_message(intent, content, timestamp=evt.date)
async def handle_telegram_edit(self, source: 'AbstractUser', sender: p.Puppet, evt: Message
) -> None:
@@ -353,7 +360,7 @@ class PortalTelegram(BasePortal, ABC):
intent = sender.intent_for(self) if sender else self.main_intent
await intent.set_typing(self.mxid, is_typing=False)
- event_id = await intent.send_message(self.mxid, content)
+ event_id = await self._send_message(intent, content)
prev_edit_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space, -1) or editing_msg
DBMessage(mxid=event_id, mx_room=self.mxid, tg_space=tg_space, tgid=TelegramID(evt.id),
@@ -522,6 +529,7 @@ class PortalTelegram(BasePortal, ABC):
elif isinstance(action, MessageActionChatMigrateTo):
self.peer_type = "channel"
self._migrate_and_save_telegram(TelegramID(action.channel_id))
+ # TODO encrypt
await sender.intent_for(self).send_emote(self.mxid,
"upgraded this group to a supergroup.")
elif isinstance(action, MessageActionGameScore):
diff --git a/optional-requirements.txt b/optional-requirements.txt
index 0c404596..61f51981 100644
--- a/optional-requirements.txt
+++ b/optional-requirements.txt
@@ -18,3 +18,6 @@ prometheus_client>=0.6,<0.8
#/postgres
psycopg2-binary>=2,<3
+
+#/e2be
+matrix-nio[e2e]>=0.9,<0.10
diff --git a/requirements.txt b/requirements.txt
index 4941f95a..e841e270 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,6 +4,6 @@ ruamel.yaml>=0.15.35,<0.17
python-magic>=0.4,<0.5
commonmark>=0.8,<0.10
aiohttp>=3,<4
-mautrix==0.5.0.beta4
+mautrix==0.5.0.beta5
telethon>=1.10,<1.12
telethon-session-sqlalchemy>=0.2.14,<0.3