`")
try:
await evt.sender.ensure_started(even_if_no_session=True)
first_name, last_name = evt.sender.command_status["full_name"]
user = await evt.sender.client.sign_up(evt.args[0], first_name, last_name)
- ignore_coro(asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop))
+ asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop)
evt.sender.command_status = None
return await evt.reply(f"Successfully registered to Telegram.")
except PhoneNumberOccupiedError:
@@ -105,7 +106,7 @@ async def enter_code_register(evt: CommandEvent) -> Dict:
@command_handler(needs_auth=False, management_only=True,
help_section=SECTION_AUTH,
help_text="Get instructions on how to log in.")
-async def login(evt: CommandEvent) -> Optional[Dict]:
+async def login(evt: CommandEvent) -> EventID:
override_sender = False
if len(evt.args) > 0 and evt.sender.is_admin:
evt.sender = await u.User.get_by_mxid(evt.args[0]).ensure_started()
@@ -142,7 +143,7 @@ async def login(evt: CommandEvent) -> Optional[Dict]:
async def _request_code(evt: CommandEvent, phone_number: str, next_status: Dict[str, Any]
- ) -> Dict:
+ ) -> EventID:
ok = False
try:
await evt.sender.ensure_started(even_if_no_session=True)
@@ -174,7 +175,7 @@ async def _request_code(evt: CommandEvent, phone_number: str, next_status: Dict[
@command_handler(needs_auth=False)
-async def enter_phone_or_token(evt: CommandEvent) -> Optional[Dict]:
+async def enter_phone_or_token(evt: CommandEvent) -> Optional[EventID]:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-phone-or-token `")
elif not evt.config.get("bridge.allow_matrix_login", True):
@@ -198,7 +199,7 @@ async def enter_phone_or_token(evt: CommandEvent) -> Optional[Dict]:
@command_handler(needs_auth=False)
-async def enter_code(evt: CommandEvent) -> Optional[Dict]:
+async def enter_code(evt: CommandEvent) -> Optional[EventID]:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-code `")
elif not evt.config.get("bridge.allow_matrix_login", True):
@@ -214,7 +215,7 @@ async def enter_code(evt: CommandEvent) -> Optional[Dict]:
@command_handler(needs_auth=False)
-async def enter_password(evt: CommandEvent) -> Optional[Dict]:
+async def enter_password(evt: CommandEvent) -> Optional[EventID]:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-password `")
elif not evt.config.get("bridge.allow_matrix_login", True):
@@ -233,7 +234,7 @@ async def enter_password(evt: CommandEvent) -> Optional[Dict]:
return None
-async def _sign_in(evt: CommandEvent, **sign_in_info) -> Dict:
+async def _sign_in(evt: CommandEvent, **sign_in_info) -> EventID:
try:
await evt.sender.ensure_started(even_if_no_session=True)
user = await evt.sender.client.sign_in(**sign_in_info)
@@ -243,7 +244,7 @@ async def _sign_in(evt: CommandEvent, **sign_in_info) -> Dict:
await evt.reply(f"[{existing_user.displayname}]"
f"(https://matrix.to/#/{existing_user.mxid})"
" was logged out from the account.")
- ignore_coro(asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop))
+ asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop)
evt.sender.command_status = None
name = f"@{user.username}" if user.username else f"+{user.phone}"
return await evt.reply(f"Successfully logged in as {name}")
@@ -265,7 +266,7 @@ async def _sign_in(evt: CommandEvent, **sign_in_info) -> Dict:
@command_handler(needs_auth=True,
help_section=SECTION_AUTH,
help_text="Log out from Telegram.")
-async def logout(evt: CommandEvent) -> Optional[Dict]:
+async def logout(evt: CommandEvent) -> EventID:
if await evt.sender.log_out():
return await evt.reply("Logged out successfully.")
return await evt.reply("Failed to log out.")
diff --git a/mautrix_telegram/commands/telegram/misc.py b/mautrix_telegram/commands/telegram/misc.py
index 5555bd1c..60b12181 100644
--- a/mautrix_telegram/commands/telegram/misc.py
+++ b/mautrix_telegram/commands/telegram/misc.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,13 +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, List, Optional, Tuple
+from typing import List, Optional, Tuple
+import logging
import codecs
import base64
import re
from telethon.errors import (InviteHashInvalidError, InviteHashExpiredError, OptionsTooMuchError,
- UserAlreadyParticipantError)
+ UserAlreadyParticipantError, ChatIdInvalidError)
from telethon.tl.patched import Message
from telethon.tl.types import (User as TLUser, TypeUpdates, MessageMediaGame, MessageMediaPoll,
TypePeer)
@@ -29,6 +29,8 @@ from telethon.tl.functions.messages import (ImportChatInviteRequest, CheckChatIn
GetBotCallbackAnswerRequest, SendVoteRequest)
from telethon.tl.functions.channels import JoinChannelRequest
+from mautrix.types import EventID
+
from ... import puppet as pu, portal as po
from ...abstract_user import AbstractUser
from ...db import Message as DBMessage
@@ -39,7 +41,7 @@ from ...commands import command_handler, CommandEvent, SECTION_MISC, SECTION_CRE
@command_handler(help_section=SECTION_MISC,
help_args="[_-r|--remote_] <_query_>",
help_text="Search your contacts or the Telegram servers for users.")
-async def search(evt: CommandEvent) -> Optional[Dict]:
+async def search(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] `")
@@ -60,7 +62,7 @@ async def search(evt: CommandEvent) -> Optional[Dict]:
"Minimum length of remote query is 5 characters.")
return await evt.reply("No results 3:")
- reply = [] # type: List[str]
+ reply: List[str] = []
if remote:
reply += ["**Results from Telegram server:**", ""]
else:
@@ -80,7 +82,7 @@ async def search(evt: CommandEvent) -> Optional[Dict]:
"either the internal user ID, the username or the phone number. "
"**N.B.** The phone numbers you start chats with must already be in "
"your contacts.")
-async def pm(evt: CommandEvent) -> Optional[Dict]:
+async def pm(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp pm `")
@@ -99,7 +101,7 @@ async def pm(evt: CommandEvent) -> Optional[Dict]:
f"{pu.Puppet.get_displayname(user, False)}")
-async def _join(evt: CommandEvent, arg: str) -> Tuple[Optional[TypeUpdates], Optional[Dict]]:
+async def _join(evt: CommandEvent, arg: str) -> Tuple[Optional[TypeUpdates], Optional[EventID]]:
if arg.startswith("joinchat/"):
invite_hash = arg[len("joinchat/"):]
try:
@@ -122,7 +124,7 @@ async def _join(evt: CommandEvent, arg: str) -> Tuple[Optional[TypeUpdates], Opt
@command_handler(help_section=SECTION_CREATING_PORTALS,
help_args="<_link_>",
help_text="Join a chat with an invite link.")
-async def join(evt: CommandEvent) -> Optional[Dict]:
+async def join(evt: CommandEvent) -> Optional[EventID]:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp join `")
@@ -142,7 +144,11 @@ async def join(evt: CommandEvent) -> Optional[Dict]:
return await evt.reply(f"Invited you to portal of {portal.title}")
else:
await evt.reply(f"Creating room for {chat.title}... This might take a while.")
- await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
+ try:
+ await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
+ except ChatIdInvalidError as e:
+ logging.getLogger("mau.commands").info(updates.stringify())
+ raise e
return await evt.reply(f"Created room for {portal.title}")
return None
@@ -150,7 +156,7 @@ async def join(evt: CommandEvent) -> Optional[Dict]:
@command_handler(help_section=SECTION_MISC,
help_args="[`chats`|`contacts`|`me`]",
help_text="Synchronize your chat portals, contacts and/or own info.")
-async def sync(evt: CommandEvent) -> Optional[Dict]:
+async def sync(evt: CommandEvent) -> EventID:
if len(evt.args) > 0:
sync_only = evt.args[0]
if sync_only not in ("chats", "contacts", "me"):
@@ -212,7 +218,7 @@ async def _parse_encoded_msgid(user: AbstractUser, enc_id: str, type_name: str
@command_handler(help_section=SECTION_MISC,
help_args="<_play ID_>",
help_text="Play a Telegram game.")
-async def play(evt: CommandEvent) -> Optional[Dict]:
+async def play(evt: CommandEvent) -> EventID:
if len(evt.args) < 1:
return await evt.reply("**Usage:** `$cmdprefix+sp play `")
elif not await evt.sender.is_logged_in():
@@ -232,14 +238,14 @@ async def play(evt: CommandEvent) -> Optional[Dict]:
if not isinstance(game, BotCallbackAnswer):
return await evt.reply("Game request response invalid")
- await evt.reply(f"Click [here]({game.url}) to play {msg.media.game.title}:\n\n"
+ return await evt.reply(f"Click [here]({game.url}) to play {msg.media.game.title}:\n\n"
f"{msg.media.game.description}")
@command_handler(help_section=SECTION_MISC,
help_args="<_poll ID_> <_choice number_>",
help_text="Vote in a Telegram poll.")
-async def vote(evt: CommandEvent) -> Optional[Dict]:
+async def vote(evt: CommandEvent) -> EventID:
if len(evt.args) < 1:
return await evt.reply("**Usage:** `$cmdprefix+sp vote `")
elif not await evt.sender.is_logged_in():
diff --git a/mautrix_telegram/config.py b/mautrix_telegram/config.py
index 4aa165fd..eb42b33f 100644
--- a/mautrix_telegram/config.py
+++ b/mautrix_telegram/config.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,149 +13,27 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Any, Dict, Optional, Tuple
-from ruamel.yaml import YAML
+from typing import Any, Dict, List, NamedTuple
from ruamel.yaml.comments import CommentedMap
-import random
-import string
+import os
-yaml = YAML() # type: YAML
-yaml.indent(4)
+from mautrix.types import UserID
+from mautrix.client import Client
+from mautrix.bridge.config import BaseBridgeConfig, ConfigUpdateHelper
+
+Permissions = NamedTuple("Permissions", relaybot=bool, user=bool, puppeting=bool,
+ matrix_puppeting=bool, admin=bool, level=str)
-class DictWithRecursion:
- def __init__(self, data: Optional[CommentedMap] = None) -> None:
- self._data = data or CommentedMap() # type: CommentedMap
-
- @staticmethod
- def _parse_key(key: str) -> Tuple[str, Optional[str]]:
- if '.' not in key:
- return key, None
- key, next_key = key.split('.', 1)
- if len(key) > 0 and key[0] == "[":
- end_index = next_key.index("]")
- key = key[1:] + "." + next_key[:end_index]
- next_key = next_key[end_index + 2:] if len(next_key) > end_index + 1 else None
- return key, next_key
-
- def _recursive_get(self, data: CommentedMap, key: str, default_value: Any) -> Any:
- key, next_key = self._parse_key(key)
- if next_key is not None:
- next_data = data.get(key, CommentedMap())
- return self._recursive_get(next_data, next_key, default_value)
- return data.get(key, default_value)
-
- def get(self, key: str, default_value: Any, allow_recursion: bool = True) -> Any:
- if allow_recursion and '.' in key:
- return self._recursive_get(self._data, key, default_value)
- return self._data.get(key, default_value)
-
- def __getitem__(self, key: str) -> Any:
- return self.get(key, None)
-
- def __contains__(self, key: str) -> bool:
- return self[key] is not None
-
- def _recursive_set(self, data: CommentedMap, key: str, value: Any) -> None:
- key, next_key = self._parse_key(key)
- if next_key is not None:
- if key not in data:
- data[key] = CommentedMap()
- next_data = data.get(key, CommentedMap())
- return self._recursive_set(next_data, next_key, value)
- data[key] = value
-
- def set(self, key: str, value: Any, allow_recursion: bool = True) -> None:
- if allow_recursion and '.' in key:
- self._recursive_set(self._data, key, value)
- return
- self._data[key] = value
-
- def __setitem__(self, key: str, value: Any) -> None:
- self.set(key, value)
-
- def _recursive_del(self, data: CommentedMap, key: str) -> None:
- key, next_key = self._parse_key(key)
- if next_key is not None:
- if key not in data:
- return
- next_data = data[key]
- return self._recursive_del(next_data, next_key)
- try:
- del data[key]
- del data.ca.items[key]
- except KeyError:
- pass
-
- def delete(self, key: str, allow_recursion: bool = True) -> None:
- if allow_recursion and '.' in key:
- self._recursive_del(self._data, key)
- return
- try:
- del self._data[key]
- del self._data.ca.items[key]
- except KeyError:
- pass
-
- def __delitem__(self, key: str) -> None:
- self.delete(key)
-
-
-class Config(DictWithRecursion):
- def __init__(self, path: str, registration_path: str, base_path: str,
- overrides: Dict[str, Any] = None) -> None:
- super().__init__()
- self.path = path # type: str
- self.registration_path = registration_path # type: str
- self.base_path = base_path # type: str
- self._registration = None # type: Optional[Dict]
- self._overrides = overrides or {} # type: Dict[str, Any]
-
+class Config(BaseBridgeConfig):
def __getitem__(self, key: str) -> Any:
try:
- return self._overrides[f"MAUTRIX_TELEGRAM_{key.replace('.', '_').upper()}"]
+ return os.environ[f"MAUTRIX_TELEGRAM_{key.replace('.', '_').upper()}"]
except KeyError:
return super().__getitem__(key)
- def load(self) -> None:
- with open(self.path, 'r') as stream:
- self._data = yaml.load(stream)
-
- def load_base(self) -> Optional[DictWithRecursion]:
- try:
- with open(self.base_path, 'r') as stream:
- return DictWithRecursion(yaml.load(stream))
- except OSError:
- pass
- return None
-
- def save(self) -> None:
- with open(self.path, 'w') as stream:
- yaml.dump(self._data, stream)
- if self._registration and self.registration_path:
- with open(self.registration_path, 'w') as stream:
- yaml.dump(self._registration, stream)
-
- @staticmethod
- def _new_token() -> str:
- return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(64))
-
- def update(self) -> None:
- base = self.load_base()
- if not base:
- return
-
- def copy(from_path, to_path=None) -> None:
- if from_path in self:
- base[to_path or from_path] = self[from_path]
-
- def copy_dict(from_path, to_path=None, override_existing_map=True) -> None:
- if from_path in self:
- to_path = to_path or from_path
- if override_existing_map or to_path not in base:
- base[to_path] = CommentedMap()
- for key, value in self[from_path].items():
- base[to_path][key] = value
+ def do_update(self, helper: ConfigUpdateHelper) -> None:
+ copy, copy_dict, base = helper
copy("homeserver.address")
copy("homeserver.domain")
@@ -202,12 +79,14 @@ class Config(DictWithRecursion):
copy("bridge.displayname_template")
copy("bridge.displayname_preference")
+ copy("bridge.displayname_max_length")
copy("bridge.max_initial_member_sync")
copy("bridge.sync_channel_members")
copy("bridge.skip_deleted_members")
copy("bridge.startup_sync")
copy("bridge.sync_dialog_limit")
+ copy("bridge.sync_direct_chats")
copy("bridge.max_telegram_delete")
copy("bridge.sync_matrix_state")
copy("bridge.allow_matrix_login")
@@ -302,58 +181,43 @@ class Config(DictWithRecursion):
else:
copy("logging")
- self._data = base._data
- self.save()
-
- def _get_permissions(self, key: str) -> Tuple[bool, bool, bool, bool, bool, bool]:
+ def _get_permissions(self, key: str) -> Permissions:
level = self["bridge.permissions"].get(key, "")
admin = level == "admin"
matrix_puppeting = level == "full" or admin
puppeting = level == "puppeting" or matrix_puppeting
user = level == "user" or puppeting
relaybot = level == "relaybot" or user
- return relaybot, user, puppeting, matrix_puppeting, admin, level
+ return Permissions(relaybot, user, puppeting, matrix_puppeting, admin, level)
- def get_permissions(self, mxid: str) -> Tuple[bool, bool, bool, bool, bool, bool]:
- permissions = self["bridge.permissions"] or {}
+ def get_permissions(self, mxid: UserID) -> Permissions:
+ permissions = self["bridge.permissions"]
if mxid in permissions:
return self._get_permissions(mxid)
- homeserver = mxid[mxid.index(":") + 1:]
+ _, homeserver = Client.parse_user_id(mxid)
if homeserver in permissions:
return self._get_permissions(homeserver)
return self._get_permissions("*")
- def generate_registration(self) -> None:
+ @property
+ def namespaces(self) -> Dict[str, List[Dict[str, Any]]]:
homeserver = self["homeserver.domain"]
- username_format = self.get("bridge.username_template", "telegram_{userid}") \
- .format(userid=".+")
- alias_format = self.get("bridge.alias_template", "telegram_{groupname}") \
- .format(groupname=".+")
+ username_format = self["bridge.username_template"].format(userid=".+")
+ alias_format = self["bridge.alias_template"].format(groupname=".+")
+ group_id = ({"group_id": self["appservice.community_id"]}
+ if self["appservice.community_id"] else {})
- self.set("appservice.as_token", self._new_token())
- self.set("appservice.hs_token", self._new_token())
-
- self._registration = {
- "id": self["appservice.id"] or "telegram",
- "as_token": self["appservice.as_token"],
- "hs_token": self["appservice.hs_token"],
- "namespaces": {
- "users": [{
- "exclusive": True,
- "regex": f"@{username_format}:{homeserver}"
- }],
- "aliases": [{
- "exclusive": True,
- "regex": f"#{alias_format}:{homeserver}"
- }]
- },
- "url": self["appservice.address"],
- "sender_localpart": self["appservice.bot_username"],
- "rate_limited": False
+ return {
+ "users": [{
+ "exclusive": True,
+ "regex": f"@{username_format}:{homeserver}",
+ **group_id,
+ }],
+ "aliases": [{
+ "exclusive": True,
+ "regex": f"#{alias_format}:{homeserver}",
+ }]
}
- if self["appservice.community_id"]:
- self._registration["namespaces"]["users"][0]["group_id"] \
- = self["appservice.community_id"]
diff --git a/mautrix_telegram/context.py b/mautrix_telegram/context.py
index 88c332d4..4566de3f 100644
--- a/mautrix_telegram/context.py
+++ b/mautrix_telegram/context.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -15,13 +14,13 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
from typing import Optional, Tuple, TYPE_CHECKING
+import asyncio
+
+from alchemysession import AlchemySessionContainer
+
+from mautrix.appservice import AppService
if TYPE_CHECKING:
- import asyncio
-
- from alchemysession import AlchemySessionContainer
- from mautrix_appservice import AppService
-
from .web import PublicBridgeWebsite, ProvisioningAPI
from .config import Config
from .bot import Bot
@@ -29,17 +28,26 @@ if TYPE_CHECKING:
class Context:
- def __init__(self, az: 'AppService', config: 'Config', loop: 'asyncio.AbstractEventLoop',
- session_container: 'AlchemySessionContainer', bot: Optional['Bot']) -> None:
- self.az = az # type: AppService
- self.config = config # type: Config
- self.loop = loop # type: asyncio.AbstractEventLoop
- self.bot = bot # type: Optional[Bot]
- self.mx = None # type: Optional[MatrixHandler]
- self.session_container = session_container # type: AlchemySessionContainer
- self.public_website = None # type: Optional[PublicBridgeWebsite]
- self.provisioning_api = None # type: Optional[ProvisioningAPI]
+ az: AppService
+ config: 'Config'
+ loop: asyncio.AbstractEventLoop
+ bot: Optional['Bot']
+ mx: Optional['MatrixHandler']
+ session_container: AlchemySessionContainer
+ public_website: Optional['PublicBridgeWebsite']
+ provisioning_api: Optional['ProvisioningAPI']
+
+ def __init__(self, az: AppService, config: 'Config', loop: asyncio.AbstractEventLoop,
+ session_container: AlchemySessionContainer, bot: Optional['Bot']) -> None:
+ self.az = az
+ self.config = config
+ self.loop = loop
+ self.bot = bot
+ self.mx = None
+ self.session_container = session_container
+ self.public_website = None
+ self.provisioning_api = None
@property
- def core(self) -> Tuple['AppService', 'Config', 'asyncio.AbstractEventLoop', Optional['Bot']]:
+ def core(self) -> Tuple[AppService, 'Config', asyncio.AbstractEventLoop, Optional['Bot']]:
return self.az, self.config, self.loop, self.bot
diff --git a/mautrix_telegram/db/__init__.py b/mautrix_telegram/db/__init__.py
index 724af6a2..28106767 100644
--- a/mautrix_telegram/db/__init__.py
+++ b/mautrix_telegram/db/__init__.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,18 +13,19 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from .base import Base
+from sqlalchemy.engine.base import Engine
+
+from mautrix.bridge.db import UserProfile, RoomState
+
from .bot_chat import BotChat
from .message import Message
from .portal import Portal
from .puppet import Puppet
-from .room_state import RoomState
from .telegram_file import TelegramFile
from .user import User, UserPortal, Contact
-from .user_profile import UserProfile
-def init(db_engine) -> None:
+def init(db_engine: Engine) -> None:
for table in (Portal, Message, User, Contact, UserPortal, Puppet, TelegramFile, UserProfile,
RoomState, BotChat):
table.db = db_engine
diff --git a/mautrix_telegram/db/base.py b/mautrix_telegram/db/base.py
deleted file mode 100644
index bbca82a7..00000000
--- a/mautrix_telegram/db/base.py
+++ /dev/null
@@ -1,58 +0,0 @@
-# -*- coding: future_fstrings -*-
-# 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 abc import abstractmethod
-
-from sqlalchemy import Table
-from sqlalchemy.engine.base import Engine
-from sqlalchemy.engine.result import RowProxy
-from sqlalchemy.sql.base import ImmutableColumnCollection
-from sqlalchemy.ext.declarative import declarative_base
-
-
-class BaseBase:
- db = None # type: Engine
- t = None # type: Table
- __table__ = None # type: Table
- c = None # type: ImmutableColumnCollection
-
- @classmethod
- @abstractmethod
- def _one_or_none(cls, rows: RowProxy):
- pass
-
- @classmethod
- def _select_one_or_none(cls, *args):
- return cls._one_or_none(cls.db.execute(cls.t.select().where(*args)))
-
- @property
- @abstractmethod
- def _edit_identity(self):
- pass
-
- def update(self, **values) -> None:
- with self.db.begin() as conn:
- conn.execute(self.t.update()
- .where(self._edit_identity)
- .values(**values))
- for key, value in values.items():
- setattr(self, key, value)
-
- def delete(self) -> None:
- with self.db.begin() as conn:
- conn.execute(self.t.delete().where(self._edit_identity))
-
-Base = declarative_base(cls=BaseBase)
diff --git a/mautrix_telegram/db/base.pyi b/mautrix_telegram/db/base.pyi
deleted file mode 100644
index 8575893d..00000000
--- a/mautrix_telegram/db/base.pyi
+++ /dev/null
@@ -1,26 +0,0 @@
-from abc import abstractmethod
-
-from sqlalchemy import Table
-from sqlalchemy.engine.base import Engine
-from sqlalchemy.engine.result import RowProxy
-from sqlalchemy.sql.base import ImmutableColumnCollection
-from sqlalchemy.ext.declarative import declarative_base
-
-class Base(declarative_base):
- db: Engine
- t: Table
- __table__: Table
- c: ImmutableColumnCollection
-
- @classmethod
- @abstractmethod
- def _one_or_none(cls, rows: RowProxy): ...
-
- @classmethod
- def _select_one_or_none(cls, *args): ...
-
- def _edit_identity(self): ...
-
- def update(self, **values) -> None: ...
-
- def delete(self) -> None: ...
diff --git a/mautrix_telegram/db/bot_chat.py b/mautrix_telegram/db/bot_chat.py
index c7363cc5..9903a630 100644
--- a/mautrix_telegram/db/bot_chat.py
+++ b/mautrix_telegram/db/bot_chat.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -17,28 +16,31 @@
from typing import Iterable
from sqlalchemy import Column, Integer, String
+from sqlalchemy.engine.result import RowProxy
+
+from mautrix.bridge.db import Base
from ..types import TelegramID
-from .base import Base
# Fucking Telegram not telling bots what chats they are in 3:<
class BotChat(Base):
__tablename__ = "bot_chat"
- id = Column(Integer, primary_key=True) # type: TelegramID
- type = Column(String, nullable=False)
+ id: TelegramID = Column(Integer, primary_key=True)
+ type: str = Column(String, nullable=False)
@classmethod
- def delete(cls, chat_id: TelegramID) -> None:
+ def delete_by_id(cls, chat_id: TelegramID) -> None:
with cls.db.begin() as conn:
conn.execute(cls.t.delete().where(cls.c.id == chat_id))
+ @classmethod
+ def scan(cls, row: RowProxy) -> 'BotChat':
+ return cls(id=row[0], type=row[1])
+
@classmethod
def all(cls) -> Iterable['BotChat']:
- rows = cls.db.execute(cls.t.select())
- for row in rows:
- chat_id, chat_type = row
- yield cls(id=chat_id, type=chat_type)
+ return cls._select_all()
def insert(self) -> None:
with self.db.begin() as conn:
diff --git a/mautrix_telegram/db/message.py b/mautrix_telegram/db/message.py
index 327ba508..83082718 100644
--- a/mautrix_telegram/db/message.py
+++ b/mautrix_telegram/db/message.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,42 +13,35 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
+from typing import Optional, Iterator
+
from sqlalchemy import Column, UniqueConstraint, Integer, String, and_, func, desc, select
from sqlalchemy.engine.result import RowProxy
-from typing import Optional, List
+from sqlalchemy.sql.expression import ClauseElement
-from ..types import MatrixRoomID, MatrixEventID, TelegramID
-from .base import Base
+from mautrix.types import RoomID, EventID
+from mautrix.bridge.db import Base
+
+from ..types import TelegramID
class Message(Base):
__tablename__ = "message"
- mxid = Column(String) # type: MatrixEventID
- mx_room = Column(String) # type: MatrixRoomID
- tgid = Column(Integer, primary_key=True) # type: TelegramID
- tg_space = Column(Integer, primary_key=True) # type: TelegramID
- edit_index = Column(Integer, primary_key=True) # type: int
+ mxid: EventID = Column(String)
+ mx_room: RoomID = Column(String)
+ tgid: TelegramID = Column(Integer, primary_key=True)
+ tg_space: TelegramID = Column(Integer, primary_key=True)
+ edit_index: int = Column(Integer, primary_key=True)
- __table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"),)
+ __table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room_2"),)
@classmethod
- def _one_or_none(cls, rows: RowProxy) -> Optional['Message']:
- try:
- mxid, mx_room, tgid, tg_space, edit_index = next(rows)
- return cls(mxid=mxid, mx_room=mx_room, tgid=tgid, tg_space=tg_space,
- edit_index=edit_index)
- except StopIteration:
- return None
-
- @staticmethod
- def _all(rows: RowProxy) -> List['Message']:
- return [Message(mxid=row[0], mx_room=row[1], tgid=row[2], tg_space=row[3],
- edit_index=row[4])
- for row in rows]
+ def scan(cls, row: RowProxy) -> 'Message':
+ return cls(mxid=row[0], mx_room=row[1], tgid=row[2], tg_space=row[3], edit_index=row[4])
@classmethod
- def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> List['Message']:
+ def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> Iterator['Message']:
return cls._all(cls.db.execute(cls.t.select().where(and_(cls.c.tgid == tgid,
cls.c.tg_space == tg_space))))
@@ -69,7 +61,7 @@ class Message(Base):
return cls._one_or_none(cls.db.execute(query))
@classmethod
- def count_spaces_by_mxid(cls, mxid: MatrixEventID, mx_room: MatrixRoomID) -> int:
+ def count_spaces_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> int:
rows = cls.db.execute(select([func.count(cls.c.tg_space)])
.where(and_(cls.c.mxid == mxid, cls.c.mx_room == mx_room)))
try:
@@ -79,7 +71,7 @@ class Message(Base):
return 0
@classmethod
- def get_by_mxid(cls, mxid: MatrixEventID, mx_room: MatrixRoomID, tg_space: TelegramID
+ def get_by_mxid(cls, mxid: EventID, mx_room: RoomID, tg_space: TelegramID
) -> Optional['Message']:
return cls._select_one_or_none(and_(cls.c.mxid == mxid,
cls.c.mx_room == mx_room,
@@ -95,14 +87,14 @@ class Message(Base):
.values(**values))
@classmethod
- def update_by_mxid(cls, s_mxid: MatrixEventID, s_mx_room: MatrixRoomID, **values) -> None:
+ def update_by_mxid(cls, s_mxid: EventID, s_mx_room: RoomID, **values) -> None:
with cls.db.begin() as conn:
conn.execute(cls.t.update()
.where(and_(cls.c.mxid == s_mxid, cls.c.mx_room == s_mx_room))
.values(**values))
@property
- def _edit_identity(self):
+ def _edit_identity(self) -> ClauseElement:
return and_(self.c.tgid == self.tgid, self.c.tg_space == self.tg_space,
self.c.edit_index == self.edit_index)
diff --git a/mautrix_telegram/db/portal.py b/mautrix_telegram/db/portal.py
index fd4a1ba1..c5a04846 100644
--- a/mautrix_telegram/db/portal.py
+++ b/mautrix_telegram/db/portal.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,55 +13,52 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from sqlalchemy import Column, Integer, String, Boolean, Text, and_
-from sqlalchemy.engine.result import RowProxy
from typing import Optional
-from ..types import MatrixRoomID, TelegramID
-from .base import Base
+from sqlalchemy import Column, Integer, String, Boolean, Text, and_
+from sqlalchemy.engine.result import RowProxy
+from sqlalchemy.sql.expression import ClauseElement
+
+from mautrix.types import RoomID
+from mautrix.bridge.db import Base
+
+from ..types import TelegramID
class Portal(Base):
__tablename__ = "portal"
# Telegram chat information
- tgid = Column(Integer, primary_key=True) # type: TelegramID
- tg_receiver = Column(Integer, primary_key=True) # type: TelegramID
- peer_type = Column(String, nullable=False)
- megagroup = Column(Boolean)
+ tgid: TelegramID = Column(Integer, primary_key=True)
+ tg_receiver: TelegramID = Column(Integer, primary_key=True)
+ peer_type: str = Column(String, nullable=False)
+ megagroup: bool = Column(Boolean)
# Matrix portal information
- mxid = Column(String, unique=True, nullable=True) # type: Optional[MatrixRoomID]
+ mxid: RoomID = Column(String, unique=True, nullable=True)
- config = Column(Text, nullable=True)
+ config: str = Column(Text, nullable=True)
# Telegram chat metadata
- username = Column(String, nullable=True)
- title = Column(String, nullable=True)
- about = Column(String, nullable=True)
- photo_id = Column(String, nullable=True)
+ username: str = Column(String, nullable=True)
+ title: str = Column(String, nullable=True)
+ about: str = Column(String, nullable=True)
+ photo_id: str = Column(String, nullable=True)
@classmethod
- def scan(cls, row) -> Optional['Portal']:
+ def scan(cls, row: RowProxy) -> Optional['Portal']:
(tgid, tg_receiver, peer_type, megagroup, mxid, config, username, title, about,
photo_id) = row
return cls(tgid=tgid, tg_receiver=tg_receiver, peer_type=peer_type, megagroup=megagroup,
mxid=mxid, config=config, username=username, title=title, about=about,
photo_id=photo_id)
- @classmethod
- def _one_or_none(cls, rows: RowProxy) -> Optional['Portal']:
- try:
- return cls.scan(next(rows))
- except StopIteration:
- return None
-
@classmethod
def get_by_tgid(cls, tgid: TelegramID, tg_receiver: TelegramID) -> Optional['Portal']:
return cls._select_one_or_none(and_(cls.c.tgid == tgid, cls.c.tg_receiver == tg_receiver))
@classmethod
- def get_by_mxid(cls, mxid: MatrixRoomID) -> Optional['Portal']:
+ def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']:
return cls._select_one_or_none(cls.c.mxid == mxid)
@classmethod
@@ -70,7 +66,7 @@ class Portal(Base):
return cls._select_one_or_none(cls.c.username == username)
@property
- def _edit_identity(self):
+ def _edit_identity(self) -> ClauseElement:
return and_(self.c.tgid == self.tgid, self.c.tg_receiver == self.tg_receiver)
def insert(self) -> None:
diff --git a/mautrix_telegram/db/puppet.py b/mautrix_telegram/db/puppet.py
index 489ef672..8b3027e2 100644
--- a/mautrix_telegram/db/puppet.py
+++ b/mautrix_telegram/db/puppet.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,44 +13,42 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from sqlalchemy import Column, Integer, String, Boolean
-from sqlalchemy.engine.result import RowProxy
-from sqlalchemy.sql import expression
from typing import Optional, Iterable
-from ..types import MatrixUserID, MatrixRoomID, TelegramID
-from .base import Base
+from sqlalchemy import Column, Integer, String, Boolean
+from sqlalchemy.sql import expression
+from sqlalchemy.engine.result import RowProxy
+from sqlalchemy.sql.expression import ClauseElement
+
+from mautrix.types import UserID, SyncToken
+from mautrix.bridge.db import Base
+
+from ..types import TelegramID
class Puppet(Base):
__tablename__ = "puppet"
- id = Column(Integer, primary_key=True) # type: TelegramID
- custom_mxid = Column(String, nullable=True) # type: Optional[MatrixUserID]
- access_token = Column(String, nullable=True)
- displayname = Column(String, nullable=True)
- displayname_source = Column(Integer, nullable=True) # type: Optional[TelegramID]
- username = Column(String, nullable=True)
- photo_id = Column(String, nullable=True)
- is_bot = Column(Boolean, nullable=True)
- matrix_registered = Column(Boolean, nullable=False, server_default=expression.false())
- disable_updates = Column(Boolean, nullable=False, server_default=expression.false())
+ id: TelegramID = Column(Integer, primary_key=True)
+ custom_mxid: UserID = Column(String, nullable=True)
+ access_token: str = Column(String, nullable=True)
+ next_batch: SyncToken = Column(String, nullable=True)
+ displayname: str = Column(String, nullable=True)
+ displayname_source: TelegramID = Column(Integer, nullable=True)
+ username: str = Column(String, nullable=True)
+ photo_id: str = Column(String, nullable=True)
+ is_bot: bool = Column(Boolean, nullable=True)
+ matrix_registered: bool = Column(Boolean, nullable=False, server_default=expression.false())
+ disable_updates: bool = Column(Boolean, nullable=False, server_default=expression.false())
@classmethod
- def scan(cls, row) -> Optional['Puppet']:
- (id, custom_mxid, access_token, displayname, displayname_source, username, photo_id,
- is_bot, matrix_registered, disable_updates) = row
- return cls(id=id, custom_mxid=custom_mxid, access_token=access_token,
- displayname=displayname, displayname_source=displayname_source,
- username=username, photo_id=photo_id, is_bot=is_bot,
- matrix_registered=matrix_registered, disable_updates=disable_updates)
-
- @classmethod
- def _one_or_none(cls, rows: RowProxy) -> Optional['Puppet']:
- try:
- return cls.scan(next(rows))
- except StopIteration:
- return None
+ def scan(cls, row: RowProxy) -> Optional['Puppet']:
+ (id, custom_mxid, access_token, next_batch, displayname, displayname_source, username,
+ photo_id, is_bot, matrix_registered, disable_updates) = row
+ return cls(id=id, custom_mxid=custom_mxid, access_token=access_token, username=username,
+ next_batch=next_batch, displayname=displayname, photo_id=photo_id,
+ displayname_source=displayname_source, matrix_registered=matrix_registered,
+ disable_updates=disable_updates, is_bot=is_bot)
@classmethod
def all_with_custom_mxid(cls) -> Iterable['Puppet']:
@@ -64,7 +61,7 @@ class Puppet(Base):
return cls._select_one_or_none(cls.c.id == tgid)
@classmethod
- def get_by_custom_mxid(cls, mxid: MatrixUserID) -> Optional['Puppet']:
+ def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.custom_mxid == mxid)
@classmethod
@@ -76,13 +73,14 @@ class Puppet(Base):
return cls._select_one_or_none(cls.c.displayname == displayname)
@property
- def _edit_identity(self):
+ def _edit_identity(self) -> ClauseElement:
return self.c.id == self.id
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(
id=self.id, custom_mxid=self.custom_mxid, access_token=self.access_token,
- displayname=self.displayname, displayname_source=self.displayname_source,
- username=self.username, photo_id=self.photo_id, is_bot=self.is_bot,
- matrix_registered=self.matrix_registered, disable_updates=self.disable_updates))
+ next_batch=self.next_batch, displayname=self.displayname, username=self.username,
+ displayname_source=self.displayname_source, photo_id=self.photo_id,
+ is_bot=self.is_bot, matrix_registered=self.matrix_registered,
+ disable_updates=self.disable_updates))
diff --git a/mautrix_telegram/db/room_state.py b/mautrix_telegram/db/room_state.py
deleted file mode 100644
index 0aa9aa84..00000000
--- a/mautrix_telegram/db/room_state.py
+++ /dev/null
@@ -1,62 +0,0 @@
-# -*- coding: future_fstrings -*-
-# 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 sqlalchemy import Column, String, Text
-from typing import Dict, Optional
-import json
-
-from ..types import MatrixRoomID
-from .base import Base
-
-
-class RoomState(Base):
- __tablename__ = "mx_room_state"
-
- room_id = Column(String, primary_key=True) # type: MatrixRoomID
- power_levels = Column("power_levels", Text, nullable=True) # type: Optional[Dict]
-
- @property
- def _power_levels_text(self) -> Optional[str]:
- return json.dumps(self.power_levels) if self.power_levels else None
-
- @property
- def has_power_levels(self) -> bool:
- return bool(self.power_levels)
-
- @classmethod
- def get(cls, room_id: MatrixRoomID) -> Optional['RoomState']:
- rows = cls.db.execute(cls.t.select().where(cls.c.room_id == room_id))
- try:
- room_id, power_levels_text = next(rows)
- return cls(room_id=room_id, power_levels=(json.loads(power_levels_text)
- if power_levels_text else None))
- except StopIteration:
- return None
-
- def update(self) -> None:
- with self.db.begin() as conn:
- conn.execute(self.t.update()
- .where(self.c.room_id == self.room_id)
- .values(power_levels=self._power_levels_text))
-
- @property
- def _edit_identity(self):
- return self.c.room_id == self.room_id
-
- def insert(self) -> None:
- with self.db.begin() as conn:
- conn.execute(self.t.insert().values(room_id=self.room_id,
- power_levels=self._power_levels_text))
diff --git a/mautrix_telegram/db/telegram_file.py b/mautrix_telegram/db/telegram_file.py
index 4b36b10a..909bd782 100644
--- a/mautrix_telegram/db/telegram_file.py
+++ b/mautrix_telegram/db/telegram_file.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,38 +13,41 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from sqlalchemy import Column, ForeignKey, Integer, BigInteger, String, Boolean
from typing import Optional
-from .base import Base
+from sqlalchemy import Column, ForeignKey, Integer, BigInteger, String, Boolean
+from sqlalchemy.engine.result import RowProxy
+
+from mautrix.types import ContentURI
+from mautrix.bridge.db import Base
class TelegramFile(Base):
__tablename__ = "telegram_file"
- id = Column(String, primary_key=True)
- mxc = Column(String)
- mime_type = Column(String)
- was_converted = Column(Boolean)
- timestamp = Column(BigInteger)
- size = Column(Integer, nullable=True)
- width = Column(Integer, nullable=True)
- height = Column(Integer, nullable=True)
- thumbnail_id = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True)
- thumbnail = None # type: Optional[TelegramFile]
+ id: str = Column(String, primary_key=True)
+ mxc: ContentURI = Column(String)
+ mime_type: str = Column(String)
+ was_converted: bool = Column(Boolean)
+ timestamp: int = Column(BigInteger)
+ size: int = Column(Integer, nullable=True)
+ width: int = Column(Integer, nullable=True)
+ height: int = Column(Integer, nullable=True)
+ thumbnail_id: str = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True)
+ thumbnail: Optional['TelegramFile'] = None
+
+ @classmethod
+ def scan(cls, row: RowProxy) -> 'TelegramFile':
+ loc_id, mxc, mime, conv, ts, s, w, h, thumb_id = row
+ thumb = None
+ if thumb_id:
+ thumb = cls.get(thumb_id)
+ return cls(id=loc_id, mxc=mxc, mime_type=mime, was_converted=conv, timestamp=ts,
+ size=s, width=w, height=h, thumbnail_id=thumb_id, thumbnail=thumb)
@classmethod
def get(cls, loc_id: str) -> Optional['TelegramFile']:
- rows = cls.db.execute(cls.t.select().where(cls.c.id == loc_id))
- try:
- loc_id, mxc, mime, conv, ts, s, w, h, thumb_id = next(rows)
- thumb = None
- if thumb_id:
- thumb = cls.get(thumb_id)
- return cls(id=loc_id, mxc=mxc, mime_type=mime, was_converted=conv, timestamp=ts,
- size=s, width=w, height=h, thumbnail_id=thumb_id, thumbnail=thumb)
- except StopIteration:
- return None
+ return cls._select_one_or_none(cls.c.id == loc_id)
def insert(self) -> None:
with self.db.begin() as conn:
diff --git a/mautrix_telegram/db/user.py b/mautrix_telegram/db/user.py
index 61b39263..1580e277 100644
--- a/mautrix_telegram/db/user.py
+++ b/mautrix_telegram/db/user.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,46 +13,43 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String
-from sqlalchemy.engine.result import RowProxy
from typing import Optional, Iterable, Tuple
-from ..types import MatrixUserID, TelegramID
-from .base import Base
+from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String
+from sqlalchemy.engine.result import RowProxy
+from sqlalchemy.sql.expression import ClauseElement
+
+from mautrix.types import UserID
+from mautrix.bridge.db import Base
+
+from ..types import TelegramID
class User(Base):
__tablename__ = "user"
- mxid = Column(String, primary_key=True) # type: MatrixUserID
- tgid = Column(Integer, nullable=True, unique=True) # type: Optional[TelegramID]
- tg_username = Column(String, nullable=True)
- tg_phone = Column(String, nullable=True)
- saved_contacts = Column(Integer, default=0, nullable=False)
+ mxid: UserID = Column(String, primary_key=True)
+ tgid: Optional[TelegramID] = Column(Integer, nullable=True, unique=True)
+ tg_username: str = Column(String, nullable=True)
+ tg_phone: str = Column(String, nullable=True)
+ saved_contacts: int = Column(Integer, default=0, nullable=False)
@classmethod
- def _one_or_none(cls, rows: RowProxy) -> Optional['User']:
- try:
- mxid, tgid, tg_username, tg_phone, saved_contacts = next(rows)
- return cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone,
- saved_contacts=saved_contacts)
- except StopIteration:
- return None
+ def scan(cls, row: RowProxy) -> 'User':
+ mxid, tgid, tg_username, tg_phone, saved_contacts = row
+ return cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone,
+ saved_contacts=saved_contacts)
@classmethod
- def all(cls) -> Iterable['User']:
- rows = cls.db.execute(cls.t.select())
- for row in rows:
- mxid, tgid, tg_username, tg_phone, saved_contacts = row
- yield cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone,
- saved_contacts=saved_contacts)
+ def all_with_tgid(cls) -> Iterable['User']:
+ return cls._select_all(cls.c.tgid != None)
@classmethod
def get_by_tgid(cls, tgid: TelegramID) -> Optional['User']:
return cls._select_one_or_none(cls.c.tgid == tgid)
@classmethod
- def get_by_mxid(cls, mxid: MatrixUserID) -> Optional['User']:
+ def get_by_mxid(cls, mxid: UserID) -> Optional['User']:
return cls._select_one_or_none(cls.c.mxid == mxid)
@classmethod
@@ -61,7 +57,7 @@ class User(Base):
return cls._select_one_or_none(cls.c.tg_username == username)
@property
- def _edit_identity(self):
+ def _edit_identity(self) -> ClauseElement:
return self.c.mxid == self.mxid
def insert(self) -> None:
@@ -113,10 +109,10 @@ class User(Base):
class UserPortal(Base):
__tablename__ = "user_portal"
- user = Column(Integer, ForeignKey("user.tgid", onupdate="CASCADE", ondelete="CASCADE"),
- primary_key=True) # type: TelegramID
- portal = Column(Integer, primary_key=True) # type: TelegramID
- portal_receiver = Column(Integer, primary_key=True) # type: TelegramID
+ user: TelegramID = Column(Integer, ForeignKey("user.tgid", onupdate="CASCADE",
+ ondelete="CASCADE"), primary_key=True)
+ portal: TelegramID = Column(Integer, primary_key=True)
+ portal_receiver: TelegramID = Column(Integer, primary_key=True)
__table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"),
("portal.tgid", "portal.tg_receiver"),
@@ -126,5 +122,5 @@ class UserPortal(Base):
class Contact(Base):
__tablename__ = "contact"
- user = Column(Integer, ForeignKey("user.tgid"), primary_key=True) # type: TelegramID
- contact = Column(Integer, ForeignKey("puppet.id"), primary_key=True) # type: TelegramID
+ user: TelegramID = Column(Integer, ForeignKey("user.tgid"), primary_key=True)
+ contact: TelegramID = Column(Integer, ForeignKey("puppet.id"), primary_key=True)
diff --git a/mautrix_telegram/db/user_profile.py b/mautrix_telegram/db/user_profile.py
deleted file mode 100644
index d09262b5..00000000
--- a/mautrix_telegram/db/user_profile.py
+++ /dev/null
@@ -1,69 +0,0 @@
-# -*- coding: future_fstrings -*-
-# 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 sqlalchemy import Column, String, and_
-from typing import Dict, Optional
-
-from ..types import MatrixUserID, MatrixRoomID
-from .base import Base
-
-
-class UserProfile(Base):
- __tablename__ = "mx_user_profile"
-
- room_id = Column(String, primary_key=True) # type: MatrixRoomID
- user_id = Column(String, primary_key=True) # type: MatrixUserID
- membership = Column(String, nullable=False, default="leave")
- displayname = Column(String, nullable=True)
- avatar_url = Column(String, nullable=True)
-
- def dict(self) -> Dict[str, str]:
- return {
- "membership": self.membership,
- "displayname": self.displayname,
- "avatar_url": self.avatar_url,
- }
-
- @classmethod
- def get(cls, room_id: MatrixRoomID, user_id: MatrixUserID) -> Optional['UserProfile']:
- rows = cls.db.execute(
- cls.t.select().where(and_(cls.c.room_id == room_id, cls.c.user_id == user_id)))
- try:
- room_id, user_id, membership, displayname, avatar_url = next(rows)
- return cls(room_id=room_id, user_id=user_id, membership=membership,
- displayname=displayname, avatar_url=avatar_url)
- except StopIteration:
- return None
-
- @classmethod
- def delete_all(cls, room_id: MatrixRoomID) -> None:
- with cls.db.begin() as conn:
- conn.execute(cls.t.delete().where(cls.c.room_id == room_id))
-
- def update(self) -> None:
- super().update(membership=self.membership, displayname=self.displayname,
- avatar_url=self.avatar_url)
-
- @property
- def _edit_identity(self):
- return and_(self.c.room_id == self.room_id, self.c.user_id == self.user_id)
-
- def insert(self) -> None:
- with self.db.begin() as conn:
- conn.execute(self.t.insert().values(room_id=self.room_id, user_id=self.user_id,
- membership=self.membership,
- displayname=self.displayname,
- avatar_url=self.avatar_url))
diff --git a/mautrix_telegram/formatter/from_matrix/__init__.py b/mautrix_telegram/formatter/from_matrix/__init__.py
index cfcb8ba2..4cac62b4 100644
--- a/mautrix_telegram/formatter/from_matrix/__init__.py
+++ b/mautrix_telegram/formatter/from_matrix/__init__.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,29 +13,30 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Optional, List, Tuple, Callable, Pattern, Match, TYPE_CHECKING, Dict, Any
+from typing import Optional, List, Tuple, Callable, Pattern, Match, TYPE_CHECKING
import re
import logging
from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityItalic,
TypeMessageEntity)
+from telethon.helpers import add_surrogate, del_surrogate
+
+from mautrix.types import RoomID, MessageEventContent
from ... import puppet as pu
-from ...types import TelegramID, MatrixRoomID
+from ...types import TelegramID
from ...db import Message as DBMessage
-from ..util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
- trim_reply_fallback_text)
from .parser import ParsedMessage, parse_html
if TYPE_CHECKING:
from ...context import Context
-log = logging.getLogger("mau.fmt.mx") # type: logging.Logger
-should_bridge_plaintext_highlights = False # type: bool
+log: logging.Logger = logging.getLogger("mau.fmt.mx")
+should_bridge_plaintext_highlights: bool = False
-command_regex = re.compile(r"^!([A-Za-z0-9@]+)") # type: Pattern
-not_command_regex = re.compile(r"^\\(![A-Za-z0-9@]+)") # type: Pattern
-plain_mention_regex = None # type: Optional[Pattern]
+command_regex: Pattern = re.compile(r"^!([A-Za-z0-9@]+)")
+not_command_regex: Pattern = re.compile(r"^\\(![A-Za-z0-9@]+)")
+plain_mention_regex: Optional[Pattern] = None
def plain_mention_to_html(match: Match) -> str:
@@ -49,17 +49,22 @@ def plain_mention_to_html(match: Match) -> str:
return "".join(match.groups())
+MAX_LENGTH = 4096
+CUTOFF_TEXT = " [message cut]"
+CUT_MAX_LENGTH = MAX_LENGTH - len(CUTOFF_TEXT)
+
+
def cut_long_message(message: str, entities: List[TypeMessageEntity]) -> ParsedMessage:
- if len(message) > 4096:
- message = message[0:4082] + " [message cut]"
+ if len(message) > MAX_LENGTH:
+ message = message[0:CUT_MAX_LENGTH] + CUTOFF_TEXT
new_entities = []
for entity in entities:
- if entity.offset > 4082:
+ if entity.offset > CUT_MAX_LENGTH:
continue
- if entity.offset + entity.length > 4082:
- entity.length = 4082 - entity.offset
+ if entity.offset + entity.length > CUT_MAX_LENGTH:
+ entity.length = CUT_MAX_LENGTH - entity.offset
new_entities.append(entity)
- new_entities.append(MessageEntityItalic(4082, len(" [message cut]")))
+ new_entities.append(MessageEntityItalic(CUT_MAX_LENGTH, len(CUTOFF_TEXT)))
entities = new_entities
return message, entities
@@ -76,8 +81,8 @@ def matrix_to_telegram(html: str) -> ParsedMessage:
if should_bridge_plaintext_highlights:
html = plain_mention_regex.sub(plain_mention_to_html, html)
- text, entities = parse_html(add_surrogates(html))
- text = remove_surrogates(text.strip())
+ text, entities = parse_html(add_surrogate(html))
+ text = del_surrogate(text.strip())
text, entities = cut_long_message(text, entities)
return text, entities
@@ -85,26 +90,12 @@ def matrix_to_telegram(html: str) -> ParsedMessage:
raise FormatError(f"Failed to convert Matrix format: {html}") from e
-def matrix_reply_to_telegram(content: Dict[str, Any], tg_space: TelegramID,
- room_id: Optional[MatrixRoomID] = None) -> Optional[TelegramID]:
- relates_to = content.get("m.relates_to", None) or {}
- if not relates_to:
- return None
- reply = (relates_to if relates_to.get("rel_type", None) == "m.reference"
- else relates_to.get("m.in_reply_to", None) or {})
- if not reply:
- return None
- room_id = room_id or reply.get("room_id", None)
- event_id = reply.get("event_id", None)
+def matrix_reply_to_telegram(content: MessageEventContent, tg_space: TelegramID,
+ room_id: Optional[RoomID] = None) -> Optional[TelegramID]:
+ event_id = content.get_reply_to()
if not event_id:
return
-
- try:
- if content["format"] == "org.matrix.custom.html":
- content["formatted_body"] = trim_reply_fallback_html(content["formatted_body"])
- except KeyError:
- pass
- content["body"] = trim_reply_fallback_text(content["body"])
+ content.trim_reply_fallback()
message = DBMessage.get_by_mxid(event_id, room_id, tg_space)
if message:
@@ -124,10 +115,10 @@ def matrix_text_to_telegram(text: str) -> ParsedMessage:
return text, entities
-def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[str], str]]:
+def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[Match], str]]:
entities = []
- def replacer(match) -> str:
+ def replacer(match: Match) -> str:
puppet = pu.Puppet.find_by_displayname(match.group(2))
if puppet:
offset = match.start()
@@ -148,7 +139,7 @@ def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[str], st
def init_mx(context: "Context") -> None:
global plain_mention_regex, should_bridge_plaintext_highlights
config = context.config
- dn_template = config.get("bridge.displayname_template", "{displayname} (Telegram)")
+ dn_template = config["bridge.displayname_template"]
dn_template = re.escape(dn_template).replace(re.escape("{displayname}"), "[^>]+")
plain_mention_regex = re.compile(f"^({dn_template})")
- should_bridge_plaintext_highlights = config["bridge.plaintext_highlights"] or False
+ should_bridge_plaintext_highlights = config["bridge.plaintext_highlights"]
diff --git a/mautrix_telegram/formatter/from_matrix/html_reader.py b/mautrix_telegram/formatter/from_matrix/html_reader.py
deleted file mode 100644
index d707537c..00000000
--- a/mautrix_telegram/formatter/from_matrix/html_reader.py
+++ /dev/null
@@ -1,66 +0,0 @@
-# -*- coding: future_fstrings -*-
-# 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 Dict, List, Tuple
-
-from html.parser import HTMLParser
-
-
-class HTMLNode(list):
- def __init__(self, tag: str, attrs: List[Tuple[str, str]]):
- super().__init__()
- self.tag = tag # type: str
- self.text = "" # type: str
- self.tail = "" # type: str
- self.attrib = dict(attrs) # type: Dict[str, str]
-
-
-class NodeifyingParser(HTMLParser):
- # From https://www.w3.org/TR/html5/syntax.html#writing-html-documents-elements
- void_tags = ("area", "base", "br", "col", "command", "embed", "hr", "img", "input", "link",
- "meta", "param", "source", "track", "wbr")
-
- def __init__(self):
- super().__init__()
- self.stack = [HTMLNode("html", [])] # type: List[HTMLNode]
-
- def handle_starttag(self, tag, attrs):
- node = HTMLNode(tag, attrs)
- self.stack[-1].append(node)
- if tag not in self.void_tags:
- self.stack.append(node)
-
- def handle_startendtag(self, tag, attrs):
- self.stack[-1].append(HTMLNode(tag, attrs))
-
- def handle_endtag(self, tag):
- if tag == self.stack[-1].tag:
- self.stack.pop()
-
- def handle_data(self, data):
- if len(self.stack[-1]) > 0:
- self.stack[-1][-1].tail += data
- else:
- self.stack[-1].text += data
-
- def error(self, message):
- pass
-
-
-def read_html(data: str) -> HTMLNode:
- parser = NodeifyingParser()
- parser.feed(data)
- return parser.stack[0]
diff --git a/mautrix_telegram/formatter/from_matrix/html_reader.pyi b/mautrix_telegram/formatter/from_matrix/html_reader.pyi
deleted file mode 100644
index d292ff3c..00000000
--- a/mautrix_telegram/formatter/from_matrix/html_reader.pyi
+++ /dev/null
@@ -1,11 +0,0 @@
-from typing import Dict, List
-
-
-class HTMLNode(List['HTMLNode']):
- tag: str
- text: str
- tail: str
- attrib: Dict[str, str]
-
-
-def read_html(data: str) -> HTMLNode: ...
diff --git a/mautrix_telegram/formatter/from_matrix/parser.py b/mautrix_telegram/formatter/from_matrix/parser.py
index ad019098..fdcf5aac 100644
--- a/mautrix_telegram/formatter/from_matrix/parser.py
+++ b/mautrix_telegram/formatter/from_matrix/parser.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,240 +13,77 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import List, Tuple, Pattern
-import re
+from typing import List, Tuple, Optional
-from telethon.tl.types import (MessageEntityMention as Mention, MessageEntityBotCommand as Command,
- MessageEntityMentionName as MentionName, MessageEntityUrl as URL,
- MessageEntityEmail as Email, MessageEntityTextUrl as TextURL,
- MessageEntityBold as Bold, MessageEntityItalic as Italic,
- MessageEntityCode as Code, MessageEntityPre as Pre,
- MessageEntityStrike as Strike, MessageEntityUnderline as Underline,
- MessageEntityBlockquote as Blockquote, TypeMessageEntity)
+from telethon.tl.types import TypeMessageEntity
+
+from mautrix.types import UserID, RoomID
+from mautrix.util.formatter import MatrixParser as BaseMatrixParser, RecursionContext
+from mautrix.util.formatter.html_reader_htmlparser import read_html, HTMLNode
from ... import user as u, puppet as pu, portal as po
-from ...types import MatrixUserID
-from .telegram_message import TelegramMessage, Entity, offset_length_multiply
+from .telegram_message import TelegramMessage, TelegramEntityType
-from .html_reader import HTMLNode, read_html
ParsedMessage = Tuple[str, List[TypeMessageEntity]]
def parse_html(input_html: str) -> ParsedMessage:
- return MatrixParser.parse(input_html)
+ msg = MatrixParser.parse(input_html)
+ return msg.text, msg.telegram_entities
-class RecursionContext:
- def __init__(self, strip_linebreaks: bool = True, ul_depth: int = 0):
- self.strip_linebreaks = strip_linebreaks # type: bool
- self.ul_depth = ul_depth # type: int
- self._inited = True # type: bool
-
- def __setattr__(self, key, value):
- if getattr(self, "_inited", False) is True:
- raise TypeError("'RecursionContext' object is immutable")
- super(RecursionContext, self).__setattr__(key, value)
-
- def enter_list(self) -> 'RecursionContext':
- return RecursionContext(strip_linebreaks=self.strip_linebreaks, ul_depth=self.ul_depth + 1)
-
- def enter_code_block(self) -> 'RecursionContext':
- return RecursionContext(strip_linebreaks=False, ul_depth=self.ul_depth)
-
-
-class MatrixParser:
- mention_regex = re.compile("https://matrix.to/#/(@.+:.+)") # type: Pattern
- room_regex = re.compile("https://matrix.to/#/(#.+:.+)") # type: Pattern
- block_tags = ("p", "pre", "blockquote",
- "ol", "ul", "li",
- "h1", "h2", "h3", "h4", "h5", "h6",
- "div", "hr", "table") # type: Tuple[str, ...]
- list_bullets = ("●", "○", "■", "‣") # type: Tuple[str, ...]
+class MatrixParser(BaseMatrixParser[TelegramMessage]):
+ e = TelegramEntityType
+ fs = TelegramMessage
+ read_html = read_html
@classmethod
- def list_bullet(cls, depth: int) -> str:
- return cls.list_bullets[(depth - 1) % len(cls.list_bullets)] + " "
-
- @classmethod
- def list_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
- ordered = node.tag == "ol"
- tagged_children = cls.node_to_tagged_tmessages(node, ctx)
- counter = 1
- indent_length = 0
- if ordered:
- try:
- counter = int(node.attrib.get("start", "1"))
- except ValueError:
- counter = 1
-
- longest_index = counter - 1 + len(tagged_children)
- indent_length = len(str(longest_index))
- indent = (indent_length + 4) * " "
- children = [] # type: List[TelegramMessage]
- for child, tag in tagged_children:
- if tag != "li":
- continue
-
- if ordered:
- prefix = f"{counter}. "
- counter += 1
- else:
- prefix = cls.list_bullet(ctx.ul_depth)
- child = child.prepend(prefix)
- parts = child.split("\n")
- parts = parts[:1] + [part.prepend(indent) for part in parts[1:]]
- child = TelegramMessage.join(parts, "\n")
- children.append(child)
- return TelegramMessage.join(children, "\n")
-
- @classmethod
- def header_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
- children = cls.node_to_tmessages(node, ctx)
- length = int(node.tag[1])
- prefix = "#" * length + " "
- return TelegramMessage.join(children, "").prepend(prefix).format(Bold)
-
- @classmethod
- def basic_format_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
+ def custom_node_to_fstring(cls, node: HTMLNode, ctx: RecursionContext
+ ) -> Optional[TelegramMessage]:
msg = cls.tag_aware_parse_node(node, ctx)
- if node.tag in ("b", "strong"):
- msg.format(Bold)
- elif node.tag in ("i", "em"):
- msg.format(Italic)
- elif node.tag in ("s", "strike", "del"):
- msg.format(Strike)
- elif node.tag in ("u", "ins"):
- msg.format(Underline)
- elif node == "blockquote":
- msg.format(Blockquote)
- elif node.tag == "command":
- msg.format(Command)
+ if node.tag == "command":
+ msg.format(TelegramEntityType.COMMAND)
+ return None
+ @classmethod
+ def user_pill_to_fstring(cls, msg: TelegramMessage, user_id: UserID) -> TelegramMessage:
+ user = (pu.Puppet.get_by_mxid(user_id)
+ or u.User.get_by_mxid(user_id, create=False))
+ if not user:
+ return msg
+ if user.username:
+ return TelegramMessage(f"@{user.username}").format(TelegramEntityType.MENTION)
+ elif user.tgid:
+ displayname = user.plain_displayname or msg.text
+ return TelegramMessage(displayname).format(TelegramEntityType.MENTION_NAME,
+ user_id=user.tgid)
return msg
@classmethod
- def link_to_tstring(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
- msg = cls.tag_aware_parse_node(node, ctx)
- href = node.attrib.get("href", "")
- if not href:
- return msg
-
- if href.startswith("mailto:"):
- return TelegramMessage(href[len("mailto:"):]).format(Email)
-
- mention = cls.mention_regex.match(href)
- if mention:
- mxid = MatrixUserID(mention.group(1))
- user = (pu.Puppet.get_by_mxid(mxid)
- or u.User.get_by_mxid(mxid, create=False))
- if not user:
- return msg
- if user.username:
- return TelegramMessage(f"@{user.username}").format(Mention)
- elif user.tgid:
- displayname = user.plain_displayname or msg.text
- return TelegramMessage(displayname).format(MentionName, user_id=user.tgid)
- return msg
-
- room = cls.room_regex.match(href)
- if room:
- username = po.Portal.get_username_from_mx_alias(room.group(1))
- portal = po.Portal.find_by_username(username)
- if portal and portal.username:
- return TelegramMessage(f"@{portal.username}").format(Mention)
-
- return (msg.format(URL)
- if msg.text == href
- else msg.format(TextURL, url=href))
+ def url_to_fstring(cls, msg: TelegramMessage, url: str) -> TelegramMessage:
+ if url == msg.text:
+ return msg.format(cls.e.URL)
+ else:
+ return msg.format(cls.e.INLINE_URL, url=url)
@classmethod
- def blockquote_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
+ def room_pill_to_fstring(cls, msg: TelegramMessage, room_id: RoomID) -> TelegramMessage:
+ username = po.Portal.get_username_from_mx_alias(room_id)
+ portal = po.Portal.find_by_username(username)
+ if portal and portal.username:
+ return TelegramMessage(f"@{portal.username}").format(TelegramEntityType.MENTION)
+
+ @classmethod
+ def header_to_fstring(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
+ children = cls.node_to_fstrings(node, ctx)
+ length = int(node.tag[1])
+ prefix = "#" * length + " "
+ return TelegramMessage.join(children, "").prepend(prefix).format(TelegramEntityType.BOLD)
+
+ @classmethod
+ def blockquote_to_fstring(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
msg = cls.tag_aware_parse_node(node, ctx)
children = msg.trim().split("\n")
children = [child.prepend("> ") for child in children]
return TelegramMessage.join(children, "\n")
-
- @classmethod
- def node_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
- if node.tag == "mx-reply":
- return TelegramMessage("")
- elif node.tag == "ol":
- return cls.list_to_tmessage(node, ctx)
- elif node.tag == "ul":
- return cls.list_to_tmessage(node, ctx.enter_list())
- elif node.tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
- return cls.header_to_tmessage(node, ctx)
- elif node.tag == "br":
- return TelegramMessage("\n")
- elif node.tag in ("b", "strong", "i", "em", "s", "del", "u", "ins", "command"):
- return cls.basic_format_to_tmessage(node, ctx)
- elif node.tag == "blockquote":
- # Telegram already has blockquote entities in the protocol schema, but it strips them
- # server-side and none of the official clients support them.
- # TODO once Telegram changes that, use the above if block for blockquotes too.
- return cls.blockquote_to_tmessage(node, ctx)
- elif node.tag == "a":
- return cls.link_to_tstring(node, ctx)
- elif node.tag == "p":
- return cls.tag_aware_parse_node(node, ctx).append("\n")
- elif node.tag == "pre":
- lang = ""
- try:
- if node[0].tag == "code":
- node = node[0]
- lang = node.attrib["class"][len("language-"):]
- except (IndexError, KeyError):
- pass
- return cls.parse_node(node, ctx.enter_code_block()).format(Pre, language=lang)
- elif node.tag == "code":
- return cls.parse_node(node, ctx.enter_code_block()).format(Code)
- return cls.tag_aware_parse_node(node, ctx)
-
- @staticmethod
- def text_to_tmessage(text: str, ctx: RecursionContext) -> TelegramMessage:
- if ctx.strip_linebreaks:
- text = text.replace("\n", "")
- return TelegramMessage(text)
-
- @classmethod
- def node_to_tagged_tmessages(cls, node: HTMLNode, ctx: RecursionContext
- ) -> List[Tuple[TelegramMessage, str]]:
- output = []
-
- if node.text:
- output.append((cls.text_to_tmessage(node.text, ctx), "text"))
- for child in node:
- output.append((cls.node_to_tmessage(child, ctx), child.tag))
- if child.tail:
- output.append((cls.text_to_tmessage(child.tail, ctx), "text"))
- return output
-
- @classmethod
- def node_to_tmessages(cls, node: HTMLNode, ctx: RecursionContext
- ) -> List[TelegramMessage]:
- return [msg for (msg, tag) in cls.node_to_tagged_tmessages(node, ctx)]
-
- @classmethod
- def tag_aware_parse_node(cls, node: HTMLNode, ctx: RecursionContext
- ) -> TelegramMessage:
- msgs = cls.node_to_tagged_tmessages(node, ctx)
- output = TelegramMessage()
- prev_was_block = False
- for msg, tag in msgs:
- if tag in cls.block_tags:
- msg = msg.append("\n")
- if not prev_was_block:
- msg = msg.prepend("\n")
- prev_was_block = True
- output = output.append(msg)
- return output.trim()
-
- @classmethod
- def parse_node(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
- return TelegramMessage.join(cls.node_to_tmessages(node, ctx))
-
- @classmethod
- def parse(cls, data: str) -> ParsedMessage:
- msg = cls.node_to_tmessage(read_html(f"{data}"), RecursionContext())
- return msg.text, msg.entities
diff --git a/mautrix_telegram/formatter/from_matrix/telegram_message.py b/mautrix_telegram/formatter/from_matrix/telegram_message.py
index dd4af9da..9ee1b94e 100644
--- a/mautrix_telegram/formatter/from_matrix/telegram_message.py
+++ b/mautrix_telegram/formatter/from_matrix/telegram_message.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,145 +13,87 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Callable, List, Optional, Sequence, Type, Union
+from typing import Optional, Union, Any, List, Type, Dict
+from enum import Enum
-from telethon.tl.types import (MessageEntityMentionName as MentionName,
- MessageEntityTextUrl as TextURL, MessageEntityPre as Pre,
- TypeMessageEntity, InputMessageEntityMentionName as InputMentionName)
+from telethon.tl.types import (MessageEntityMention as Mention, MessageEntityBotCommand as Command,
+ MessageEntityMentionName as MentionName, MessageEntityUrl as URL,
+ MessageEntityEmail as Email, MessageEntityTextUrl as TextURL,
+ MessageEntityBold as Bold, MessageEntityItalic as Italic,
+ MessageEntityCode as Code, MessageEntityPre as Pre,
+ MessageEntityStrike as Strike, MessageEntityUnderline as Underline,
+ MessageEntityBlockquote as Blockquote, TypeMessageEntity,
+ InputMessageEntityMentionName as InputMentionName)
+
+from mautrix.util.formatter import EntityString, SemiAbstractEntity
-class Entity:
- @staticmethod
- def copy(entity: TypeMessageEntity) -> Optional[TypeMessageEntity]:
- if not entity:
- return None
- kwargs = {
- "offset": entity.offset,
- "length": entity.length,
- }
- if isinstance(entity, Pre):
- kwargs["language"] = entity.language
- elif isinstance(entity, TextURL):
- kwargs["url"] = entity.url
- elif isinstance(entity, (MentionName, InputMentionName)):
- kwargs["user_id"] = entity.user_id
- return entity.__class__(**kwargs)
+class TelegramEntityType(Enum):
+ """EntityType is a Matrix formatting entity type."""
+ BOLD = Bold
+ ITALIC = Italic
+ STRIKETHROUGH = Strike
+ UNDERLINE = Underline
+ URL = URL
+ INLINE_URL = TextURL
+ EMAIL = Email
+ PREFORMATTED = Pre
+ INLINE_CODE = Code
+ BLOCKQUOTE = Blockquote
+ MENTION = Mention
+ MENTION_NAME = MentionName
+ COMMAND = Command
- @classmethod
- def adjust(cls, entity: Union[TypeMessageEntity, List[TypeMessageEntity]],
- func: Callable[[TypeMessageEntity], None]
- ) -> Union[Optional[TypeMessageEntity], List[TypeMessageEntity]]:
- if isinstance(entity, list):
- return [Entity.adjust(element, func) for element in entity if entity]
- elif not entity:
- return None
- entity = cls.copy(entity)
- func(entity)
- if entity.offset < 0:
- entity.length += entity.offset
- entity.offset = 0
- return entity
+ USER_MENTION = 1
+ ROOM_MENTION = 2
+ HEADER = 3
-def offset_diff(amount: int) -> Callable[[TypeMessageEntity], None]:
- def func(entity: TypeMessageEntity) -> None:
- entity.offset += amount
+class TelegramEntity(SemiAbstractEntity):
+ internal: TypeMessageEntity
- return func
+ def __init__(self, type: Union[TelegramEntityType, Type[TypeMessageEntity]],
+ offset: int, length: int, extra_info: Dict[str, Any]) -> None:
+ if isinstance(type, TelegramEntityType):
+ if isinstance(type.value, int):
+ raise ValueError(f"Can't create Entity with non-Telegram EntityType {type}")
+ type = type.value
+ self.internal = type(offset=offset, length=length, **extra_info)
+
+ def copy(self) -> Optional['TelegramEntity']:
+ extra_info = {}
+ if isinstance(self.internal, Pre):
+ extra_info["language"] = self.internal.language
+ elif isinstance(self.internal, TextURL):
+ extra_info["url"] = self.internal.url
+ elif isinstance(self.internal, (MentionName, InputMentionName)):
+ extra_info["user_id"] = self.internal.user_id
+ return TelegramEntity(type(self.internal), offset=self.internal.offset,
+ length=self.internal.length, extra_info=extra_info)
+
+ def __repr__(self) -> str:
+ return str(self.internal)
+
+ @property
+ def offset(self) -> int:
+ return self.internal.offset
+
+ @offset.setter
+ def offset(self, value: int) -> None:
+ self.internal.offset = value
+
+ @property
+ def length(self) -> int:
+ return self.internal.length
+
+ @length.setter
+ def length(self, value: int) -> None:
+ self.internal.length = value
-def offset_length_multiply(amount: int) -> Callable[[TypeMessageEntity], None]:
- def func(entity: TypeMessageEntity) -> None:
- entity.offset *= amount
- entity.length *= amount
+class TelegramMessage(EntityString[TelegramEntity, TelegramEntityType]):
+ entity_class = TelegramEntity
- return func
-
-
-class TelegramMessage:
- def __init__(self, text: str = "", entities: Optional[List[TypeMessageEntity]] = None) -> None:
- self.text = text # type: str
- self.entities = entities or [] # type: List[TypeMessageEntity]
-
- def offset_entities(self, offset: int) -> 'TelegramMessage':
- def apply_offset(entity: TypeMessageEntity, inner_offset: int
- ) -> Optional[TypeMessageEntity]:
- entity = Entity.copy(entity)
- entity.offset += inner_offset
- if entity.offset < 0:
- entity.offset = 0
- elif entity.offset > len(self.text):
- return None
- elif entity.offset + entity.length > len(self.text):
- entity.length = len(self.text) - entity.offset
- return entity
-
- self.entities = [apply_offset(entity, offset) for entity in self.entities if entity]
- self.entities = [x for x in self.entities if x is not None]
- return self
-
- def append(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage':
- for msg in args:
- if isinstance(msg, str):
- msg = TelegramMessage(text=msg)
- self.entities += Entity.adjust(msg.entities, offset_diff(len(self.text)))
- self.text += msg.text
- return self
-
- def prepend(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage':
- for msg in args:
- if isinstance(msg, str):
- msg = TelegramMessage(text=msg)
- self.entities = msg.entities + Entity.adjust(self.entities, offset_diff(len(msg.text)))
- self.text = msg.text + self.text
- return self
-
- def format(self, entity_type: Type[TypeMessageEntity], offset: int = None, length: int = None,
- **kwargs) -> 'TelegramMessage':
- self.entities.append(entity_type(offset=offset or 0,
- length=length if length is not None else len(self.text),
- **kwargs))
- return self
-
- def concat(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage':
- return TelegramMessage().append(self, *args)
-
- def trim(self) -> 'TelegramMessage':
- orig_len = len(self.text)
- self.text = self.text.lstrip()
- diff = orig_len - len(self.text)
- self.text = self.text.rstrip()
- self.offset_entities(-diff)
- return self
-
- def split(self, separator, max_items: int = 0) -> List['TelegramMessage']:
- text_parts = self.text.split(separator, max_items - 1)
- output = [] # type: List[TelegramMessage]
-
- offset = 0
- for part in text_parts:
- msg = TelegramMessage(part)
- for entity in self.entities:
- start_in_range = len(part) > entity.offset - offset >= 0
- end_in_range = len(part) >= entity.offset - offset + entity.length > 0
- if start_in_range and end_in_range:
- msg.entities.append(Entity.adjust(entity, offset_diff(-offset)))
- output.append(msg)
-
- offset += len(part)
- offset += len(separator)
-
- return output
-
- @staticmethod
- def join(items: Sequence[Union[str, 'TelegramMessage']],
- separator: str = " ") -> 'TelegramMessage':
- main = TelegramMessage()
- for msg in items:
- if isinstance(msg, str):
- msg = TelegramMessage(text=msg)
- main.entities += Entity.adjust(msg.entities, offset_diff(len(main.text)))
- main.text += msg.text + separator
- if len(separator) > 0:
- main.text = main.text[:-len(separator)]
- return main
+ @property
+ def telegram_entities(self) -> List[TypeMessageEntity]:
+ return [entity.internal for entity in self.entities]
diff --git a/mautrix_telegram/formatter/from_telegram.py b/mautrix_telegram/formatter/from_telegram.py
index 62e9ff8a..72025775 100644
--- a/mautrix_telegram/formatter/from_telegram.py
+++ b/mautrix_telegram/formatter/from_telegram.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,7 +13,7 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
+from typing import List, Optional, TYPE_CHECKING
from html import escape
import logging
import re
@@ -23,48 +22,43 @@ from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, M
MessageEntityEmail, MessageEntityTextUrl, MessageEntityBold,
MessageEntityItalic, MessageEntityCode, MessageEntityPre,
MessageEntityBotCommand, MessageEntityHashtag, MessageEntityCashtag,
- MessageEntityPhone, TypeMessageEntity, Message, PeerChannel,
+ MessageEntityPhone, TypeMessageEntity, PeerChannel,
MessageEntityBlockquote, MessageEntityStrike, MessageFwdHeader,
MessageEntityUnderline, PeerUser)
+from telethon.tl.custom import Message
+from telethon.helpers import add_surrogate, del_surrogate
-from mautrix_appservice import MatrixRequestError
-from mautrix_appservice.intent_api import IntentAPI
+from mautrix.errors import MatrixRequestError
+from mautrix.appservice import IntentAPI
+from mautrix.types import (TextMessageEventContent, RelatesTo, RelationType, Format, MessageType,
+ MessageEvent)
from .. import user as u, puppet as pu, portal as po
from ..types import TelegramID
from ..db import Message as DBMessage
-from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
- trim_reply_fallback_text)
if TYPE_CHECKING:
from ..abstract_user import AbstractUser
-log = logging.getLogger("mau.fmt.tg") # type: logging.Logger
+log: logging.Logger = logging.getLogger("mau.fmt.tg")
-def telegram_reply_to_matrix(evt: Message, source: 'AbstractUser') -> Dict:
+def telegram_reply_to_matrix(evt: Message, source: 'AbstractUser') -> Optional[RelatesTo]:
if evt.reply_to_msg_id:
space = (evt.to_id.channel_id
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
else source.tgid)
- msg = DBMessage.get_one_by_tgid(evt.reply_to_msg_id, space)
+ msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to_msg_id), space)
if msg:
- return {
- "m.in_reply_to": {
- "event_id": msg.mxid,
- "room_id": msg.mx_room,
- },
- "rel_type": "m.reference",
- "event_id": msg.mxid,
- "room_id": msg.mx_room,
- }
- return {}
+ return RelatesTo(rel_type=RelationType.REFERENCE, event_id=msg.mxid)
+ return None
-async def _add_forward_header(source, text: str, html: Optional[str],
- fwd_from: MessageFwdHeader) -> Tuple[str, str]:
- if not html:
- html = escape(text)
+async def _add_forward_header(source: 'AbstractUser', content: TextMessageEventContent,
+ fwd_from: MessageFwdHeader) -> None:
+ if not content.formatted_body or content.format != Format.HTML:
+ content.format = Format.HTML
+ content.formatted_body = escape(content.body)
fwd_from_html, fwd_from_text = None, None
if fwd_from.from_id:
user = u.User.get_by_tgid(TelegramID(fwd_from.from_id))
@@ -81,11 +75,14 @@ async def _add_forward_header(source, text: str, html: Optional[str],
f"{escape(fwd_from_text)}")
if not fwd_from_text:
- user = await source.client.get_entity(PeerUser(fwd_from.from_id))
- if user:
- fwd_from_text = pu.Puppet.get_displayname(user, False)
- fwd_from_html = f"{escape(fwd_from_text)}"
- else:
+ try:
+ user = await source.client.get_entity(PeerUser(fwd_from.from_id))
+ if user:
+ fwd_from_text = pu.Puppet.get_displayname(user, False)
+ fwd_from_html = f"{escape(fwd_from_text)}"
+ except ValueError:
+ fwd_from_text = fwd_from_html = "unknown user"
+ elif fwd_from.channel_id:
portal = po.Portal.get_by_tgid(TelegramID(fwd_from.channel_id))
if portal:
fwd_from_text = portal.title
@@ -93,78 +90,48 @@ async def _add_forward_header(source, text: str, html: Optional[str],
fwd_from_html = (f""
f"{escape(fwd_from_text)}")
else:
- fwd_from_html = f"{escape(fwd_from_text)}"
+ fwd_from_html = f"channel {escape(fwd_from_text)}"
else:
- channel = await source.client.get_entity(PeerChannel(fwd_from.channel_id))
- if channel:
- fwd_from_text = channel.title
- fwd_from_html = f"{fwd_from_text}"
+ try:
+ channel = await source.client.get_entity(PeerChannel(fwd_from.channel_id))
+ if channel:
+ fwd_from_text = f"channel {channel.title}"
+ fwd_from_html = f"channel {escape(channel.title)}"
+ except ValueError:
+ fwd_from_text = fwd_from_html = "unknown channel"
+ elif fwd_from.from_name:
+ fwd_from_text = fwd_from.from_name
+ fwd_from_html = f"{escape(fwd_from.from_name)}"
+ else:
+ fwd_from_text = "unknown source"
+ fwd_from_html = f"unknown source"
- if not fwd_from_text:
- if fwd_from.from_id:
- fwd_from_text = "Unknown user"
- else:
- fwd_from_text = "Unknown source"
- fwd_from_html = f"{fwd_from_text}"
-
- text = "\n".join([f"> {line}" for line in text.split("\n")])
- text = f"Forwarded from {fwd_from_text}:\n{text}"
- html = (f"Forwarded message from {fwd_from_html}
"
- f"{html}
")
- return text, html
+ content.body = "\n".join([f"> {line}" for line in content.body.split("\n")])
+ content.body = f"Forwarded from {fwd_from_text}:\n{content.body}"
+ content.formatted_body = (
+ f"Forwarded message from {fwd_from_html}
"
+ f"{content.formatted_body}
")
-async def _add_reply_header(source: "AbstractUser", text: str, html: str, evt: Message,
- relates_to: Dict, main_intent: IntentAPI) -> Tuple[str, str]:
+async def _add_reply_header(source: 'AbstractUser', content: TextMessageEventContent, evt: Message,
+ main_intent: IntentAPI):
space = (evt.to_id.channel_id
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
else source.tgid)
- msg = DBMessage.get_one_by_tgid(evt.reply_to_msg_id, space)
+ msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to_msg_id), space)
if not msg:
- return text, html
+ return
- relates_to["rel_type"] = "m.reference"
- relates_to["event_id"] = msg.mxid
- relates_to["room_id"] = msg.mx_room
- relates_to["m.in_reply_to"] = {
- "event_id": msg.mxid,
- "room_id": msg.mx_room,
- }
+ content.relates_to = RelatesTo(rel_type=RelationType.REFERENCE, event_id=msg.mxid)
try:
- event = await main_intent.get_event(msg.mx_room, msg.mxid)
-
- content = event["content"]
- r_sender = event["sender"]
-
- r_text_body = trim_reply_fallback_text(content["body"])
- r_html_body = trim_reply_fallback_html(content["formatted_body"]
- if "formatted_body" in content
- else escape(content["body"]))
-
- puppet = pu.Puppet.get_by_mxid(r_sender, create=False)
- r_displayname = puppet.displayname if puppet else r_sender
- r_sender_link = f"{escape(r_displayname)}"
- except (ValueError, KeyError, MatrixRequestError):
- r_sender_link = "unknown user"
- r_displayname = "unknown user"
- r_text_body = "Failed to fetch message"
- r_html_body = "Failed to fetch message"
-
- r_msg_link = f"In reply to"
- html = (
- f"{r_msg_link} {r_sender_link}\n{r_html_body}
"
- + (html or escape(text)))
-
- lines = r_text_body.strip().split("\n")
- text_with_quote = f"> <{r_displayname}> {lines.pop(0)}"
- for line in lines:
- if line:
- text_with_quote += f"\n> {line}"
- text_with_quote += "\n\n"
- text_with_quote += text
- return text_with_quote, html
+ event: MessageEvent = await main_intent.get_event(msg.mx_room, msg.mxid)
+ if isinstance(event.content, TextMessageEventContent):
+ event.content.trim_reply_fallback()
+ content.set_reply(event)
+ except MatrixRequestError:
+ pass
async def telegram_to_matrix(evt: Message, source: "AbstractUser",
@@ -172,33 +139,42 @@ async def telegram_to_matrix(evt: Message, source: "AbstractUser",
prefix_text: Optional[str] = None, prefix_html: Optional[str] = None,
override_text: str = None,
override_entities: List[TypeMessageEntity] = None,
- no_reply_fallback: bool = False) -> Tuple[str, str, Dict]:
- text = add_surrogates(override_text or evt.message)
+ no_reply_fallback: bool = False) -> TextMessageEventContent:
+ content = TextMessageEventContent(
+ msgtype=MessageType.TEXT,
+ body=add_surrogate(override_text or evt.message),
+ )
entities = override_entities or evt.entities
- html = _telegram_entities_to_matrix_catch(text, entities) if entities else None
- relates_to = {} # type: Dict
+ if entities:
+ content.format = Format.HTML
+ content.formatted_body = _telegram_entities_to_matrix_catch(content.body, entities)
if prefix_html:
- html = prefix_html + (html or escape(text))
+ if not content.formatted_body:
+ content.format = Format.HTML
+ content.formatted_body = escape(content.body)
+ content.formatted_body = prefix_html + content.formatted_body
if prefix_text:
- text = prefix_text + text
+ content.body = prefix_text + content.body
if evt.fwd_from:
- text, html = await _add_forward_header(source, text, html, evt.fwd_from)
+ await _add_forward_header(source, content, evt.fwd_from)
if evt.reply_to_msg_id and not no_reply_fallback:
- text, html = await _add_reply_header(source, text, html, evt, relates_to, main_intent)
+ await _add_reply_header(source, content, evt, main_intent)
if isinstance(evt, Message) and evt.post and evt.post_author:
- if not html:
- html = escape(text)
- text += f"\n- {evt.post_author}"
- html += f"
- {evt.post_author}"
+ if not content.formatted_body:
+ content.formatted_body = escape(content.body)
+ content.body += f"\n- {evt.post_author}"
+ content.formatted_body += f"
- {evt.post_author}"
- if html:
- html = html.replace("\n", "
")
+ content.body = del_surrogate(content.body)
- return remove_surrogates(text), remove_surrogates(html), relates_to
+ if content.formatted_body:
+ content.formatted_body = del_surrogate(content.formatted_body.replace("\n", "
"))
+
+ return content
def _telegram_entities_to_matrix_catch(text: str, entities: List[TypeMessageEntity]) -> str:
@@ -313,8 +289,8 @@ def _parse_name_mention(html: List[str], entity_text: str, user_id: TelegramID)
return False
-message_link_regex = re.compile(
- r"https?://t(?:elegram)?\.(?:me|dog)/([A-Za-z][A-Za-z0-9_]{3,}[A-Za-z0-9])/([0-9]{1,50})")
+message_link_regex = re.compile(r"https?://t(?:elegram)?\.(?:me|dog)/"
+ r"([A-Za-z][A-Za-z0-9_]{3,}[A-Za-z0-9])/([0-9]{1,50})")
def _parse_url(html: List[str], entity_text: str, url: str) -> bool:
diff --git a/mautrix_telegram/formatter/util.py b/mautrix_telegram/formatter/util.py
deleted file mode 100644
index 4ac01284..00000000
--- a/mautrix_telegram/formatter/util.py
+++ /dev/null
@@ -1,57 +0,0 @@
-# -*- coding: future_fstrings -*-
-# 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 Optional, Pattern
-from html import escape
-import struct
-import re
-
-
-# add_surrogates and remove_surrogates are unicode surrogate utility functions from Telethon.
-# Licensed under the MIT license.
-# https://github.com/LonamiWebs/Telethon/blob/7cce7aa3e4c6c7019a55530391b1761d33e5a04e/telethon/helpers.py
-def add_surrogates(text: Optional[str]) -> Optional[str]:
- if text is None:
- return None
- return "".join("".join(chr(y) for y in struct.unpack(" Optional[str]:
- if text is None:
- return None
- return text.encode("utf-16", "surrogatepass").decode("utf-16")
-
-
-# trim_reply_fallback_text, html_reply_fallback_regex and trim_reply_fallback_html are Matrix
-# reply fallback utility functions.
-# You may copy and use them under any OSI-approved license.
-def trim_reply_fallback_text(text: str) -> str:
- if not text.startswith("> ") or "\n" not in text:
- return text
- lines = text.split("\n")
- while len(lines) > 0 and lines[0].startswith("> "):
- lines.pop(0)
- return "\n".join(lines)
-
-
-html_reply_fallback_regex = re.compile("^"
- r"[\s\S]+?"
- " ") # type: Pattern
-
-
-def trim_reply_fallback_html(html: str) -> str:
- return html_reply_fallback_regex.sub("", html)
diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py
index 03c10af2..68ba0b03 100644
--- a/mautrix_telegram/matrix.py
+++ b/mautrix_telegram/matrix.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,58 +13,56 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Dict, List, Match, Optional, Set, Tuple, TYPE_CHECKING
-import logging
-import asyncio
-import time
-import re
+from typing import Dict, Set, Tuple, Union, Iterable, TYPE_CHECKING
-from mautrix_appservice import MatrixRequestError, IntentError
+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)
+from mautrix.errors import MatrixError
-from .types import MatrixEvent, MatrixEventID, MatrixRoomID, MatrixUserID
from . import user as u, portal as po, puppet as pu, commands as com
if TYPE_CHECKING:
from .context import Context
+ from .bot import Bot
try:
from prometheus_client import Histogram
- EVENT_TIME = Histogram("matrix_event", "Time spent processing Matrix events",
- ["event_type"])
+ EVENT_TIME = Histogram("matrix_event", "Time spent processing Matrix events", ["event_type"])
except ImportError:
Histogram = None
EVENT_TIME = None
+RoomMetaStateEventContent = Union[RoomNameStateEventContent, RoomAvatarStateEventContent,
+ RoomTopicStateEventContent]
-class MatrixHandler:
- log = logging.getLogger("mau.mx") # type: logging.Logger
+
+class MatrixHandler(BaseMatrixHandler):
+ bot: 'Bot'
+ commands: 'com.CommandProcessor'
+ previously_typing: Dict[RoomID, Set[UserID]]
def __init__(self, context: 'Context') -> None:
- self.az, self.config, _, self.tgbot = context.core
- self.commands = com.CommandProcessor(context) # type: com.CommandProcessor
- self.previously_typing = [] # type: List[MatrixUserID]
+ super(MatrixHandler, self).__init__(context.az, context.config, loop=context.loop,
+ command_processor=com.CommandProcessor(context))
+ self.bot = context.bot
+ self.previously_typing = {}
- self.az.matrix_event_handler(self.handle_event)
+ async def get_user(self, user_id: UserID) -> 'u.User':
+ return await u.User.get_by_mxid(user_id).ensure_started()
- async def init_as_bot(self) -> None:
- displayname = self.config["appservice.bot_displayname"]
- if displayname:
- try:
- await self.az.intent.set_display_name(
- displayname if displayname != "remove" else "")
- except asyncio.TimeoutError:
- self.log.exception("TimeoutError when trying to set displayname")
+ async def get_portal(self, room_id: RoomID) -> 'po.Portal':
+ return po.Portal.get_by_mxid(room_id)
- avatar = self.config["appservice.bot_avatar"]
- if avatar:
- try:
- await self.az.intent.set_avatar(avatar if avatar != "remove" else "")
- except asyncio.TimeoutError:
- self.log.exception("TimeoutError when trying to set avatar")
+ async def get_puppet(self, user_id: UserID) -> 'pu.Puppet':
+ return pu.Puppet.get_by_mxid(user_id)
- async def handle_puppet_invite(self, room_id: MatrixRoomID, puppet: pu.Puppet, inviter: u.User
- ) -> None:
+ async def handle_puppet_invite(self, room_id: RoomID, puppet: pu.Puppet, inviter: u.User,
+ event_id: EventID) -> None:
intent = puppet.default_mxid_intent
self.log.debug(f"{inviter} invited puppet for {puppet.tgid} to {room_id}")
if not await inviter.is_logged_in():
@@ -83,7 +80,7 @@ class MatrixHandler:
return
try:
members = await self.az.intent.get_room_members(room_id)
- except MatrixRequestError:
+ except MatrixError:
members = []
if self.az.bot_mxid not in members:
if len(members) > 1:
@@ -95,18 +92,16 @@ class MatrixHandler:
await intent.join_room(room_id)
portal = po.Portal.get_by_tgid(puppet.tgid, inviter.tgid, "user")
- # TODO: if portal is None:
if portal.mxid:
try:
- await intent.invite(portal.mxid, inviter.mxid)
- await intent.send_notice(room_id, text=None, html=(
- "You already have a private chat with me: "
- f""
- "Link to room"
- ""))
+ await intent.invite_user(portal.mxid, inviter.mxid)
+ await intent.send_notice(
+ room_id, text=f"You already have a private chat with me: {portal.mxid}",
+ html=("You already have a private chat with me: "
+ f"Link to room"))
await intent.leave_room(room_id)
return
- except MatrixRequestError:
+ except MatrixError:
pass
portal.mxid = room_id
portal.save()
@@ -117,67 +112,25 @@ class MatrixHandler:
await intent.send_notice(room_id, "This puppet will remain inactive until a "
"Telegram chat is created for this room.")
- async def accept_bot_invite(self, room_id: MatrixRoomID, inviter: u.User) -> None:
- tries = 0
- while tries < 5:
- try:
- await self.az.intent.join_room(room_id)
- break
- except (IntentError, MatrixRequestError):
- tries += 1
- wait_for_seconds = (tries + 1) * 10
- if tries < 5:
- self.log.exception(f"Failed to join room {room_id} with bridge bot, "
- f"retrying in {wait_for_seconds} seconds...")
- await asyncio.sleep(wait_for_seconds)
- else:
- self.log.exception("Failed to join room {room}, giving up.")
- return
-
- if not inviter.whitelisted:
- await self.az.intent.send_notice(
- room_id,
- text="You are not whitelisted to use this bridge.\n\n"
- "If you are the owner of this bridge, see the "
- "`bridge.permissions` section in your config file.",
- html="You are not whitelisted to use this bridge.
"
- "If you are the owner of this bridge, see the "
- "bridge.permissions section in your config file.
")
- await self.az.intent.leave_room(room_id)
-
+ async def send_welcome_message(self, room_id: RoomID, inviter: 'u.User') -> None:
try:
is_management = len(await self.az.intent.get_room_members(room_id)) == 2
- except MatrixRequestError:
- is_management = False
+ except MatrixError:
+ # The AS bot is not in the room.
+ return
cmd_prefix = self.commands.command_prefix
text = html = "Hello, I'm a Telegram bridge bot. "
if is_management and inviter.puppet_whitelisted and not await inviter.is_logged_in():
text += f"Use `{cmd_prefix} help` for help or `{cmd_prefix} login` to log in."
html += (f"Use {cmd_prefix} help for help"
f" or {cmd_prefix} login to log in.")
- pass
else:
text += f"Use `{cmd_prefix} help` for help."
html += f"Use {cmd_prefix} help for help."
await self.az.intent.send_notice(room_id, text=text, html=html)
- async def handle_invite(self, room_id: MatrixRoomID, user_id: MatrixUserID,
- inviter_mxid: MatrixUserID) -> None:
- self.log.debug(f"{inviter_mxid} invited {user_id} to {room_id}")
- inviter = u.User.get_by_mxid(inviter_mxid)
- if inviter is None:
- self.log.exception("Failed to find user with Matrix ID {inviter_mxid}")
- await inviter.ensure_started()
- if user_id == self.az.bot_mxid:
- return await self.accept_bot_invite(room_id, inviter)
- elif not inviter.whitelisted:
- return
-
- puppet = pu.Puppet.get_by_mxid(user_id)
- if puppet:
- await self.handle_puppet_invite(room_id, puppet, inviter)
- return
-
+ async def handle_invite(self, room_id: RoomID, user_id: UserID, inviter: 'u.User',
+ event_id: EventID) -> None:
user = u.User.get_by_mxid(user_id, create=False)
if not user:
return
@@ -187,10 +140,7 @@ class MatrixHandler:
await portal.invite_telegram(inviter, user)
return
- # The rest can probably be ignored
-
- async def handle_join(self, room_id: MatrixRoomID, user_id: MatrixUserID,
- event_id: MatrixEventID) -> None:
+ async def handle_join(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
user = await u.User.get_by_mxid(user_id).ensure_started()
portal = po.Portal.get_by_mxid(room_id)
@@ -198,24 +148,24 @@ class MatrixHandler:
return
if not user.relaybot_whitelisted:
- await portal.main_intent.kick(room_id, user.mxid,
- "You are not whitelisted on this Telegram bridge.")
+ await portal.main_intent.kick_user(room_id, user.mxid,
+ "You are not whitelisted on this Telegram bridge.")
return
elif not await user.is_logged_in() and not portal.has_bot:
- await portal.main_intent.kick(room_id, user.mxid,
- "This chat does not have a bot relaying "
- "messages for unauthenticated users.")
+ await portal.main_intent.kick_user(room_id, user.mxid,
+ "This chat does not have a bot relaying "
+ "messages for unauthenticated users.")
return
self.log.debug(f"{user} joined {room_id}")
if await user.is_logged_in() or portal.has_bot:
await portal.join_matrix(user, event_id)
- async def handle_part(self, room_id: MatrixRoomID, user_id: MatrixUserID,
- sender_mxid: MatrixUserID, event_id: MatrixEventID) -> None:
+ async def handle_raw_leave(self, room_id: RoomID, user_id: UserID, sender_id: UserID,
+ reason: str, event_id: EventID) -> None:
self.log.debug(f"{user_id} left {room_id}")
- sender = u.User.get_by_mxid(sender_mxid, create=False)
+ sender = u.User.get_by_mxid(sender_id, create=False)
if not sender:
return
await sender.ensure_started()
@@ -226,98 +176,67 @@ class MatrixHandler:
puppet = pu.Puppet.get_by_mxid(user_id)
if puppet:
- if sender:
- await portal.kick_matrix(puppet, sender)
+ await portal.kick_matrix(puppet, sender)
return
user = u.User.get_by_mxid(user_id, create=False)
if not user:
return
await user.ensure_started()
- if await user.is_logged_in() or portal.has_bot:
- await portal.leave_matrix(user, sender, event_id)
-
- def is_command(self, message: Dict) -> Tuple[bool, str]:
- text = message.get("body", "")
- prefix = self.config["bridge.command_prefix"]
- is_command = text.startswith(prefix)
- if is_command:
- text = text[len(prefix) + 1:].lstrip()
- return is_command, text
-
- async def handle_message(self, room: MatrixRoomID, sender_id: MatrixUserID, message: Dict,
- event_id: MatrixEventID) -> None:
- is_command, text = self.is_command(message)
- sender = await u.User.get_by_mxid(sender_id).ensure_started()
- if not sender.relaybot_whitelisted:
- self.log.debug(f"Ignoring message \"{message}\" from {sender} to {room}:"
- " User is not whitelisted.")
- return
- self.log.debug(f"Received Matrix event \"{message}\" from {sender} in {room}")
-
- portal = po.Portal.get_by_mxid(room)
- if not is_command and portal and (await sender.is_logged_in() or portal.has_bot):
- await portal.handle_matrix_message(sender, message, event_id)
- return
-
- if not sender.whitelisted or message.get("msgtype", "m.unknown") != "m.text":
- return
-
- try:
- is_management = len(await self.az.intent.get_room_members(room)) == 2
- except MatrixRequestError:
- # The AS bot is not in the room.
- return
-
- if is_command or is_management:
- try:
- command, arguments = text.split(" ", 1)
- args = arguments.split(" ")
- except ValueError:
- # Not enough values to unpack, i.e. no arguments
- command = text
- args = []
- await self.commands.handle(room, event_id, sender, command, args, is_management,
- is_portal=portal is not None)
+ if sender_id != user_id:
+ await portal.kick_matrix(user, sender)
+ else:
+ await portal.leave_matrix(user, event_id)
@staticmethod
- async def handle_redaction(room_id: MatrixRoomID, sender_mxid: MatrixUserID,
- event_id: MatrixEventID) -> None:
- sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
+ async def allow_message(user: 'u.User') -> bool:
+ return user.relaybot_whitelisted
+
+ @staticmethod
+ async def allow_command(user: 'u.User') -> bool:
+ return user.whitelisted
+
+ @staticmethod
+ async def allow_bridging_message(user: 'u.User', portal: 'po.Portal') -> bool:
+ return await user.is_logged_in() or portal.has_bot
+
+ @staticmethod
+ async def handle_redaction(evt: RedactionEvent) -> None:
+ sender = await u.User.get_by_mxid(evt.sender).ensure_started()
if not sender.relaybot_whitelisted:
return
- portal = po.Portal.get_by_mxid(room_id)
+ portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return
- await portal.handle_matrix_deletion(sender, event_id)
+ await portal.handle_matrix_deletion(sender, evt.redacts)
@staticmethod
- async def handle_power_levels(room_id: MatrixRoomID, sender_mxid: MatrixUserID,
- new: Dict, old: Dict) -> None:
- portal = po.Portal.get_by_mxid(room_id)
- sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
+ async def handle_power_levels(evt: StateEvent) -> None:
+ portal = po.Portal.get_by_mxid(evt.room_id)
+ sender = await u.User.get_by_mxid(evt.sender).ensure_started()
if await sender.has_full_access(allow_bot=True) and portal:
- await portal.handle_matrix_power_levels(sender, new["users"], old["users"])
+ await portal.handle_matrix_power_levels(sender, evt.content.users,
+ evt.unsigned.prev_content.users)
@staticmethod
- async def handle_room_meta(evt_type: str, room_id: MatrixRoomID, sender_mxid: MatrixUserID,
- content: dict) -> None:
+ async def handle_room_meta(evt_type: EventType, room_id: RoomID, sender_mxid: UserID,
+ content: RoomMetaStateEventContent) -> None:
portal = po.Portal.get_by_mxid(room_id)
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
if await sender.has_full_access(allow_bot=True) and portal:
handler, content_key = {
- "m.room.name": (portal.handle_matrix_title, "name"),
- "m.room.topic": (portal.handle_matrix_about, "topic"),
- "m.room.avatar": (portal.handle_matrix_avatar, "url"),
+ EventType.ROOM_NAME: (portal.handle_matrix_title, "name"),
+ EventType.ROOM_TOPIC: (portal.handle_matrix_about, "topic"),
+ EventType.ROOM_AVATAR: (portal.handle_matrix_avatar, "url"),
}[evt_type]
if content_key not in content:
return
await handler(sender, content[content_key])
@staticmethod
- async def handle_room_pin(room_id: MatrixRoomID, sender_mxid: MatrixUserID,
+ async def handle_room_pin(room_id: RoomID, sender_mxid: UserID,
new_events: Set[str], old_events: Set[str]) -> None:
portal = po.Portal.get_by_mxid(room_id)
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
@@ -325,62 +244,69 @@ class MatrixHandler:
events = new_events - old_events
if len(events) > 0:
# New event pinned, set that as pinned in Telegram.
- await portal.handle_matrix_pin(sender, MatrixEventID(events.pop()))
+ await portal.handle_matrix_pin(sender, EventID(events.pop()))
elif len(new_events) == 0:
# All pinned events removed, remove pinned event in Telegram.
await portal.handle_matrix_pin(sender, None)
@staticmethod
- async def handle_room_upgrade(room_id: MatrixRoomID, new_room_id: MatrixRoomID) -> None:
+ async def handle_room_upgrade(room_id: RoomID, new_room_id: RoomID) -> None:
portal = po.Portal.get_by_mxid(room_id)
if portal:
await portal.handle_matrix_upgrade(new_room_id)
- @staticmethod
- async def handle_name_change(room_id: MatrixRoomID, user_id: MatrixUserID, displayname: str,
- prev_displayname: str, event_id: MatrixEventID) -> None:
+ async def handle_member_info_change(self, room_id: RoomID, user_id: UserID,
+ profile: MemberStateEventContent,
+ prev_profile: MemberStateEventContent,
+ event_id: EventID) -> None:
+ if profile.displayname == prev_profile.displayname:
+ return
+
portal = po.Portal.get_by_mxid(room_id)
if not portal or not portal.has_bot:
return
user = await u.User.get_by_mxid(user_id).ensure_started()
if await user.needs_relaybot(portal):
- await portal.name_change_matrix(user, displayname, prev_displayname, event_id)
+ await portal.name_change_matrix(user, profile.displayname, prev_profile.displayname,
+ event_id)
@staticmethod
- def parse_read_receipts(content: Dict) -> Dict[MatrixUserID, MatrixEventID]:
- return {user_id: event_id
+ def parse_read_receipts(content: ReceiptEventContent) -> Iterable[Tuple[UserID, EventID]]:
+ return ((user_id, event_id)
for event_id, receipts in content.items()
- for user_id in receipts.get("m.read", {})}
+ for user_id in receipts.get(ReceiptType.READ, {}))
@staticmethod
- async def handle_read_receipts(room_id: MatrixRoomID,
- receipts: Dict[MatrixUserID, MatrixEventID]) -> None:
+ async def handle_read_receipts(room_id: RoomID, receipts: Iterable[Tuple[UserID, EventID]]
+ ) -> None:
portal = po.Portal.get_by_mxid(room_id)
if not portal:
return
- for user_id, event_id in receipts.items():
+ for user_id, event_id in receipts:
user = await u.User.get_by_mxid(user_id).ensure_started()
if not await user.is_logged_in():
continue
await portal.mark_read(user, event_id)
@staticmethod
- async def handle_presence(user_id: MatrixUserID, presence: str) -> None:
+ async def handle_presence(user_id: UserID, presence: PresenceState) -> None:
user = await u.User.get_by_mxid(user_id).ensure_started()
if not await user.is_logged_in():
return
- await user.set_presence(presence == "online")
+ await user.set_presence(presence == PresenceState.ONLINE)
- async def handle_typing(self, room_id: MatrixRoomID, now_typing: List[MatrixUserID]) -> None:
+ async def handle_typing(self, room_id: RoomID, now_typing: Set[UserID]) -> None:
portal = po.Portal.get_by_mxid(room_id)
if not portal:
return
- for user_id in set(self.previously_typing + now_typing):
+ previously_typing = self.previously_typing.get(room_id, set())
+
+ for user_id in set(previously_typing | now_typing):
is_typing = user_id in now_typing
- was_typing = user_id in self.previously_typing
+ was_typing = user_id in previously_typing
if is_typing and was_typing:
continue
@@ -390,88 +316,46 @@ class MatrixHandler:
await portal.set_typing(user, is_typing)
- self.previously_typing = now_typing
+ self.previously_typing[room_id] = now_typing
- def filter_matrix_event(self, event: MatrixEvent) -> bool:
- sender = event.get("sender", None)
- if not sender:
- return False
- return (sender == self.az.bot_mxid
- or pu.Puppet.get_id_from_mxid(sender) is not None)
+ def filter_matrix_event(self, evt: Event) -> bool:
+ if not isinstance(evt, (RedactionEvent, MessageEvent, StateEvent)):
+ return True
+ return evt.sender and (evt.sender == self.az.bot_mxid
+ or pu.Puppet.get_id_from_mxid(evt.sender) is not None)
- async def try_handle_ephemeral_event(self, evt: MatrixEvent) -> None:
- try:
- await self.handle_ephemeral_event(evt)
- except Exception:
- self.log.exception("Error handling manually received Matrix event")
+ async def handle_ephemeral_event(self, evt: Union[ReceiptEvent, PresenceEvent, TypingEvent]
+ ) -> None:
+ if evt.type == EventType.RECEIPT:
+ await self.handle_read_receipts(evt.room_id, self.parse_read_receipts(evt.content))
+ elif evt.type == EventType.PRESENCE:
+ await self.handle_presence(evt.sender, evt.content.presence)
+ elif evt.type == EventType.TYPING:
+ await self.handle_typing(evt.room_id, set(evt.content.user_ids))
- async def handle_ephemeral_event(self, evt: MatrixEvent) -> None:
- evt_type = evt.get("type", "m.unknown") # type: str
- room_id = evt.get("room_id", None) # type: Optional[MatrixRoomID]
- sender = evt.get("sender", None) # type: Optional[MatrixUserID]
- content = evt.get("content", {}) # type: Dict
- if evt_type == "m.receipt":
- await self.handle_read_receipts(room_id, self.parse_read_receipts(content))
- elif evt_type == "m.presence":
- await self.handle_presence(sender, content.get("presence", "offline"))
- elif evt_type == "m.typing":
- await self.handle_typing(room_id, content.get("user_ids", []))
+ async def handle_event(self, evt: Event) -> None:
+ if evt.type == EventType.ROOM_REDACTION:
+ await self.handle_redaction(evt)
- async def handle_event(self, evt: MatrixEvent) -> None:
- if self.filter_matrix_event(evt):
- return
- start_time = time.time()
- self.log.debug("Received event: %s", evt)
- evt_type = evt.get("type", "m.unknown") # type: str
- room_id = evt.get("room_id", None) # type: Optional[MatrixRoomID]
- event_id = evt.get("event_id", None) # type: Optional[MatrixEventID]
- sender = evt.get("sender", None) # type: Optional[MatrixUserID]
- state_key = evt.get("state_key", None)
- content = evt.get("content", {}) # type: Dict
- if state_key is not None:
- if evt_type == "m.room.member":
- prev_content = evt.get("unsigned", {}).get("prev_content", {}) # type: Dict
- membership = content.get("membership", "") # type: str
- prev_membership = prev_content.get("membership", "leave") # type: str
- if membership == prev_membership:
- match = re.compile("@(.+):(.+)").match(state_key) # type: Match
- mxid = match.group(0) # type: str
- displayname = content.get("displayname", None) or mxid # type: str
- prev_displayname = prev_content.get("displayname", None) or mxid # type: str
- if displayname != prev_displayname:
- await self.handle_name_change(room_id, state_key, displayname,
- prev_displayname, event_id)
- elif membership == "invite":
- await self.handle_invite(room_id, state_key, sender)
- elif prev_membership == "join" and membership == "leave":
- await self.handle_part(room_id, state_key, sender, event_id)
- elif membership == "join":
- await self.handle_join(room_id, state_key, event_id)
- elif evt_type == "m.room.power_levels":
- prev_content = evt.get("unsigned", {}).get("prev_content", {})
- await self.handle_power_levels(room_id, sender, evt["content"], prev_content)
- elif evt_type in ("m.room.name", "m.room.avatar", "m.room.topic"):
- await self.handle_room_meta(evt_type, room_id, sender, evt["content"])
- elif evt_type == "m.room.pinned_events":
- new_events = set(evt["content"]["pinned"])
- try:
- old_events = set(evt["unsigned"]["prev_content"]["pinned"])
- except KeyError:
- old_events = set()
- await self.handle_room_pin(room_id, sender, new_events, old_events)
- elif evt_type == "m.room.tombstone":
- await self.handle_room_upgrade(room_id, evt["content"]["replacement_room"])
- else:
- return
- else:
- if evt_type in ("m.room.message", "m.sticker"):
- if evt_type != "m.room.message":
- content["msgtype"] = evt_type
- await self.handle_message(room_id, sender, content, event_id)
- elif evt_type == "m.room.redaction":
- await self.handle_redaction(room_id, sender, evt["redacts"])
- else:
- return
+ async def handle_state_event(self, evt: StateEvent) -> None:
+ if evt.type == EventType.ROOM_POWER_LEVELS:
+ await self.handle_power_levels(evt)
+ elif evt.type in (EventType.ROOM_NAME, EventType.ROOM_AVATAR, EventType.ROOM_TOPIC):
+ await self.handle_room_meta(evt.type, evt.room_id, evt.sender, evt.content)
+ elif evt.type == EventType.ROOM_PINNED_EVENTS:
+ new_events = set(evt.content.pinned)
+ try:
+ old_events = set(evt.unsigned.prev_content.pinned)
+ except (KeyError, ValueError, TypeError, AttributeError):
+ old_events = set()
+ 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.content.replacement_room)
- if EVENT_TIME:
- EVENT_TIME.labels(event_type=evt_type).observe(time.time() - start_time)
+ # async def handle_event(self, evt: MatrixEvent) -> None:
+ # if self.filter_matrix_event(evt):
+ # return
+ # start_time = time.time()
+ #
+ # if EVENT_TIME:
+ # EVENT_TIME.labels(event_type=evt_type).observe(time.time() - start_time)
diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py
deleted file mode 100644
index 2be1c05f..00000000
--- a/mautrix_telegram/portal.py
+++ /dev/null
@@ -1,2171 +0,0 @@
-# -*- coding: future_fstrings -*-
-# 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 Awaitable, Dict, List, Optional, Pattern, Tuple, Union, cast, TYPE_CHECKING, Any
-from collections import deque
-from datetime import datetime
-from string import Template
-from html import escape as escape_html
-import asyncio
-import random
-import mimetypes
-import codecs
-import unicodedata
-import base64
-import hashlib
-import logging
-import json
-import re
-
-import magic
-from sqlalchemy.exc import IntegrityError
-
-from telethon.tl.functions.messages import (
- AddChatUserRequest, CreateChatRequest, DeleteChatUserRequest, EditChatAdminRequest,
- EditChatPhotoRequest, EditChatTitleRequest, ExportChatInviteRequest, GetFullChatRequest,
- UpdatePinnedMessageRequest, MigrateChatRequest, SetTypingRequest, EditChatAboutRequest)
-from telethon.tl.functions.channels import (
- CreateChannelRequest, EditAdminRequest, EditBannedRequest, EditPhotoRequest, EditTitleRequest,
- GetParticipantsRequest, InviteToChannelRequest, JoinChannelRequest, LeaveChannelRequest,
- UpdateUsernameRequest)
-from telethon.tl.functions.messages import ReadHistoryRequest as ReadMessageHistoryRequest
-from telethon.tl.functions.channels import ReadHistoryRequest as ReadChannelHistoryRequest
-from telethon.errors import (ChatAdminRequiredError, ChatNotModifiedError, PhotoExtInvalidError,
- PhotoInvalidDimensionsError, PhotoSaveFileInvalidError)
-from telethon.tl.patched import Message, MessageService
-from telethon.tl.types import (
- Channel, ChatAdminRights, ChatBannedRights, ChannelFull, ChannelParticipantAdmin, Document,
- ChannelParticipantCreator, ChannelParticipantsRecent, ChannelParticipantsSearch, Chat,
- ChatFull, ChatInviteEmpty, ChatParticipantAdmin, ChatParticipantCreator, ChatPhoto, Poll,
- DocumentAttributeFilename, DocumentAttributeImageSize, DocumentAttributeSticker, PhotoEmpty,
- DocumentAttributeVideo, GeoPoint, InputChannel, InputChatUploadedPhoto, InputPhotoFileLocation,
- InputPeerChannel, InputPeerChat, InputPeerUser, InputUser, InputUserSelf, MessageMediaPoll,
- MessageActionChannelCreate, MessageActionChatAddUser, MessageActionChatCreate, ChatPhotoEmpty,
- MessageActionChatDeletePhoto, MessageActionChatDeleteUser, MessageActionChatEditPhoto,
- MessageActionChatEditTitle, MessageActionChatJoinedByLink, MessageActionChatMigrateTo,
- MessageActionPinMessage, MessageActionGameScore, MessageMediaContact, MessageMediaDocument,
- MessageMediaGeo, MessageMediaPhoto, MessageMediaUnsupported, MessageMediaGame,
- PeerChannel, PeerChat, PeerUser, Photo, PhotoCachedSize, SendMessageCancelAction,
- SendMessageTypingAction, TypeChannelParticipant, TypeChat, TypeChatParticipant,
- TypeDocumentAttribute, TypeInputPeer, TypeMessageAction, TypeMessageEntity, TypePeer,
- TypePhotoSize, TypeUpdates, TypeUser, PhotoSize, TypeUserFull, UpdateChatUserTyping,
- UpdateNewChannelMessage, UpdateNewMessage, UpdateUserTyping, User, UserFull, MessageEntityPre,
- InputMediaUploadedDocument, InputPeerPhotoFileLocation)
-from mautrix_appservice import MatrixRequestError, IntentError, AppService, IntentAPI
-
-from .types import MatrixEventID, MatrixRoomID, MatrixUserID, TelegramID
-from .context import Context
-from .db import Portal as DBPortal, Message as DBMessage, TelegramFile as DBTelegramFile
-from .util import ignore_coro, sane_mimetypes
-from . import puppet as p, user as u, formatter, util
-
-if TYPE_CHECKING:
- from .bot import Bot
- from .abstract_user import AbstractUser
- from .config import Config
- from .tgclient import MautrixTelegramClient
-
-config = None # type: Config
-
-TypeMessage = Union[Message, MessageService]
-TypeParticipant = Union[TypeChatParticipant, TypeChannelParticipant]
-DedupMXID = Tuple[MatrixEventID, TelegramID]
-InviteList = Union[MatrixUserID, List[MatrixUserID]]
-
-
-class Portal:
- base_log = logging.getLogger("mau.portal") # type: logging.Logger
- az = None # type: AppService
- bot = None # type: Bot
- loop = None # type: asyncio.AbstractEventLoop
-
- # Config cache
- filter_mode = None # type: str
- filter_list = None # type: List[str]
-
- public_portals = False # type: bool
- max_initial_member_sync = -1 # type: int
- sync_channel_members = True # type: bool
- sync_matrix_state = True # type: bool
-
- dedup_pre_db_check = False # type: bool
- dedup_cache_queue_length = 20 # type: int
-
- alias_template = None # type: str
- mx_alias_regex = None # type: Pattern
- hs_domain = None # type: str
-
- # Instance cache
- by_mxid = {} # type: Dict[MatrixRoomID, Portal]
- by_tgid = {} # type: Dict[Tuple[TelegramID, TelegramID], Portal]
-
- def __init__(self, tgid: TelegramID, peer_type: str, tg_receiver: Optional[TelegramID] = None,
- mxid: Optional[MatrixRoomID] = 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:
- self.mxid = mxid # type: Optional[MatrixRoomID]
- self.tgid = tgid # type: TelegramID
- self.tg_receiver = tg_receiver or tgid # type: TelegramID
- self.peer_type = peer_type # type: str
- self.username = username # type: str
- self.megagroup = megagroup # type: bool
- self.title = title # type: Optional[str]
- self.about = about # type: str
- self.photo_id = photo_id # type: str
- self.local_config = json.loads(local_config or "{}") # type: Dict[str, Any]
- self._db_instance = db_instance # type: DBPortal
- self.deleted = False # type: bool
- self.log = self.base_log.getChild(self.tgid_log) if self.tgid else self.base_log
-
- self._main_intent = None # type: IntentAPI
- self._room_create_lock = asyncio.Lock() # type: asyncio.Lock
- self._temp_pinned_message_id = None # type: Optional[int]
- self._temp_pinned_message_id_space = None # type: Optional[TelegramID]
- self._temp_pinned_message_sender = None # type: Optional[p.Puppet]
-
- self._dedup = deque() # type: deque
- self._dedup_mxid = {} # type: Dict[str, DedupMXID]
- self._dedup_action = deque() # type: deque
-
- self._send_locks = {} # type: Dict[int, asyncio.Lock]
-
- if tgid:
- self.by_tgid[self.tgid_full] = self
- if mxid:
- self.by_mxid[mxid] = self
-
- # region Propegrties
-
- @property
- def tgid_full(self) -> Tuple[TelegramID, TelegramID]:
- return self.tgid, self.tg_receiver
-
- @property
- def tgid_log(self) -> str:
- if self.tgid == self.tg_receiver:
- return str(self.tgid)
- return f"{self.tg_receiver}<->{self.tgid}"
-
- @property
- def peer(self) -> TypePeer:
- if self.peer_type == "user":
- return PeerUser(user_id=self.tgid)
- elif self.peer_type == "chat":
- return PeerChat(chat_id=self.tgid)
- elif self.peer_type == "channel":
- return PeerChannel(channel_id=self.tgid)
-
- @property
- def has_bot(self) -> bool:
- return bool(self.bot and self.bot.is_in_chat(self.tgid))
-
- @property
- def main_intent(self) -> IntentAPI:
- if not self._main_intent:
- direct = self.peer_type == "user"
- puppet = p.Puppet.get(self.tgid) if direct else None
- self._main_intent = puppet.intent if direct else self.az.intent
- return self._main_intent
-
- # endregion
- # region Filtering
-
- def allow_bridging(self, tgid: Optional[TelegramID] = None) -> bool:
- tgid = tgid or self.tgid
- if self.peer_type == "user":
- return True
- elif self.filter_mode == "whitelist":
- return tgid in self.filter_list
- elif self.filter_mode == "blacklist":
- return tgid not in self.filter_list
- return True
-
- # endregion
- # region Permission checks
-
- async def can_user_perform(self, user: 'u.User', event: str, default: int = 50) -> bool:
- if user.is_admin:
- return True
- if not self.mxid:
- # No room for anybody to perform actions in
- return False
- try:
- await self.main_intent.get_power_levels(self.mxid)
- except MatrixRequestError:
- return False
- return self.main_intent.state_store.has_power_level(
- self.mxid, user.mxid,
- event=f"net.maunium.telegram.{event}",
- default=default)
-
- # endregion
- # region Deduplication
-
- @staticmethod
- def _hash_event(event: TypeMessage) -> str:
- # Non-channel messages are unique per-user (wtf telegram), so we have no other choice than
- # to deduplicate based on a hash of the message content.
-
- # The timestamp is only accurate to the second, so we can't rely solely on that either.
- if isinstance(event, MessageService):
- hash_content = [event.date.timestamp(), event.from_id, event.action]
- else:
- hash_content = [event.date.timestamp(), event.message]
- if event.fwd_from:
- hash_content += [event.fwd_from.from_id, event.fwd_from.channel_id]
- elif isinstance(event, Message) and event.media:
- try:
- hash_content += {
- MessageMediaContact: lambda media: [media.user_id],
- MessageMediaDocument: lambda media: [media.document.id],
- MessageMediaPhoto: lambda media: [media.photo.id],
- MessageMediaGeo: lambda media: [media.geo.long, media.geo.lat],
- }[type(event.media)](event.media)
- except KeyError:
- pass
- return hashlib.md5("-"
- .join(str(a) for a in hash_content)
- .encode("utf-8")
- ).hexdigest()
-
- def is_duplicate_action(self, event: TypeMessage) -> bool:
- evt_hash = self._hash_event(event) if self.peer_type != "channel" else event.id
- if evt_hash in self._dedup_action:
- return True
-
- self._dedup_action.append(evt_hash)
-
- if len(self._dedup_action) > self.dedup_cache_queue_length:
- self._dedup_action.popleft()
- return False
-
- def update_duplicate(self, event: TypeMessage, mxid: DedupMXID = None,
- expected_mxid: Optional[DedupMXID] = None, force_hash: bool = False
- ) -> Optional[DedupMXID]:
- evt_hash = self._hash_event(
- event) if self.peer_type != "channel" or force_hash else event.id
- try:
- found_mxid = self._dedup_mxid[evt_hash]
- except KeyError:
- return MatrixEventID("None"), TelegramID(0)
-
- if found_mxid != expected_mxid:
- return found_mxid
- self._dedup_mxid[evt_hash] = mxid
- return None
-
- def is_duplicate(self, event: TypeMessage, mxid: DedupMXID = None, force_hash: bool = False
- ) -> Optional[DedupMXID]:
- evt_hash = (self._hash_event(event)
- if self.peer_type != "channel" or force_hash
- else event.id)
- if evt_hash in self._dedup:
- return self._dedup_mxid[evt_hash]
-
- self._dedup_mxid[evt_hash] = mxid
- self._dedup.append(evt_hash)
-
- if len(self._dedup) > self.dedup_cache_queue_length:
- del self._dedup_mxid[self._dedup.popleft()]
- return None
-
- def get_input_entity(self, user: 'AbstractUser') -> Awaitable[TypeInputPeer]:
- return user.client.get_input_entity(self.peer)
-
- async def get_entity(self, user: 'AbstractUser') -> TypeChat:
- try:
- return await user.client.get_entity(self.peer)
- except ValueError:
- if user.is_bot:
- self.log.warning(f"Could not find entity with bot {user.tgid}. "
- "Failing...")
- raise
- self.log.warning(f"Could not find entity with user {user.tgid}. "
- "falling back to get_dialogs.")
- async for dialog in user.client.iter_dialogs():
- if dialog.entity.id == self.tgid:
- return dialog.entity
- raise
-
- # endregion
- # region Matrix room info updating
-
- async def invite_to_matrix(self, users: InviteList) -> None:
- if isinstance(users, str):
- await self.main_intent.invite(self.mxid, users, check_cache=True)
- elif isinstance(users, list):
- for user in users:
- await self.main_intent.invite(self.mxid, user, check_cache=True)
- else:
- raise ValueError("Invalid invite identifier given to invite_matrix()")
-
- async def update_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User],
- direct: bool, puppet: p.Puppet = None, levels: Dict = None,
- users: List[User] = None,
- participants: List[TypeParticipant] = None) -> None:
- if not direct:
- await self.update_info(user, entity)
- if not users or not participants:
- users, participants = await self._get_users(user, entity)
- await self.sync_telegram_users(user, users)
- await self.update_telegram_participants(participants, levels)
- else:
- if not puppet:
- puppet = p.Puppet.get(self.tgid)
- await puppet.update_info(user, entity)
- await puppet.intent.join_room(self.mxid)
- if self.sync_matrix_state:
- await self.sync_matrix_members()
-
- async def create_matrix_room(self, user: 'AbstractUser', entity: TypeChat = None,
- invites: InviteList = None, update_if_exists: bool = True,
- synchronous: bool = False) -> Optional[str]:
- if self.mxid:
- if update_if_exists:
- if not entity:
- entity = await self.get_entity(user)
- update = self.update_matrix_room(user, entity, self.peer_type == "user")
- if synchronous:
- await update
- else:
- ignore_coro(asyncio.ensure_future(update, loop=self.loop))
- await self.invite_to_matrix(invites or [])
- return self.mxid
- async with self._room_create_lock:
- return await self._create_matrix_room(user, entity, invites)
-
- async def _create_matrix_room(self, user: 'AbstractUser', entity: TypeChat, invites: InviteList
- ) -> Optional[MatrixRoomID]:
- direct = self.peer_type == "user"
-
- if self.mxid:
- return self.mxid
-
- if not self.allow_bridging():
- return None
-
- if not entity:
- entity = await self.get_entity(user)
- self.log.debug("Fetched data: %s", entity)
-
- self.log.debug(f"Creating room")
-
- try:
- self.title = entity.title
- except AttributeError:
- self.title = None
-
- puppet = p.Puppet.get(self.tgid) if direct else None
- self._main_intent = puppet.intent if direct else self.az.intent
-
- if self.peer_type == "channel":
- self.megagroup = entity.megagroup
-
- if self.peer_type == "channel" and entity.username:
- public = Portal.public_portals
- alias = self._get_alias_localpart(entity.username)
- self.username = entity.username
- else:
- public = False
- # TODO invite link alias?
- alias = None
-
- if alias:
- # TODO? properly handle existing room aliases
- await self.main_intent.remove_room_alias(alias)
-
- power_levels = self._get_base_power_levels({}, entity)
- users = participants = None
- if not direct:
- users, participants = await self._get_users(user, entity)
- self._participants_to_power_levels(participants, power_levels)
- initial_state = [{
- "type": "m.room.power_levels",
- "content": power_levels,
- }]
- if config["appservice.community_id"]:
- initial_state.append({
- "type": "m.room.related_groups",
- "content": {"groups": [config["appservice.community_id"]]},
- })
-
- room_id = await self.main_intent.create_room(alias=alias, is_public=public,
- is_direct=direct, invitees=invites or [],
- name=self.title, initial_state=initial_state)
- if not room_id:
- raise Exception(f"Failed to create room")
-
- self.mxid = MatrixRoomID(room_id)
- self.by_mxid[self.mxid] = self
- self.save()
- self.az.state_store.set_power_levels(self.mxid, power_levels)
- user.register_portal(self)
- ignore_coro(asyncio.ensure_future(self.update_matrix_room(user, entity, direct, puppet,
- levels=power_levels, users=users,
- participants=participants),
- loop=self.loop))
-
- return self.mxid
-
- def _get_base_power_levels(self, levels: dict = None, entity: TypeChat = None) -> dict:
- levels = levels or {}
- if self.peer_type == "user":
- levels["ban"] = 100
- levels["kick"] = 100
- levels["invite"] = 100
- levels.setdefault("events", {})
- levels["events"]["m.room.name"] = 0
- levels["events"]["m.room.avatar"] = 0
- levels["events"]["m.room.topic"] = 0
- levels["state_default"] = 0
- levels["users_default"] = 0
- levels["events_default"] = 0
- else:
- dbr = entity.default_banned_rights
- if not dbr:
- self.log.debug(f"default_banned_rights is None in {entity}")
- dbr = ChatBannedRights(invite_users=True, change_info=True, pin_messages=True,
- send_stickers=False, send_messages=False, until_date=0)
- levels["ban"] = 99
- levels["kick"] = 50
- levels["invite"] = 50 if dbr.invite_users else 0
- levels.setdefault("events", {})
- levels["events"]["m.room.name"] = 50 if dbr.change_info else 0
- levels["events"]["m.room.avatar"] = 50 if dbr.change_info else 0
- levels["events"]["m.room.topic"] = 50 if dbr.change_info else 0
- levels["events"][
- "m.room.pinned_events"] = 50 if dbr.pin_messages else 0
- levels["events"]["m.room.power_levels"] = 75
- levels["events"]["m.room.history_visibility"] = 75
- levels["state_default"] = 50
- levels["users_default"] = 0
- levels["events_default"] = (50 if (self.peer_type == "channel" and not entity.megagroup
- or entity.default_banned_rights.send_messages)
- else 0)
- levels["events"]["m.sticker"] = 50 if dbr.send_stickers else levels["events_default"]
- if "users" not in levels:
- levels["users"] = {
- self.main_intent.mxid: 100
- }
- else:
- levels["users"][self.main_intent.mxid] = 100
- return levels
-
- @property
- def alias(self) -> Optional[str]:
- if not self.username:
- return None
- return f"#{self._get_alias_localpart()}:{self.hs_domain}"
-
- def _get_alias_localpart(self, username: Optional[str] = None) -> Optional[str]:
- username = username or self.username
- if not username:
- return None
- return self.alias_template.format(groupname=username)
-
- def add_bot_chat(self, bot: User) -> None:
- if self.bot and bot.id == self.bot.tgid:
- self.bot.add_chat(self.tgid, self.peer_type)
- return
-
- user = u.User.get_by_tgid(TelegramID(bot.id))
- if user and user.is_bot:
- user.register_portal(self)
-
- async def sync_telegram_users(self, source: 'AbstractUser', users: List[User]) -> None:
- allowed_tgids = set()
- skip_deleted = config["bridge.skip_deleted_members"]
- for entity in users:
- if skip_deleted and entity.deleted:
- continue
- puppet = p.Puppet.get(TelegramID(entity.id))
- if entity.bot:
- self.add_bot_chat(entity)
- allowed_tgids.add(entity.id)
- await puppet.intent.ensure_joined(self.mxid)
- await puppet.update_info(source, entity)
-
- user = u.User.get_by_tgid(TelegramID(entity.id))
- if user:
- await self.invite_to_matrix(user.mxid)
-
- # We can't trust the member list if any of the following cases is true:
- # * There are close to 10 000 users, because Telegram might not be sending all members.
- # * The member sync count is limited, because then we might ignore some members.
- # * It's a channel, because non-admins don't have access to the member list.
- trust_member_list = (len(allowed_tgids) < 9900
- and Portal.max_initial_member_sync == -1
- and (self.megagroup or self.peer_type != "channel"))
- if trust_member_list:
- joined_mxids = cast(List[MatrixUserID],
- await self.main_intent.get_room_members(self.mxid))
- for user_mxid in joined_mxids:
- if user_mxid == self.az.bot_mxid:
- continue
- puppet_id = p.Puppet.get_id_from_mxid(user_mxid)
- if puppet_id and puppet_id not in allowed_tgids:
- if self.bot and puppet_id == self.bot.tgid:
- self.bot.remove_chat(self.tgid)
- await self.main_intent.kick(self.mxid, user_mxid,
- "User had left this Telegram chat.")
- continue
- mx_user = u.User.get_by_mxid(user_mxid, create=False)
- if mx_user and mx_user.is_bot and mx_user.tgid not in allowed_tgids:
- mx_user.unregister_portal(self)
-
- if mx_user and not self.has_bot and mx_user.tgid not in allowed_tgids:
- await self.main_intent.kick(self.mxid, mx_user.mxid,
- "You had left this Telegram chat.")
- continue
-
- async def add_telegram_user(self, user_id: TelegramID, source: Optional['AbstractUser'] = None
- ) -> None:
- puppet = p.Puppet.get(user_id)
- if source:
- entity = await source.client.get_entity(PeerUser(user_id)) # type: User
- await puppet.update_info(source, entity)
- await puppet.intent.join_room(self.mxid)
-
- user = u.User.get_by_tgid(user_id)
- if user:
- user.register_portal(self)
- await self.invite_to_matrix(user.mxid)
-
- async def delete_telegram_user(self, user_id: TelegramID, sender: p.Puppet) -> None:
- puppet = p.Puppet.get(user_id)
- user = u.User.get_by_tgid(user_id)
- kick_message = (f"Kicked by {sender.displayname}"
- if sender and sender.tgid != puppet.tgid
- else "Left Telegram chat")
- if sender and sender.tgid != puppet.tgid:
- await self.main_intent.kick(self.mxid, puppet.mxid, kick_message)
- else:
- await puppet.intent.leave_room(self.mxid)
- if user:
- user.unregister_portal(self)
- await self.main_intent.kick(self.mxid, user.mxid, kick_message)
-
- async def update_info(self, user: 'AbstractUser', entity: TypeChat = None) -> None:
- if self.peer_type == "user":
- self.log.warning(f"Called update_info() for direct chat portal")
- return
-
- self.log.debug(f"Updating info")
- if not entity:
- entity = await self.get_entity(user)
- self.log.debug("Fetched data: %s", entity)
- changed = False
-
- if self.peer_type == "channel":
- changed = await self.update_username(entity.username) or changed
- # TODO update about text
- # changed = self.update_about(entity.about) or changed
-
- changed = await self.update_title(entity.title) or changed
-
- if isinstance(entity.photo, ChatPhoto):
- changed = await self.update_avatar(user, entity.photo) or changed
-
- if changed:
- self.save()
-
- async def update_username(self, username: str, save: bool = False) -> bool:
- if self.username != username:
- if self.username:
- await self.main_intent.remove_room_alias(self._get_alias_localpart())
- self.username = username or None
- if self.username:
- await self.main_intent.add_room_alias(self.mxid, self._get_alias_localpart())
- if Portal.public_portals:
- await self.main_intent.set_join_rule(self.mxid, "public")
- else:
- await self.main_intent.set_join_rule(self.mxid, "invite")
-
- if save:
- self.save()
- return True
- return False
-
- async def update_about(self, about: str, save: bool = False) -> bool:
- if self.about != about:
- self.about = about
- await self.main_intent.set_room_topic(self.mxid, self.about)
- if save:
- self.save()
- return True
- return False
-
- async def update_title(self, title: str, save: bool = False) -> bool:
- if self.title != title:
- self.title = title
- await self.main_intent.set_room_name(self.mxid, self.title)
- if save:
- self.save()
- return True
- return False
-
- @staticmethod
- def _get_largest_photo_size(photo: Union[Photo, Document]
- ) -> Tuple[Optional[InputPhotoFileLocation],
- Optional[TypePhotoSize]]:
- if not photo:
- return None, None
- if isinstance(photo, Document) and not photo.thumbs:
- return None, None
- largest = max(photo.sizes if isinstance(photo, Photo) else photo.thumbs,
- key=(lambda photo2: (len(photo2.bytes)
- if not isinstance(photo2, PhotoSize)
- else photo2.size)))
- return InputPhotoFileLocation(
- id=photo.id,
- access_hash=photo.access_hash,
- file_reference=photo.file_reference,
- thumb_size=largest.type,
- ), largest
-
- async def remove_avatar(self, _: 'AbstractUser', save: bool = False) -> None:
- await self.main_intent.set_room_avatar(self.mxid, None)
- self.photo_id = None
- if save:
- self.save()
-
- async def update_avatar(self, user: 'AbstractUser',
- photo: Union[ChatPhoto, ChatPhotoEmpty, Photo, PhotoEmpty],
- save: bool = False) -> bool:
- if isinstance(photo, ChatPhoto):
- loc = InputPeerPhotoFileLocation(
- peer=await self.get_input_entity(user),
- local_id=photo.photo_big.local_id,
- volume_id=photo.photo_big.volume_id,
- big=True
- )
- photo_id = f"{loc.volume_id}-{loc.local_id}"
- elif isinstance(photo, Photo):
- loc, largest = self._get_largest_photo_size(photo)
- photo_id = f"{largest.location.volume_id}-{largest.location.local_id}"
- elif isinstance(photo, (ChatPhotoEmpty, PhotoEmpty)):
- photo_id = ""
- loc = None
- else:
- raise ValueError(f"Unknown photo type {type(photo)}")
- if self.photo_id != photo_id:
- if not photo_id:
- await self.main_intent.set_room_avatar(self.mxid, "")
- self.photo_id = ""
- if save:
- self.save()
- return True
- file = await util.transfer_file_to_matrix(user.client, self.main_intent, loc)
- if file:
- await self.main_intent.set_room_avatar(self.mxid, file.mxc)
- self.photo_id = photo_id
- if save:
- self.save()
- return True
- return False
-
- async def _get_users(self, user: 'AbstractUser',
- entity: Union[TypeInputPeer, InputUser, TypeChat, TypeUser]
- ) -> Tuple[List[TypeUser], List[TypeParticipant]]:
- if self.peer_type == "chat":
- chat = await user.client(GetFullChatRequest(chat_id=self.tgid))
- return chat.users, chat.full_chat.participants.participants
- elif self.peer_type == "channel":
- if not self.megagroup and not Portal.sync_channel_members:
- return [], []
-
- limit = Portal.max_initial_member_sync
- if limit == 0:
- return [], []
-
- try:
- if 0 < limit <= 200:
- response = await user.client(GetParticipantsRequest(
- entity, ChannelParticipantsRecent(), offset=0, limit=limit, hash=0))
- return response.users, response.participants
- elif limit > 200 or limit == -1:
- users = [] # type: List[TypeUser]
- participants = [] # type: List[TypeParticipant]
- offset = 0
- remaining_quota = limit if limit > 0 else 1000000
- query = (ChannelParticipantsSearch("") if limit == -1
- else ChannelParticipantsRecent())
- while True:
- if remaining_quota <= 0:
- break
- response = await user.client(GetParticipantsRequest(
- entity, query, offset=offset, limit=min(remaining_quota, 100), hash=0))
- if not response.users:
- break
- participants += response.participants
- users += response.users
- offset += len(response.participants)
- remaining_quota -= len(response.participants)
- return users, participants
- except ChatAdminRequiredError:
- return [], []
- elif self.peer_type == "user":
- return [entity], []
- return [], []
-
- async def get_invite_link(self, user: 'u.User') -> str:
- if self.peer_type == "user":
- raise ValueError("You can't invite users to private chats.")
- if self.username:
- return f"https://t.me/{self.username}"
- link = await user.client(ExportChatInviteRequest(peer=await self.get_input_entity(user)))
- if isinstance(link, ChatInviteEmpty):
- raise ValueError("Failed to get invite link.")
- return link.link
-
- async def get_authenticated_matrix_users(self) -> List['u.User']:
- try:
- members = await self.main_intent.get_room_members(self.mxid)
- except MatrixRequestError:
- return []
- authenticated = [] # type: List[u.User]
- has_bot = self.has_bot
- for member_str in members:
- member = MatrixUserID(member_str)
- if p.Puppet.get_id_from_mxid(member) or member == self.main_intent.mxid:
- continue
- user = await u.User.get_by_mxid(member).ensure_started() # type: u.User
- authenticated_through_bot = has_bot and user.relaybot_whitelisted
- if authenticated_through_bot or await user.has_full_access(allow_bot=True):
- authenticated.append(user)
- return authenticated
-
- @staticmethod
- async def cleanup_room(intent: IntentAPI, room_id: str, message: str = "Portal deleted",
- puppets_only: bool = False) -> None:
- try:
- members = await intent.get_room_members(room_id)
- except MatrixRequestError:
- members = []
- for user in members:
- puppet = p.Puppet.get_by_mxid(MatrixUserID(user), create=False)
- if user != intent.mxid and (not puppets_only or puppet):
- try:
- if puppet:
- await puppet.intent.leave_room(room_id)
- else:
- await intent.kick(room_id, user, message)
- except (MatrixRequestError, IntentError):
- pass
- await intent.leave_room(room_id)
-
- async def unbridge(self) -> None:
- await self.cleanup_room(self.main_intent, self.mxid, "Room unbridged", puppets_only=True)
- self.delete()
-
- async def cleanup_and_delete(self) -> None:
- await self.cleanup_room(self.main_intent, self.mxid)
- self.delete()
-
- # endregion
- # region Matrix event handling
-
- @staticmethod
- def _get_file_meta(body: str, mime: str) -> str:
- try:
- current_extension = body[body.rindex("."):].lower()
- body = body[:body.rindex(".")]
- if mimetypes.types_map[current_extension] == mime:
- return body + current_extension
- except (ValueError, KeyError):
- pass
- if mime:
- return f"matrix_upload{sane_mimetypes.guess_extension(mime)}"
- return ""
-
- def get_config(self, key: str) -> Any:
- local = util.recursive_get(self.local_config, key)
- if local is not None:
- return local
- return config[f"bridge.{key}"]
-
- async def _get_state_change_message(self, event: str, user: 'u.User',
- arguments: Optional[Dict] = None) -> Optional[Dict]:
- tpl = self.get_config(f"state_event_formats.{event}")
- if len(tpl) == 0:
- # Empty format means they don't want the message
- return None
- displayname = await self.get_displayname(user)
-
- tpl_args = dict(mxid=user.mxid,
- username=user.mxid_localpart,
- displayname=escape_html(displayname))
- tpl_args = {**tpl_args, **(arguments or {})}
- message = Template(tpl).safe_substitute(tpl_args)
- return {
- "format": "org.matrix.custom.html",
- "formatted_body": message,
- }
-
- async def name_change_matrix(self, user: 'u.User', displayname: str, prev_displayname: str,
- event_id: MatrixEventID) -> None:
- async with self.require_send_lock(self.bot.tgid):
- message = await self._get_state_change_message(
- "name_change", user,
- dict(displayname=displayname, prev_displayname=prev_displayname))
- if not message:
- return
- response = await self.bot.client.send_message(
- self.peer, message,
- parse_mode=self._matrix_event_to_entities)
- space = self.tgid if self.peer_type == "channel" else self.bot.tgid
- self.is_duplicate(response, (event_id, space))
-
- async def get_displayname(self, user: 'u.User') -> str:
- return (await self.main_intent.get_displayname(self.mxid, user.mxid)
- or user.mxid)
-
- async def sync_matrix_members(self) -> None:
- resp = await self.main_intent.get_room_joined_memberships(self.mxid)
- members = resp["joined"]
- for mxid, info in members.items():
- member = {
- "membership": "join",
- }
- if "display_name" in info:
- member["displayname"] = info["display_name"]
- if "avatar_url" in info:
- member["avatar_url"] = info["avatar_url"]
- self.az.state_store.set_member(self.mxid, mxid, member)
-
- def set_typing(self, user: 'u.User', typing: bool = True,
- action: type = SendMessageTypingAction) -> Awaitable[bool]:
- return user.client(SetTypingRequest(
- self.peer, action() if typing else SendMessageCancelAction()))
-
- async def mark_read(self, user: 'u.User', event_id: MatrixEventID) -> None:
- if user.is_bot:
- return
- space = self.tgid if self.peer_type == "channel" else user.tgid
- message = DBMessage.get_by_mxid(event_id, self.mxid, space)
- if not message:
- return
- if self.peer_type == "channel":
- await user.client(ReadChannelHistoryRequest(
- channel=await self.get_input_entity(user), max_id=message.tgid))
- else:
- await user.client(ReadMessageHistoryRequest(peer=self.peer, max_id=message.tgid))
-
- async def kick_matrix(self, user: Union['u.User', 'p.Puppet'], source: 'u.User') -> None:
- if user.tgid == source.tgid:
- return
- if await source.needs_relaybot(self):
- source = self.bot
- if self.peer_type == "chat":
- await source.client(DeleteChatUserRequest(chat_id=self.tgid, user_id=user.tgid))
- elif self.peer_type == "channel":
- channel = await self.get_input_entity(source)
- rights = ChatBannedRights(datetime.fromtimestamp(0), True)
- await source.client(EditBannedRequest(channel=channel,
- user_id=user.tgid,
- banned_rights=rights))
-
- async def leave_matrix(self, user: 'u.User', source: 'u.User',
- event_id: MatrixEventID) -> None:
- if await user.needs_relaybot(self):
- async with self.require_send_lock(self.bot.tgid):
- message = await self._get_state_change_message("leave", user)
- if not message:
- return
- response = await self.bot.client.send_message(
- self.peer, message,
- parse_mode=self._matrix_event_to_entities)
- space = self.tgid if self.peer_type == "channel" else self.bot.tgid
- self.is_duplicate(response, (event_id, space))
- return
-
- if self.peer_type == "user":
- await self.main_intent.leave_room(self.mxid)
- self.delete()
- try:
- del self.by_tgid[self.tgid_full]
- del self.by_mxid[self.mxid]
- except KeyError:
- pass
- elif source and source.tgid != user.tgid:
- await self.kick_matrix(user, source)
- elif self.peer_type == "chat":
- await user.client(DeleteChatUserRequest(chat_id=self.tgid, user_id=InputUserSelf()))
- elif self.peer_type == "channel":
- channel = await self.get_input_entity(user)
- await user.client(LeaveChannelRequest(channel=channel))
-
- async def join_matrix(self, user: 'u.User', event_id: MatrixEventID) -> None:
- if await user.needs_relaybot(self):
- async with self.require_send_lock(self.bot.tgid):
- message = await self._get_state_change_message("join", user)
- if not message:
- return
- response = await self.bot.client.send_message(
- self.peer, message,
- parse_mode=self._matrix_event_to_entities)
- space = self.tgid if self.peer_type == "channel" else self.bot.tgid
- self.is_duplicate(response, (event_id, space))
- return
-
- if self.peer_type == "channel" and not user.is_bot:
- await user.client(JoinChannelRequest(channel=await self.get_input_entity(user)))
- else:
- # We'll just assume the user is already in the chat.
- pass
-
- async def _apply_msg_format(self, sender: 'u.User', msgtype: str, message: Dict[str, Any]
- ) -> None:
- if "formatted_body" not in message:
- message["format"] = "org.matrix.custom.html"
- message["formatted_body"] = escape_html(message.get("body", "")).replace("\n", "
")
- body = message["formatted_body"]
-
- tpl = (self.get_config(f"message_formats.[{msgtype}]")
- or "$sender_displayname: $message")
- displayname = await self.get_displayname(sender)
- tpl_args = dict(sender_mxid=sender.mxid,
- sender_username=sender.mxid_localpart,
- sender_displayname=escape_html(displayname),
- message=body)
- message["formatted_body"] = Template(tpl).safe_substitute(tpl_args)
-
- async def _pre_process_matrix_message(self, sender: 'u.User', use_relaybot: bool,
- message: Dict[str, Any]) -> None:
- msgtype = message.get("msgtype", "m.text")
- if msgtype == "m.emote":
- await self._apply_msg_format(sender, msgtype, message)
- if "m.new_content" in message:
- await self._apply_msg_format(sender, msgtype, message["m.new_content"])
- message["m.new_content"]["msgtype"] = "m.text"
- message["msgtype"] = "m.text"
- elif use_relaybot:
- await self._apply_msg_format(sender, msgtype, message)
- if "m.new_content" in message:
- await self._apply_msg_format(sender, msgtype, message["m.new_content"])
-
- @staticmethod
- def _matrix_event_to_entities(event: Dict[str, Any]
- ) -> Tuple[str, Optional[List[TypeMessageEntity]]]:
- try:
- if event.get("format", None) == "org.matrix.custom.html":
- message, entities = formatter.matrix_to_telegram(event.get("formatted_body", ""))
- else:
- message, entities = formatter.matrix_text_to_telegram(event.get("body", ""))
- except KeyError:
- message, entities = None, None
- return message, entities
-
- def require_send_lock(self, user_id: TelegramID) -> asyncio.Lock:
- if user_id is None:
- raise ValueError("Required send lock for none id")
- try:
- return self._send_locks[user_id]
- except KeyError:
- self._send_locks[user_id] = asyncio.Lock()
- return self._send_locks[user_id]
-
- def optional_send_lock(self, user_id: TelegramID) -> Optional[asyncio.Lock]:
- if user_id is None:
- return None
- try:
- return self._send_locks[user_id]
- except KeyError:
- return None
-
- async def _handle_matrix_text(self, sender_id: TelegramID, event_id: MatrixEventID,
- space: TelegramID, client: 'MautrixTelegramClient',
- message: Dict, reply_to: TelegramID) -> None:
- lock = self.require_send_lock(sender_id)
- async with lock:
- lp = self.get_config("telegram_link_preview")
- relates_to = message.get("m.relates_to", None) or {}
- if relates_to.get("rel_type", None) == "m.replace":
- orig_msg = DBMessage.get_by_mxid(relates_to.get("event_id", ""), self.mxid, space)
- if orig_msg and "m.new_content" in message:
- message = message["m.new_content"]
- response = await client.edit_message(self.peer, orig_msg.tgid, message,
- parse_mode=self._matrix_event_to_entities,
- link_preview=lp)
- self._add_telegram_message_to_db(event_id, space, -1, response)
- return
- response = await client.send_message(self.peer, message, reply_to=reply_to,
- parse_mode=self._matrix_event_to_entities,
- link_preview=lp)
- self._add_telegram_message_to_db(event_id, space, 0, response)
-
- async def _handle_matrix_file(self, msgtype: str, sender_id: TelegramID,
- event_id: MatrixEventID, space: TelegramID,
- client: 'MautrixTelegramClient', message: dict,
- reply_to: TelegramID) -> None:
- file = await self.main_intent.download_file(message["url"])
-
- info = message.get("info", {})
- mime = info.get("mimetype", None)
-
- w, h = None, None
-
- if msgtype == "m.sticker":
- if mime != "image/gif":
- mime, file, w, h = util.convert_image(file, source_mime=mime, target_type="webp")
- else:
- # Remove sticker description
- message["mxtg_filename"] = "sticker.gif"
- message["body"] = ""
- elif "w" in info and "h" in info:
- w, h = info["w"], info["h"]
-
- file_name = self._get_file_meta(message["mxtg_filename"], mime)
- attributes = [DocumentAttributeFilename(file_name=file_name)]
- if w and h:
- attributes.append(DocumentAttributeImageSize(w, h))
-
- caption = message["body"] if message["body"].lower() != file_name.lower() else None
-
- media = await client.upload_file_direct(
- file, mime, attributes, file_name,
- max_image_size=config["bridge.image_as_file_size"] * 1000 ** 2)
- lock = self.require_send_lock(sender_id)
- async with lock:
- relates_to = message.get("m.relates_to", None) or {}
- if relates_to.get("rel_type", None) == "m.replace":
- orig_msg = DBMessage.get_by_mxid(relates_to.get("event_id", ""), self.mxid, space)
- if orig_msg:
- response = await client.edit_message(self.peer, orig_msg.tgid,
- caption, file=media)
- self._add_telegram_message_to_db(event_id, space, -1, response)
- return
- try:
- response = await client.send_media(self.peer, media, reply_to=reply_to,
- caption=caption)
- except (PhotoInvalidDimensionsError, PhotoSaveFileInvalidError, PhotoExtInvalidError):
- media = InputMediaUploadedDocument(file=media.file, mime_type=mime,
- attributes=attributes)
- response = await client.send_media(self.peer, media, reply_to=reply_to,
- caption=caption)
- self._add_telegram_message_to_db(event_id, space, 0, response)
-
- async def _handle_matrix_location(self, sender_id: TelegramID, event_id: MatrixEventID,
- space: TelegramID, client: 'MautrixTelegramClient',
- message: Dict[str, Any], reply_to: TelegramID) -> None:
- try:
- lat, long = message["geo_uri"][len("geo:"):].split(",")
- lat, long = float(lat), float(long)
- except (KeyError, ValueError):
- self.log.exception("Failed to parse location")
- return None
- caption, entities = self._matrix_event_to_entities(message)
- media = MessageMediaGeo(geo=GeoPoint(lat, long, access_hash=0))
-
- lock = self.require_send_lock(sender_id)
- async with lock:
- relates_to = message.get("m.relates_to", None) or {}
- if relates_to.get("rel_type", None) == "m.replace":
- orig_msg = DBMessage.get_by_mxid(relates_to.get("event_id", ""), self.mxid, space)
- if orig_msg:
- response = await client.edit_message(self.peer, orig_msg.tgid,
- caption, file=media)
- self._add_telegram_message_to_db(event_id, space, -1, response)
- return
- response = await client.send_media(self.peer, media, reply_to=reply_to,
- caption=caption, entities=entities)
- self._add_telegram_message_to_db(event_id, space, 0, response)
-
- def _add_telegram_message_to_db(self, event_id: MatrixEventID, space: TelegramID,
- edit_index: int, response: TypeMessage) -> None:
- self.log.debug("Handled Matrix message: %s", response)
- self.is_duplicate(response, (event_id, space), force_hash=edit_index != 0)
- if edit_index < 0:
- prev_edit = DBMessage.get_one_by_tgid(TelegramID(response.id), space, -1)
- edit_index = prev_edit.edit_index + 1
- DBMessage(
- tgid=TelegramID(response.id),
- tg_space=space,
- mx_room=self.mxid,
- mxid=event_id,
- edit_index=edit_index).insert()
-
- async def handle_matrix_message(self, sender: 'u.User', message: Dict[str, Any],
- event_id: MatrixEventID) -> None:
- if "body" not in message or "msgtype" not in message:
- self.log.debug(f"Ignoring message {event_id} in {self.mxid} without body or msgtype")
- return
-
- puppet = p.Puppet.get_by_custom_mxid(sender.mxid)
- if puppet and message.get("net.maunium.telegram.puppet", False):
- self.log.debug("Ignoring puppet-sent message by confirmed puppet user %s", sender.mxid)
- return
-
- logged_in = not await sender.needs_relaybot(self)
- client = sender.client if logged_in else self.bot.client
- sender_id = sender.tgid if logged_in else self.bot.tgid
- space = (self.tgid if self.peer_type == "channel" # Channels have their own ID space
- else (sender.tgid if logged_in else self.bot.tgid))
- reply_to = formatter.matrix_reply_to_telegram(message, space, room_id=self.mxid)
-
- message["mxtg_filename"] = message["body"]
- await self._pre_process_matrix_message(sender, not logged_in, message)
- msgtype = message["msgtype"]
-
- if msgtype == "m.notice":
- bridge_notices = self.get_config("bridge_notices.default")
- excepted = sender.mxid in self.get_config("bridge_notices.exceptions")
- if not bridge_notices and not excepted:
- return
-
- if msgtype == "m.text" or msgtype == "m.notice":
- await self._handle_matrix_text(sender_id, event_id, space, client, message, reply_to)
- elif msgtype == "m.location":
- await self._handle_matrix_location(sender_id, event_id, space, client, message,
- reply_to)
- elif msgtype in ("m.sticker", "m.image", "m.file", "m.audio", "m.video"):
- await self._handle_matrix_file(msgtype, sender_id, event_id, space, client, message,
- reply_to)
- else:
- self.log.debug(f"Unhandled Matrix event: {message}")
-
- async def handle_matrix_pin(self, sender: 'u.User',
- pinned_message: Optional[MatrixEventID]) -> None:
- if self.peer_type != "chat" and self.peer_type != "channel":
- return
- try:
- if not pinned_message:
- await sender.client(UpdatePinnedMessageRequest(peer=self.peer, id=0))
- else:
- tg_space = self.tgid if self.peer_type == "channel" else sender.tgid
- message = DBMessage.get_by_mxid(pinned_message, self.mxid, tg_space)
- if message is None:
- self.log.warning(f"Could not find pinned {pinned_message} in {self.mxid}")
- return
- await sender.client(UpdatePinnedMessageRequest(peer=self.peer, id=message.tgid))
- except ChatNotModifiedError:
- pass
-
- async def handle_matrix_deletion(self, deleter: 'u.User', event_id: MatrixEventID) -> None:
- real_deleter = deleter if not await deleter.needs_relaybot(self) else self.bot
- space = self.tgid if self.peer_type == "channel" else real_deleter.tgid
- message = DBMessage.get_by_mxid(event_id, self.mxid, space)
- if not message:
- return
- if message.edit_index == 0:
- await real_deleter.client.delete_messages(self.peer, [message.tgid])
- else:
- self.log.debug(f"Ignoring deletion of edit event {message.mxid} in {message.mx_room}")
-
- async def _update_telegram_power_level(self, sender: 'u.User', user_id: TelegramID,
- level: int) -> None:
- if self.peer_type == "chat":
- await sender.client(EditChatAdminRequest(
- chat_id=self.tgid, user_id=user_id, is_admin=level >= 50))
- elif self.peer_type == "channel":
- moderator = level >= 50
- admin = level >= 75
- rights = ChatAdminRights(change_info=moderator, post_messages=moderator,
- edit_messages=moderator, delete_messages=moderator,
- ban_users=moderator, invite_users=moderator,
- pin_messages=moderator, add_admins=admin)
- await sender.client(
- EditAdminRequest(channel=await self.get_input_entity(sender),
- user_id=user_id, admin_rights=rights))
-
- async def handle_matrix_power_levels(self, sender: 'u.User',
- new_users: Dict[MatrixUserID, int],
- old_users: Dict[str, int]) -> None:
- # TODO handle all power level changes and bridge exact admin rights to supergroups/channels
- for user, level in new_users.items():
- if not user or user == self.main_intent.mxid or user == sender.mxid:
- continue
- user_id = p.Puppet.get_id_from_mxid(user)
- if not user_id:
- mx_user = u.User.get_by_mxid(user, create=False)
- if not mx_user or not mx_user.tgid:
- continue
- user_id = mx_user.tgid
- if not user_id or user_id == sender.tgid:
- continue
- if user not in old_users or level != old_users[user]:
- await self._update_telegram_power_level(sender, user_id, level)
-
- async def handle_matrix_about(self, sender: 'u.User', about: str) -> None:
- if self.peer_type not in ("chat", "channel"):
- return
- peer = await self.get_input_entity(sender)
- await sender.client(EditChatAboutRequest(peer=peer, about=about))
- self.about = about
- self.save()
-
- async def handle_matrix_title(self, sender: 'u.User', title: str) -> None:
- if self.peer_type not in ("chat", "channel"):
- return
-
- if self.peer_type == "chat":
- response = await sender.client(EditChatTitleRequest(chat_id=self.tgid, title=title))
- else:
- channel = await self.get_input_entity(sender)
- response = await sender.client(EditTitleRequest(channel=channel, title=title))
- self._register_outgoing_actions_for_dedup(response)
- self.title = title
- self.save()
-
- async def handle_matrix_avatar(self, sender: 'u.User', url: str) -> None:
- if self.peer_type not in ("chat", "channel"):
- # Invalid peer type
- return
-
- file = await self.main_intent.download_file(url)
- mime = magic.from_buffer(file, mime=True)
- ext = sane_mimetypes.guess_extension(mime)
- uploaded = await sender.client.upload_file(file, file_name=f"avatar{ext}", use_cache=False)
- photo = InputChatUploadedPhoto(file=uploaded)
-
- if self.peer_type == "chat":
- response = await sender.client(EditChatPhotoRequest(chat_id=self.tgid, photo=photo))
- else:
- channel = await self.get_input_entity(sender)
- response = await sender.client(EditPhotoRequest(channel=channel, photo=photo))
- self._register_outgoing_actions_for_dedup(response)
- for update in response.updates:
- is_photo_update = (isinstance(update, UpdateNewMessage)
- and isinstance(update.message, MessageService)
- and isinstance(update.message.action, MessageActionChatEditPhoto))
- if is_photo_update:
- loc, size = self._get_largest_photo_size(update.message.action.photo)
- self.photo_id = f"{size.location.volume_id}-{size.location.local_id}"
- self.save()
- break
-
- async def handle_matrix_upgrade(self, new_room: MatrixRoomID) -> None:
- old_room = self.mxid
- self.migrate_and_save_matrix(new_room)
- await self.main_intent.join_room(new_room)
- entity = None # type: TypeInputPeer
- user = None # type: AbstractUser
- if self.bot and self.has_bot:
- user = self.bot
- entity = await self.get_input_entity(self.bot)
- if not entity:
- user_mxids = await self.main_intent.get_room_members(self.mxid)
- for user_str in user_mxids:
- user_id = MatrixUserID(user_str)
- if user_id == self.az.bot_mxid:
- continue
- user = u.User.get_by_mxid(user_id, create=False)
- if user and user.tgid:
- entity = await self.get_input_entity(user)
- if entity:
- break
- if not entity:
- self.log.error(
- "Failed to fully migrate to upgraded Matrix room: no Telegram user found.")
- return
- users, participants = await self._get_users(self.bot, entity)
- await self.sync_telegram_users(user, users)
- levels = await self.main_intent.get_power_levels(self.mxid)
- await self.update_telegram_participants(participants, levels)
- self.log.info(f"Upgraded room from {old_room} to {self.mxid}")
-
- def _register_outgoing_actions_for_dedup(self, response: TypeUpdates) -> None:
- for update in response.updates:
- check_dedup = (isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage))
- and isinstance(update.message, MessageService))
- if check_dedup:
- self.is_duplicate_action(update.message)
-
- # endregion
- # region Telegram chat info updating
-
- async def _get_telegram_users_in_matrix_room(self) -> List[TelegramID]:
- user_tgids = set()
- user_mxids = await self.main_intent.get_room_members(self.mxid, ("join", "invite"))
- for user_str in user_mxids:
- user = MatrixUserID(user_str)
- if user == self.az.bot_mxid:
- continue
- mx_user = u.User.get_by_mxid(user, create=False)
- if mx_user and mx_user.tgid:
- user_tgids.add(mx_user.tgid)
- puppet_id = p.Puppet.get_id_from_mxid(user)
- if puppet_id:
- user_tgids.add(puppet_id)
- return list(user_tgids)
-
- async def upgrade_telegram_chat(self, source: 'u.User') -> None:
- if self.peer_type != "chat":
- raise ValueError("Only normal group chats are upgradable to supergroups.")
-
- response = await source.client(MigrateChatRequest(chat_id=self.tgid))
- entity = None
- for chat in response.chats:
- if isinstance(chat, Channel):
- entity = chat
- break
- if not entity:
- raise ValueError("Upgrade may have failed: output channel not found.")
- self.peer_type = "channel"
- self.migrate_and_save_telegram(TelegramID(entity.id))
- await self.update_info(source, entity)
-
- async def set_telegram_username(self, source: 'u.User', username: str) -> None:
- if self.peer_type != "channel":
- raise ValueError("Only channels and supergroups have usernames.")
- await source.client(
- UpdateUsernameRequest(await self.get_input_entity(source), username))
- if await self.update_username(username):
- self.save()
-
- async def create_telegram_chat(self, source: 'u.User', supergroup: bool = False) -> None:
- if not self.mxid:
- raise ValueError("Can't create Telegram chat for portal without Matrix room.")
- elif self.tgid:
- raise ValueError("Can't create Telegram chat for portal with existing Telegram chat.")
-
- invites = await self._get_telegram_users_in_matrix_room()
- if len(invites) < 2:
- if self.bot is not None:
- info, mxid = await self.bot.get_me()
- raise ValueError("Not enough Telegram users to create a chat. "
- "Invite more Telegram ghost users to the room, such as the "
- f"relaybot ([{info.first_name}](https://matrix.to/#/{mxid})).")
- raise ValueError("Not enough Telegram users to create a chat. "
- "Invite more Telegram ghost users to the room.")
- if self.peer_type == "chat":
- response = await source.client(CreateChatRequest(title=self.title, users=invites))
- entity = response.chats[0]
- elif self.peer_type == "channel":
- response = await source.client(CreateChannelRequest(title=self.title,
- about=self.about or "",
- megagroup=supergroup))
- entity = response.chats[0]
- await source.client(InviteToChannelRequest(
- channel=await source.client.get_input_entity(entity),
- users=invites))
- else:
- raise ValueError("Invalid peer type for Telegram chat creation")
-
- self.tgid = entity.id
- self.tg_receiver = self.tgid
- self.by_tgid[self.tgid_full] = self
- await self.update_info(source, entity)
- self.db_instance.insert()
- self.log = self.base_log.getChild(str(self.tgid))
-
- if self.bot and self.bot.tgid in invites:
- self.bot.add_chat(self.tgid, self.peer_type)
-
- levels = await self.main_intent.get_power_levels(self.mxid)
- bot_level = self._get_bot_level(levels)
- if bot_level == 100:
- levels = self._get_base_power_levels(levels, entity)
- await self.main_intent.set_power_levels(self.mxid, levels)
- await self.handle_matrix_power_levels(source, levels["users"], {})
-
- async def invite_telegram(self, source: 'u.User',
- puppet: Union[p.Puppet, 'AbstractUser']) -> None:
- if self.peer_type == "chat":
- await source.client(
- AddChatUserRequest(chat_id=self.tgid, user_id=puppet.tgid, fwd_limit=0))
- elif self.peer_type == "channel":
- await source.client(InviteToChannelRequest(channel=self.peer, users=[puppet.tgid]))
- else:
- raise ValueError("Invalid peer type for Telegram user invite")
-
- # endregion
- # region Telegram event handling
-
- async def handle_telegram_typing(self, user: p.Puppet,
- _: Union[UpdateUserTyping, UpdateChatUserTyping]) -> None:
- await user.intent.set_typing(self.mxid, is_typing=True)
-
- def get_external_url(self, evt: Message) -> Optional[str]:
- if self.peer_type == "channel" and self.username is not None:
- return f"https://t.me/{self.username}/{evt.id}"
- elif self.peer_type != "user":
- return f"https://t.me/c/{self.tgid}/{evt.id}"
- return None
-
- async def handle_telegram_photo(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
- relates_to: Dict = None) -> Optional[Dict]:
- loc, largest_size = self._get_largest_photo_size(evt.media.photo)
- file = await util.transfer_file_to_matrix(source.client, intent, loc)
- if not file:
- return None
- if self.get_config("inline_images") and (evt.message
- or evt.fwd_from or evt.reply_to_msg_id):
- text, html, relates_to = await formatter.telegram_to_matrix(
- evt, source, self.main_intent,
- prefix_html=f"
",
- prefix_text="Inline image: ")
- await intent.set_typing(self.mxid, is_typing=False)
- return await intent.send_text(self.mxid, text, html=html, relates_to=relates_to,
- timestamp=evt.date,
- external_url=self.get_external_url(evt))
- info = {
- "h": largest_size.h,
- "w": largest_size.w,
- "size": len(largest_size.bytes) if (
- isinstance(largest_size, PhotoCachedSize)) else largest_size.size,
- "orientation": 0,
- "mimetype": file.mime_type,
- }
- name = f"image{sane_mimetypes.guess_extension(file.mime_type)}"
- await intent.set_typing(self.mxid, is_typing=False)
- result = await intent.send_image(self.mxid, file.mxc, info=info, text=name,
- relates_to=relates_to, timestamp=evt.date,
- external_url=self.get_external_url(evt))
- if evt.message:
- text, html, _ = await formatter.telegram_to_matrix(evt, source, self.main_intent,
- no_reply_fallback=True)
- result = await intent.send_text(self.mxid, text, html=html, timestamp=evt.date,
- external_url=self.get_external_url(evt))
- return result
-
- @staticmethod
- def _parse_telegram_document_attributes(attributes: List[TypeDocumentAttribute]) -> Dict:
- attrs = {
- "name": None,
- "mime_type": None,
- "is_sticker": False,
- "sticker_alt": None,
- "width": None,
- "height": None,
- } # type: Dict
- for attr in attributes:
- if isinstance(attr, DocumentAttributeFilename):
- attrs["name"] = attrs["name"] or attr.file_name
- attrs["mime_type"], _ = mimetypes.guess_type(attr.file_name)
- elif isinstance(attr, DocumentAttributeSticker):
- attrs["is_sticker"] = True
- attrs["sticker_alt"] = attr.alt
- elif isinstance(attr, DocumentAttributeVideo):
- attrs["width"], attrs["height"] = attr.w, attr.h
- return attrs
-
- @staticmethod
- def _parse_telegram_document_meta(evt: Message, file: DBTelegramFile, attrs: Dict,
- thumb_size: TypePhotoSize) -> Tuple[Dict, str]:
- document = evt.media.document
- name = evt.message or attrs["name"]
- if attrs["is_sticker"]:
- alt = attrs["sticker_alt"]
- if len(alt) > 0:
- try:
- name = f"{alt} ({unicodedata.name(alt[0]).lower()})"
- except ValueError:
- name = alt
-
- generic_types = ("text/plain", "application/octet-stream")
- if file.mime_type in generic_types and document.mime_type not in generic_types:
- mime_type = document.mime_type or file.mime_type
- else:
- mime_type = file.mime_type or document.mime_type
- info = {
- "size": file.size,
- "mimetype": mime_type,
- }
-
- if attrs["mime_type"] and not file.was_converted:
- file.mime_type = attrs["mime_type"] or file.mime_type
- if file.width and file.height:
- info["w"], info["h"] = file.width, file.height
- elif attrs["width"] and attrs["height"]:
- info["w"], info["h"] = attrs["width"], attrs["height"]
-
- if file.thumbnail:
- info["thumbnail_url"] = file.thumbnail.mxc
- info["thumbnail_info"] = {
- "mimetype": file.thumbnail.mime_type,
- "h": file.thumbnail.height or thumb_size.h,
- "w": file.thumbnail.width or thumb_size.w,
- "size": file.thumbnail.size,
- }
-
- return info, name
-
- async def handle_telegram_document(self, source: 'AbstractUser', intent: IntentAPI,
- evt: Message, relates_to: dict = None) -> Optional[Dict]:
- document = evt.media.document
-
- attrs = self._parse_telegram_document_attributes(document.attributes)
-
- if document.size > config["bridge.max_document_size"] * 1000 ** 2:
- name = attrs["name"] or ""
- caption = f"\n{evt.message}" if evt.message else ""
- return await intent.send_notice(self.mxid, f"Too large file {name}{caption}")
-
- thumb_loc, thumb_size = self._get_largest_photo_size(document)
- if thumb_size and not isinstance(thumb_size, (PhotoSize, PhotoCachedSize)):
- self.log.debug(f"Unsupported thumbnail type {type(thumb_size)}")
- thumb_loc = None
- thumb_size = None
- file = await util.transfer_file_to_matrix(source.client, intent, document, thumb_loc,
- is_sticker=attrs["is_sticker"])
- if not file:
- return None
-
- info, name = self._parse_telegram_document_meta(evt, file, attrs, thumb_size)
-
- await intent.set_typing(self.mxid, is_typing=False)
-
- kwargs = {
- "room_id": self.mxid,
- "url": file.mxc,
- "info": info,
- "text": name,
- "relates_to": relates_to,
- "timestamp": evt.date,
- "external_url": self.get_external_url(evt)
- }
-
- if attrs["is_sticker"]:
- return await intent.send_sticker(**kwargs)
-
- mime_type = info["mimetype"]
- if mime_type.startswith("video/"):
- kwargs["file_type"] = "m.video"
- elif mime_type.startswith("audio/"):
- kwargs["file_type"] = "m.audio"
- elif mime_type.startswith("image/"):
- kwargs["file_type"] = "m.image"
- else:
- kwargs["file_type"] = "m.file"
- return await intent.send_file(**kwargs)
-
- def handle_telegram_location(self, _: 'AbstractUser', intent: IntentAPI, evt: Message,
- relates_to: dict = None) -> Awaitable[dict]:
- location = evt.media.geo
- long = location.long
- lat = location.lat
- long_char = "E" if long > 0 else "W"
- lat_char = "N" if lat > 0 else "S"
- rounded_long = round(long, 5)
- rounded_lat = round(lat, 5)
-
- body = f"{rounded_lat}° {lat_char}, {rounded_long}° {long_char}"
-
- url = f"https://maps.google.com/?q={lat},{long}"
-
- formatted_body = f"Location: {body}"
- # At least riot-web ignores formatting in m.location messages,
- # so we'll add a plaintext link.
- body = f"Location: {body}\n{url}"
-
- return intent.send_message(self.mxid, {
- "msgtype": "m.location",
- "geo_uri": f"geo:{lat},{long}",
- "body": body,
- "format": "org.matrix.custom.html",
- "formatted_body": formatted_body,
- "m.relates_to": relates_to or None,
- }, timestamp=evt.date, external_url=self.get_external_url(evt))
-
- async def handle_telegram_text(self, source: 'AbstractUser', intent: IntentAPI, is_bot: bool,
- evt: Message) -> dict:
- self.log.debug(f"Sending {evt.message} to {self.mxid} by {intent.mxid}")
- text, html, relates_to = await formatter.telegram_to_matrix(evt, source, self.main_intent)
- await intent.set_typing(self.mxid, is_typing=False)
- msgtype = "m.notice" if is_bot and self.get_config("bot_messages_as_notices") else "m.text"
- return await intent.send_text(self.mxid, text, html=html, relates_to=relates_to,
- msgtype=msgtype, timestamp=evt.date,
- external_url=self.get_external_url(evt))
-
- async def handle_telegram_unsupported(self, source: 'AbstractUser', intent: IntentAPI,
- evt: Message, relates_to: dict = None) -> dict:
- override_text = ("This message is not supported on your version of Mautrix-Telegram. "
- "Please check https://github.com/tulir/mautrix-telegram or ask your "
- "bridge administrator about possible updates.")
- text, html, relates_to = await formatter.telegram_to_matrix(
- evt, source, self.main_intent, override_text=override_text)
- await intent.set_typing(self.mxid, is_typing=False)
- return await intent.send_message(self.mxid, {
- "body": text,
- "msgtype": "m.notice",
- "format": "org.matrix.custom.html",
- "formatted_body": html,
- "m.relates_to": relates_to,
- "net.maunium.telegram.unsupported": True,
- }, timestamp=evt.date, external_url=self.get_external_url(evt))
-
- async def handle_telegram_poll(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
- relates_to: dict) -> dict:
- poll = evt.media.poll # type: Poll
- poll_id = self._encode_msgid(source, evt)
-
- _n = 0
-
- def n() -> int:
- nonlocal _n
- _n += 1
- return _n
-
- text = (f"Poll: {poll.question}\n"
- + "\n".join(f"{n()}. {answer.text}" for answer in poll.answers) +
- "\n"
- f"Vote with !tg vote {poll_id} ")
-
- html = (f"Poll: {poll.question}
\n"
- f""
- + "\n".join(f"- {answer.text}
"
- for answer in poll.answers) +
- "
\n"
- f"Vote with !tg vote {poll_id} <choice number>")
- await intent.set_typing(self.mxid, is_typing=False)
- return await intent.send_text(self.mxid, text, html=html, relates_to=relates_to,
- msgtype="m.text", timestamp=evt.date,
- external_url=self.get_external_url(evt))
-
- @staticmethod
- def _int_to_bytes(i: int) -> bytes:
- hex_value = "{0:010x}".format(i)
- return codecs.decode(hex_value, "hex_codec")
-
- def _encode_msgid(self, source: 'AbstractUser', evt: Message) -> str:
- if self.peer_type == "channel":
- play_id = (b"c"
- + self._int_to_bytes(self.tgid)
- + self._int_to_bytes(evt.id))
- elif self.peer_type == "chat":
- play_id = (b"g"
- + self._int_to_bytes(self.tgid)
- + self._int_to_bytes(evt.id)
- + self._int_to_bytes(source.tgid))
- elif self.peer_type == "user":
- play_id = (b"u"
- + self._int_to_bytes(self.tgid)
- + self._int_to_bytes(evt.id))
- else:
- raise ValueError("Portal has invalid peer type")
- return base64.b64encode(play_id).decode("utf-8").rstrip("=")
-
- async def handle_telegram_game(self, source: 'AbstractUser', intent: IntentAPI,
- evt: Message, relates_to: dict = None):
- game = evt.media.game
- play_id = self._encode_msgid(source, evt)
- command = f"!tg play {play_id}"
- override_text = f"Run {command} in your bridge management room to play {game.title}"
- override_entities = [
- MessageEntityPre(offset=len("Run "), length=len(command), language="")]
- text, html, relates_to = await formatter.telegram_to_matrix(
- evt, source, self.main_intent,
- override_text=override_text, override_entities=override_entities)
- await intent.set_typing(self.mxid, is_typing=False)
- return await intent.send_message(self.mxid, {
- "body": text,
- "msgtype": "m.notice",
- "format": "org.matrix.custom.html",
- "formatted_body": html,
- "m.relates_to": relates_to,
- "net.maunium.telegram.game": play_id,
- }, timestamp=evt.date, external_url=self.get_external_url(evt))
-
- async def handle_telegram_edit(self, source: 'AbstractUser', sender: p.Puppet,
- evt: Message) -> None:
- if not self.mxid:
- return
- elif hasattr(evt, "media") and isinstance(evt.media, (MessageMediaGame,)):
- self.log.debug("Ignoring game message edit event")
- return
-
- lock = self.optional_send_lock(sender.tgid if sender else None)
- if lock:
- async with lock:
- pass
-
- tg_space = self.tgid if self.peer_type == "channel" else source.tgid
-
- temporary_identifier = MatrixEventID(
- f"${random.randint(1000000000000, 9999999999999)}TGBRIDGEDITEMP")
- duplicate_found = self.is_duplicate(evt, (temporary_identifier, tg_space), force_hash=True)
- if duplicate_found:
- mxid, other_tg_space = duplicate_found
- if tg_space != other_tg_space:
- prev_edit_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space, -1)
- if not prev_edit_msg:
- return
- DBMessage(mxid=mxid, mx_room=self.mxid, tg_space=tg_space, tgid=evt.id,
- edit_index=prev_edit_msg.edit_index + 1).insert()
- return
-
- text, html, _ = await formatter.telegram_to_matrix(evt, source, self.main_intent)
- editing_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space)
- if not editing_msg:
- self.log.info(f"Didn't find edited message {evt.id}@{tg_space} (src {source.tgid}) "
- "in database.")
- return
-
- msgtype = ("m.notice"
- if sender and sender.is_bot and self.get_config("bot_messages_as_notices")
- else "m.text")
- content = {
- "body": f"Edit: {text}",
- "msgtype": msgtype,
- "format": "org.matrix.custom.html",
- "formatted_body": (f"Edit: "
- f"{html or escape_html(text)}"),
- "external_url": self.get_external_url(evt),
- "m.new_content": {
- "body": text,
- "msgtype": "m.text",
- **({"format": "org.matrix.custom.html",
- "formatted_body": html} if html else {}),
- },
- "m.relates_to": {
- "rel_type": "m.replace",
- "event_id": editing_msg.mxid,
- },
- }
-
- intent = sender.intent if sender else self.main_intent
- await intent.set_typing(self.mxid, is_typing=False)
- response = await intent.send_message(self.mxid, content)
- mxid = response["event_id"]
-
- prev_edit_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space, -1) or editing_msg
- DBMessage(mxid=mxid, mx_room=self.mxid, tg_space=tg_space, tgid=evt.id,
- edit_index=prev_edit_msg.edit_index + 1).insert()
- DBMessage.update_by_mxid(temporary_identifier, self.mxid, mxid=mxid)
-
- async def handle_telegram_message(self, source: 'AbstractUser', sender: p.Puppet,
- evt: Message) -> None:
- if not self.mxid:
- await self.create_matrix_room(source, invites=[source.mxid], update_if_exists=False)
-
- lock = self.optional_send_lock(sender.tgid if sender else None)
- if lock:
- async with lock:
- pass
-
- tg_space = self.tgid if self.peer_type == "channel" else source.tgid
-
- temporary_identifier = MatrixEventID(
- f"${random.randint(1000000000000, 9999999999999)}TGBRIDGETEMP")
- duplicate_found = self.is_duplicate(evt, (temporary_identifier, tg_space))
- if duplicate_found:
- mxid, other_tg_space = duplicate_found
- self.log.debug(f"Ignoring message {evt.id}@{tg_space} (src {source.tgid}) "
- f"as it was already handled (in space {other_tg_space})")
- if tg_space != other_tg_space:
- DBMessage(tgid=TelegramID(evt.id), mx_room=self.mxid, mxid=mxid,
- tg_space=tg_space, edit_index=0).insert()
- return
-
- if self.dedup_pre_db_check and self.peer_type == "channel":
- msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space)
- if msg:
- self.log.debug(f"Ignoring message {evt.id} (src {source.tgid}) as it was already"
- f"handled into {msg.mxid}. This duplicate was catched in the db "
- "check. If you get this message often, consider increasing"
- "bridge.deduplication.cache_queue_length in the config.")
- return
-
- if sender and not sender.displayname:
- self.log.debug(f"Telegram user {sender.tgid} sent a message, but doesn't have a "
- "displayname, updating info...")
- entity = await source.client.get_entity(PeerUser(sender.tgid))
- await sender.update_info(source, entity)
-
- allowed_media = (MessageMediaPhoto, MessageMediaDocument, MessageMediaGeo,
- MessageMediaGame, MessageMediaPoll, MessageMediaUnsupported)
- media = evt.media if hasattr(evt, "media") and isinstance(evt.media,
- allowed_media) else None
- intent = sender.intent if sender else self.main_intent
- if not media and evt.message:
- is_bot = sender.is_bot if sender else False
- response = await self.handle_telegram_text(source, intent, is_bot, evt)
- elif media:
- response = await {
- MessageMediaPhoto: self.handle_telegram_photo,
- MessageMediaDocument: self.handle_telegram_document,
- MessageMediaGeo: self.handle_telegram_location,
- MessageMediaPoll: self.handle_telegram_poll,
- MessageMediaUnsupported: self.handle_telegram_unsupported,
- MessageMediaGame: self.handle_telegram_game,
- }[type(media)](source, intent, evt,
- relates_to=formatter.telegram_reply_to_matrix(evt, source))
- else:
- self.log.debug("Unhandled Telegram message: %s", evt)
- return
-
- if not response:
- return
-
- mxid = response["event_id"]
-
- prev_id = self.update_duplicate(evt, (mxid, tg_space), (temporary_identifier, tg_space))
- if prev_id:
- self.log.debug(f"Sent message {evt.id}@{tg_space} to Matrix as {mxid}. "
- f"Temporary dedup identifier was {temporary_identifier}, "
- f"but dedup map contained {prev_id[1]} instead! -- "
- "This was probably a race condition caused by Telegram sending updates"
- "to other clients before responding to the sender. I'll just redact "
- "the likely duplicate message now.")
- await intent.redact(self.mxid, mxid)
- return
-
- self.log.debug("Handled Telegram message: %s", evt)
- try:
- DBMessage(tgid=TelegramID(evt.id), mx_room=self.mxid, mxid=mxid,
- tg_space=tg_space, edit_index=0).insert()
- DBMessage.update_by_mxid(temporary_identifier, self.mxid, mxid=mxid)
- except IntegrityError as e:
- self.log.exception(f"{e.__class__.__name__} while saving message mapping. "
- "This might mean that an update was handled after it left the "
- "dedup cache queue. You can try enabling bridge.deduplication."
- "pre_db_check in the config.")
- await intent.redact(self.mxid, mxid)
-
- async def _create_room_on_action(self, source: 'AbstractUser',
- action: TypeMessageAction) -> bool:
- if source.is_relaybot:
- return False
- create_and_exit = (MessageActionChatCreate, MessageActionChannelCreate)
- create_and_continue = (MessageActionChatAddUser, MessageActionChatJoinedByLink)
- if isinstance(action, create_and_exit) or isinstance(action, create_and_continue):
- await self.create_matrix_room(source, invites=[source.mxid],
- update_if_exists=isinstance(action, create_and_exit))
- if not isinstance(action, create_and_continue):
- return False
- return True
-
- async def handle_telegram_action(self, source: 'AbstractUser', sender: p.Puppet,
- update: MessageService) -> None:
- action = update.action
- should_ignore = ((not self.mxid and not await self._create_room_on_action(source, action))
- or self.is_duplicate_action(update))
- if should_ignore or not self.mxid:
- return
- # TODO figure out how to see changes to about text / channel username
- if isinstance(action, MessageActionChatEditTitle):
- await self.update_title(action.title, save=True)
- elif isinstance(action, MessageActionChatEditPhoto):
- await self.update_avatar(source, action.photo, save=True)
- elif isinstance(action, MessageActionChatDeletePhoto):
- await self.remove_avatar(source, save=True)
- elif isinstance(action, MessageActionChatAddUser):
- for user_id in action.users:
- await self.add_telegram_user(TelegramID(user_id), source)
- elif isinstance(action, MessageActionChatJoinedByLink):
- await self.add_telegram_user(sender.id, source)
- elif isinstance(action, MessageActionChatDeleteUser):
- await self.delete_telegram_user(TelegramID(action.user_id), sender)
- elif isinstance(action, MessageActionChatMigrateTo):
- self.peer_type = "channel"
- self.migrate_and_save_telegram(TelegramID(action.channel_id))
- await sender.intent.send_emote(self.mxid, "upgraded this group to a supergroup.")
- elif isinstance(action, MessageActionPinMessage):
- await self.receive_telegram_pin_sender(sender)
- elif isinstance(action, MessageActionGameScore):
- # TODO handle game score
- pass
- else:
- self.log.debug("Unhandled Telegram action in %s: %s", self.title, action)
-
- async def set_telegram_admin(self, user_id: TelegramID) -> None:
- puppet = p.Puppet.get(user_id)
- user = u.User.get_by_tgid(user_id)
-
- levels = await self.main_intent.get_power_levels(self.mxid)
- if user:
- levels["users"][user.mxid] = 50
- if puppet:
- levels["users"][puppet.mxid] = 50
- await self.main_intent.set_power_levels(self.mxid, levels)
-
- async def receive_telegram_pin_sender(self, sender: p.Puppet) -> None:
- self._temp_pinned_message_sender = sender
- if self._temp_pinned_message_id:
- await self.update_telegram_pin()
-
- async def update_telegram_pin(self) -> None:
- intent = (self._temp_pinned_message_sender.intent
- if self._temp_pinned_message_sender else self.main_intent)
- msg_id = self._temp_pinned_message_id
- self._temp_pinned_message_id = None
- self._temp_pinned_message_sender = None
-
- message = DBMessage.get_one_by_tgid(msg_id, self._temp_pinned_message_id_space)
- if message:
- await intent.set_pinned_messages(self.mxid, [message.mxid])
- else:
- await intent.set_pinned_messages(self.mxid, [])
-
- async def receive_telegram_pin_id(self, msg_id: int, receiver: TelegramID) -> None:
- if msg_id == 0:
- return await self.update_telegram_pin()
- self._temp_pinned_message_id = msg_id
- self._temp_pinned_message_id_space = receiver if self.peer_type != "channel" else self.tgid
- if self._temp_pinned_message_sender:
- await self.update_telegram_pin()
-
- @staticmethod
- def _get_level_from_participant(participant: TypeParticipant, _: Dict) -> int:
- # TODO use the power level requirements to get better precision in channels
- if isinstance(participant, (ChatParticipantAdmin, ChannelParticipantAdmin)):
- return 50
- elif isinstance(participant, (ChatParticipantCreator, ChannelParticipantCreator)):
- return 95
- return 0
-
- @staticmethod
- def _participant_to_power_levels(levels: dict, user: Union['u.User', p.Puppet], new_level: int,
- bot_level: int) -> bool:
- new_level = min(new_level, bot_level)
- default_level = levels["users_default"] if "users_default" in levels else 0
- try:
- user_level = int(levels["users"][user.mxid])
- except (ValueError, KeyError):
- user_level = default_level
- if user_level != new_level and user_level < bot_level:
- levels["users"][user.mxid] = new_level
- return True
- return False
-
- def _get_bot_level(self, levels: dict) -> int:
- try:
- return levels["users"][self.main_intent.mxid]
- except KeyError:
- try:
- return levels["users_default"]
- except KeyError:
- return 0
-
- @staticmethod
- def _get_powerlevel_level(levels: dict) -> int:
- try:
- return levels["events"]["m.room.power_levels"]
- except KeyError:
- try:
- return levels["state_default"]
- except KeyError:
- return 50
-
- def _participants_to_power_levels(self, participants: List[TypeParticipant], levels: Dict
- ) -> bool:
- bot_level = self._get_bot_level(levels)
- if bot_level < self._get_powerlevel_level(levels):
- return False
- changed = False
- admin_power_level = min(75 if self.peer_type == "channel" else 50, bot_level)
- if levels["events"]["m.room.power_levels"] != admin_power_level:
- changed = True
- levels["events"]["m.room.power_levels"] = admin_power_level
-
- for participant in participants:
- puppet = p.Puppet.get(TelegramID(participant.user_id))
- user = u.User.get_by_tgid(TelegramID(participant.user_id))
- new_level = self._get_level_from_participant(participant, levels)
-
- if user:
- user.register_portal(self)
- changed = self._participant_to_power_levels(levels, user, new_level,
- bot_level) or changed
-
- if puppet:
- changed = self._participant_to_power_levels(levels, puppet, new_level,
- bot_level) or changed
- return changed
-
- async def update_telegram_participants(self, participants: List[TypeParticipant],
- levels: dict = None) -> None:
- if not levels:
- levels = await self.main_intent.get_power_levels(self.mxid)
- if self._participants_to_power_levels(participants, levels):
- await self.main_intent.set_power_levels(self.mxid, levels)
-
- async def set_telegram_admins_enabled(self, enabled: bool) -> None:
- level = 50 if enabled else 10
- levels = await self.main_intent.get_power_levels(self.mxid)
- levels["invite"] = level
- levels["events"]["m.room.name"] = level
- levels["events"]["m.room.avatar"] = level
- await self.main_intent.set_power_levels(self.mxid, levels)
-
- # endregion
- # region Database conversion
-
- @property
- def db_instance(self) -> DBPortal:
- if not self._db_instance:
- self._db_instance = self.new_db_instance()
- return self._db_instance
-
- def new_db_instance(self) -> DBPortal:
- 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))
-
- def migrate_and_save_telegram(self, new_id: TelegramID) -> None:
- try:
- del self.by_tgid[self.tgid_full]
- except KeyError:
- pass
- try:
- existing = self.by_tgid[(new_id, new_id)]
- existing.delete()
- except KeyError:
- pass
- self.db_instance.update(tgid=new_id, tg_receiver=new_id, peer_type=self.peer_type)
- old_id = self.tgid
- self.tgid = new_id
- self.tg_receiver = new_id
- self.by_tgid[self.tgid_full] = self
- self.log = self.base_log.getChild(str(self.tgid))
- self.log.info(f"Telegram chat upgraded from {old_id}")
-
- def migrate_and_save_matrix(self, new_id: MatrixRoomID) -> None:
- try:
- del self.by_mxid[self.mxid]
- except KeyError:
- pass
- self.mxid = new_id
- self.db_instance.update(mxid=self.mxid)
- self.by_mxid[self.mxid] = self
-
- def save(self) -> None:
- self.db_instance.update(mxid=self.mxid, username=self.username, title=self.title,
- about=self.about, photo_id=self.photo_id,
- config=json.dumps(self.local_config))
-
- def delete(self) -> None:
- try:
- del self.by_tgid[self.tgid_full]
- except KeyError:
- pass
- try:
- del self.by_mxid[self.mxid]
- except KeyError:
- pass
- if self._db_instance:
- self._db_instance.delete()
- self.deleted = True
-
- @classmethod
- def from_db(cls, db_portal: DBPortal) -> 'Portal':
- return Portal(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)
-
- # endregion
- # region Class instance lookup
-
- @classmethod
- def get_by_mxid(cls, mxid: MatrixRoomID) -> Optional['Portal']:
- try:
- return cls.by_mxid[mxid]
- except KeyError:
- pass
-
- portal = DBPortal.get_by_mxid(mxid)
- if portal:
- return cls.from_db(portal)
-
- return None
-
- @classmethod
- def get_username_from_mx_alias(cls, alias: str) -> Optional[str]:
- match = cls.mx_alias_regex.match(alias)
- if match:
- return match.group(1)
- return None
-
- @classmethod
- def find_by_username(cls, username: str) -> Optional['Portal']:
- if not username:
- return None
-
- for _, portal in cls.by_tgid.items():
- if portal.username and portal.username.lower() == username.lower():
- return portal
-
- dbportal = DBPortal.get_by_username(username)
- if dbportal:
- return cls.from_db(dbportal)
-
- return None
-
- @classmethod
- def get_by_tgid(cls, tgid: TelegramID, tg_receiver: Optional[TelegramID] = None,
- peer_type: str = None) -> Optional['Portal']:
- tg_receiver = tg_receiver or tgid
- tgid_full = (tgid, tg_receiver)
- try:
- return cls.by_tgid[tgid_full]
- except KeyError:
- pass
-
- db_portal = DBPortal.get_by_tgid(tgid, tg_receiver)
- if db_portal:
- return cls.from_db(db_portal)
-
- if peer_type:
- portal = Portal(tgid, peer_type=peer_type, tg_receiver=tg_receiver)
- portal.db_instance.insert()
- return portal
-
- return None
-
- @classmethod
- def get_by_entity(cls, entity: Union[TypeChat, TypePeer, TypeUser, TypeUserFull,
- TypeInputPeer],
- receiver_id: Optional[TelegramID] = None, create: bool = True
- ) -> Optional['Portal']:
- entity_type = type(entity)
- if entity_type in {Chat, ChatFull}:
- type_name = "chat"
- entity_id = entity.id
- elif entity_type in {PeerChat, InputPeerChat}:
- type_name = "chat"
- entity_id = entity.chat_id
- elif entity_type in {Channel, ChannelFull}:
- type_name = "channel"
- entity_id = entity.id
- elif entity_type in {PeerChannel, InputPeerChannel, InputChannel}:
- type_name = "channel"
- entity_id = entity.channel_id
- elif entity_type in {User, UserFull}:
- type_name = "user"
- entity_id = entity.id
- elif entity_type in {PeerUser, InputPeerUser, InputUser}:
- type_name = "user"
- entity_id = entity.user_id
- else:
- raise ValueError(f"Unknown entity type {entity_type.__name__}")
- return cls.get_by_tgid(TelegramID(entity_id),
- receiver_id if type_name == "user" else entity_id,
- type_name if create else None)
-
- # endregion
-
-
-def init(context: Context) -> None:
- global config
- Portal.az, config, Portal.loop, Portal.bot = context.core
- Portal.max_initial_member_sync = config["bridge.max_initial_member_sync"]
- Portal.sync_channel_members = config["bridge.sync_channel_members"]
- Portal.sync_matrix_state = config["bridge.sync_matrix_state"]
- Portal.public_portals = config["bridge.public_portals"]
- Portal.filter_mode = config["bridge.filter.mode"]
- Portal.filter_list = config["bridge.filter.list"]
- Portal.dedup_pre_db_check = config["bridge.deduplication.pre_db_check"]
- Portal.dedup_cache_queue_length = config["bridge.deduplication.cache_queue_length"]
- Portal.alias_template = config.get("bridge.alias_template", "telegram_{groupname}")
- Portal.hs_domain = config["homeserver.domain"]
- Portal.mx_alias_regex = re.compile(
- f"#{Portal.alias_template.format(groupname='(.+)')}:{Portal.hs_domain}")
diff --git a/mautrix_telegram/portal/__init__.py b/mautrix_telegram/portal/__init__.py
new file mode 100644
index 00000000..800f93d2
--- /dev/null
+++ b/mautrix_telegram/portal/__init__.py
@@ -0,0 +1,21 @@
+from .base import BasePortal, init as init_base
+from .matrix import PortalMatrix, init as init_matrix
+from .metadata import PortalMetadata, init as init_metadata
+from .telegram import PortalTelegram, init as init_telegram
+from .deduplication import init as init_dedup
+from ..context import Context
+
+
+class Portal(PortalMatrix, PortalTelegram, PortalMetadata):
+ pass
+
+
+def init(context: Context) -> None:
+ init_base(context)
+ init_dedup(context)
+ init_metadata(context)
+ init_telegram(context)
+ init_matrix(context)
+
+
+__all__ = ["Portal", "init"]
diff --git a/mautrix_telegram/portal/__init__.pyi b/mautrix_telegram/portal/__init__.pyi
new file mode 100644
index 00000000..f705bfc7
--- /dev/null
+++ b/mautrix_telegram/portal/__init__.pyi
@@ -0,0 +1,15 @@
+from typing import Union
+from .base import BasePortal
+from .portal_matrix import PortalMatrix
+from .portal_metadata import PortalMetadata
+from .portal_telegram import PortalTelegram
+from ..context import Context
+
+Portal = Union[BasePortal, PortalMatrix, PortalMetadata, PortalTelegram]
+
+
+def init(context: Context) -> None:
+ pass
+
+
+__all__ = ["Portal", "init"]
diff --git a/mautrix_telegram/portal/base.py b/mautrix_telegram/portal/base.py
new file mode 100644
index 00000000..fd4d3374
--- /dev/null
+++ b/mautrix_telegram/portal/base.py
@@ -0,0 +1,474 @@
+# 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 Awaitable, Dict, List, Optional, Tuple, Union, Any, TYPE_CHECKING
+from abc import ABC, abstractmethod
+import asyncio
+import logging
+import json
+
+from telethon.tl.functions.messages import ExportChatInviteRequest
+from telethon.tl.types import (Channel, ChannelFull, Chat, ChatFull, ChatInviteEmpty, InputChannel,
+ InputPeerChannel, InputPeerChat, InputPeerUser, InputUser,
+ PeerChannel, PeerChat, PeerUser, TypeChat, TypeInputPeer, TypePeer,
+ TypeUser, TypeUserFull, User, UserFull, TypeInputChannel, Photo,
+ Document, TypePhotoSize, PhotoSize, InputPhotoFileLocation,
+ TypeChatParticipant, TypeChannelParticipant, PhotoEmpty, ChatPhoto,
+ ChatPhotoEmpty)
+
+from mautrix.errors import MatrixRequestError, IntentError
+from mautrix.appservice import AppService, IntentAPI
+from mautrix.types import RoomID, RoomAlias, UserID, EventType, PowerLevelStateEventContent
+from mautrix.util.simple_template import SimpleTemplate
+
+from ..types import TelegramID
+from ..context import Context
+from ..db import Portal as DBPortal
+from .. import puppet as p, user as u, util
+from .deduplication import PortalDedup
+from .send_lock import PortalSendLock
+
+if TYPE_CHECKING:
+ from ..bot import Bot
+ from ..abstract_user import AbstractUser
+ from ..config import Config
+ from . import Portal
+
+TypeParticipant = Union[TypeChatParticipant, TypeChannelParticipant]
+TypeChatPhoto = Union[ChatPhoto, ChatPhotoEmpty, Photo, PhotoEmpty]
+InviteList = Union[UserID, List[UserID]]
+
+config: Optional['Config'] = None
+
+
+class BasePortal(ABC):
+ base_log: logging.Logger = logging.getLogger("mau.portal")
+ az: AppService = None
+ bot: 'Bot' = None
+ loop: asyncio.AbstractEventLoop = None
+
+ # Config cache
+ filter_mode: str = None
+ filter_list: List[str] = None
+
+ max_initial_member_sync: int = -1
+ sync_channel_members: bool = True
+ sync_matrix_state: bool = True
+ public_portals: bool = False
+
+ alias_template: SimpleTemplate[str]
+ hs_domain: str
+
+ # Instance cache
+ by_mxid: Dict[RoomID, 'Portal'] = {}
+ by_tgid: Dict[Tuple[TelegramID, TelegramID], 'Portal'] = {}
+
+ mxid: Optional[RoomID]
+ tgid: TelegramID
+ tg_receiver: TelegramID
+ peer_type: str
+ username: str
+ megagroup: bool
+ title: Optional[str]
+ about: Optional[str]
+ photo_id: Optional[str]
+ local_config: Dict[str, Any]
+ deleted: bool
+ log: logging.Logger
+
+ alias: Optional[RoomAlias]
+
+ dedup: PortalDedup
+ send_lock: PortalSendLock
+
+ _db_instance: DBPortal
+ _main_intent: Optional[IntentAPI]
+
+ def __init__(self, tgid: TelegramID, peer_type: str, tg_receiver: Optional[TelegramID] = None,
+ 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:
+ self.mxid = mxid
+ self.tgid = tgid
+ self.tg_receiver = tg_receiver or tgid
+ self.peer_type = peer_type
+ self.username = username
+ self.megagroup = megagroup
+ self.title = title
+ self.about = about
+ self.photo_id = photo_id
+ self.local_config = json.loads(local_config or "{}")
+ self._db_instance = db_instance
+ self._main_intent = None
+ self.deleted = False
+ self.log = self.base_log.getChild(self.tgid_log if self.tgid else self.mxid)
+
+ self.dedup = PortalDedup(self)
+ self.send_lock = PortalSendLock()
+
+ if tgid:
+ self.by_tgid[self.tgid_full] = self
+ if mxid:
+ self.by_mxid[mxid] = self
+
+ # region Propegrties
+
+ @property
+ def tgid_full(self) -> Tuple[TelegramID, TelegramID]:
+ return self.tgid, self.tg_receiver
+
+ @property
+ def tgid_log(self) -> str:
+ if self.tgid == self.tg_receiver:
+ return str(self.tgid)
+ return f"{self.tg_receiver}<->{self.tgid}"
+
+ @property
+ def peer(self) -> Union[TypePeer, TypeInputPeer]:
+ if self.peer_type == "user":
+ return PeerUser(user_id=self.tgid)
+ elif self.peer_type == "chat":
+ return PeerChat(chat_id=self.tgid)
+ elif self.peer_type == "channel":
+ return PeerChannel(channel_id=self.tgid)
+
+ @property
+ def has_bot(self) -> bool:
+ return bool(self.bot and self.bot.is_in_chat(self.tgid))
+
+ @property
+ def main_intent(self) -> IntentAPI:
+ if not self._main_intent:
+ direct = self.peer_type == "user"
+ puppet = p.Puppet.get(self.tgid) if direct else None
+ self._main_intent = puppet.intent_for(self) if direct else self.az.intent
+ return self._main_intent
+
+ @property
+ def allow_bridging(self) -> bool:
+ if self.peer_type == "user":
+ return True
+ elif self.filter_mode == "whitelist":
+ return self.tgid in self.filter_list
+ elif self.filter_mode == "blacklist":
+ return self.tgid not in self.filter_list
+ return True
+
+ # endregion
+ # region Miscellaneous getters
+
+ def get_config(self, key: str) -> Any:
+ local = util.recursive_get(self.local_config, key)
+ if local is not None:
+ return local
+ return config[f"bridge.{key}"]
+
+ @staticmethod
+ def _get_largest_photo_size(photo: Union[Photo, Document]
+ ) -> Tuple[Optional[InputPhotoFileLocation],
+ Optional[TypePhotoSize]]:
+ if not photo:
+ return None, None
+ if isinstance(photo, Document) and not photo.thumbs:
+ return None, None
+
+ largest = max(photo.thumbs if isinstance(photo, Document) else photo.sizes,
+ key=(lambda photo2: (len(photo2.bytes)
+ if not isinstance(photo2, PhotoSize)
+ else photo2.size)))
+ return InputPhotoFileLocation(
+ id=photo.id,
+ access_hash=photo.access_hash,
+ file_reference=photo.file_reference,
+ thumb_size=largest.type,
+ ), largest
+
+ async def can_user_perform(self, user: 'u.User', event: str) -> bool:
+ if user.is_admin:
+ return True
+ if not self.mxid:
+ # No room for anybody to perform actions in
+ return False
+ try:
+ await self.main_intent.get_power_levels(self.mxid)
+ except MatrixRequestError:
+ return False
+ evt_type = EventType.find(f"net.maunium.telegram.{event}")
+ evt_type.t_class = EventType.Class.STATE
+ return self.main_intent.state_store.has_power_level(self.mxid, user.mxid, event=evt_type)
+
+ def get_input_entity(self, user: 'AbstractUser'
+ ) -> Awaitable[Union[TypeInputPeer, TypeInputChannel]]:
+ return user.client.get_input_entity(self.peer)
+
+ async def get_entity(self, user: 'AbstractUser') -> TypeChat:
+ try:
+ return await user.client.get_entity(self.peer)
+ except ValueError:
+ if user.is_bot:
+ self.log.warning(f"Could not find entity with bot {user.tgid}. "
+ "Failing...")
+ raise
+ self.log.warning(f"Could not find entity with user {user.tgid}. "
+ "falling back to get_dialogs.")
+ async for dialog in user.client.iter_dialogs():
+ if dialog.entity.id == self.tgid:
+ return dialog.entity
+ raise
+
+ async def get_invite_link(self, user: 'u.User') -> str:
+ if self.peer_type == "user":
+ raise ValueError("You can't invite users to private chats.")
+ if self.username:
+ return f"https://t.me/{self.username}"
+ link = await user.client(ExportChatInviteRequest(peer=await self.get_input_entity(user)))
+ if isinstance(link, ChatInviteEmpty):
+ raise ValueError("Failed to get invite link.")
+ return link.link
+
+ # endregion
+ # region Matrix room cleanup
+
+ async def get_authenticated_matrix_users(self) -> List['u.User']:
+ try:
+ members = await self.main_intent.get_room_members(self.mxid)
+ except MatrixRequestError:
+ return []
+ authenticated: List[u.User] = []
+ has_bot = self.has_bot
+ for member_str in members:
+ member = UserID(member_str)
+ if p.Puppet.get_id_from_mxid(member) or member == self.main_intent.mxid:
+ continue
+ user = await u.User.get_by_mxid(member).ensure_started()
+ authenticated_through_bot = has_bot and user.relaybot_whitelisted
+ if authenticated_through_bot or await user.has_full_access(allow_bot=True):
+ authenticated.append(user)
+ return authenticated
+
+ @staticmethod
+ async def cleanup_room(intent: IntentAPI, room_id: RoomID, message: str = "Portal deleted",
+ puppets_only: bool = False) -> None:
+ try:
+ members = await intent.get_room_members(room_id)
+ except MatrixRequestError:
+ members = []
+ for user in members:
+ puppet = p.Puppet.get_by_mxid(UserID(user), create=False)
+ if user != intent.mxid and (not puppets_only or puppet):
+ try:
+ if puppet:
+ await puppet.default_mxid_intent.leave_room(room_id)
+ else:
+ await intent.kick_user(room_id, user, message)
+ except (MatrixRequestError, IntentError):
+ pass
+ await intent.leave_room(room_id)
+
+ async def unbridge(self) -> None:
+ await self.cleanup_room(self.main_intent, self.mxid, "Room unbridged", puppets_only=True)
+ self.delete()
+
+ async def cleanup_and_delete(self) -> None:
+ await self.cleanup_room(self.main_intent, self.mxid)
+ self.delete()
+
+ # endregion
+ # region Database conversion
+
+ @property
+ def db_instance(self) -> DBPortal:
+ if not self._db_instance:
+ self._db_instance = self.new_db_instance()
+ return self._db_instance
+
+ def new_db_instance(self) -> DBPortal:
+ 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))
+
+ 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,
+ config=json.dumps(self.local_config))
+
+ def delete(self) -> None:
+ try:
+ del self.by_tgid[self.tgid_full]
+ except KeyError:
+ pass
+ try:
+ del self.by_mxid[self.mxid]
+ except KeyError:
+ pass
+ if self._db_instance:
+ self._db_instance.delete()
+ self.deleted = True
+
+ @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)
+
+ # endregion
+ # region Class instance lookup
+
+ @classmethod
+ def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']:
+ try:
+ return cls.by_mxid[mxid]
+ except KeyError:
+ pass
+
+ portal = DBPortal.get_by_mxid(mxid)
+ if portal:
+ return cls.from_db(portal)
+
+ return None
+
+ @classmethod
+ def get_username_from_mx_alias(cls, alias: str) -> Optional[str]:
+ return cls.alias_template.parse(alias)
+
+ @classmethod
+ def find_by_username(cls, username: str) -> Optional['Portal']:
+ if not username:
+ return None
+
+ for _, portal in cls.by_tgid.items():
+ if portal.username and portal.username.lower() == username.lower():
+ return portal
+
+ dbportal = DBPortal.get_by_username(username)
+ if dbportal:
+ return cls.from_db(dbportal)
+
+ return None
+
+ @classmethod
+ def get_by_tgid(cls, tgid: TelegramID, tg_receiver: Optional[TelegramID] = None,
+ peer_type: str = None) -> Optional['Portal']:
+ tg_receiver = tg_receiver or tgid
+ tgid_full = (tgid, tg_receiver)
+ try:
+ return cls.by_tgid[tgid_full]
+ except KeyError:
+ pass
+
+ db_portal = DBPortal.get_by_tgid(tgid, tg_receiver)
+ if db_portal:
+ return cls.from_db(db_portal)
+
+ if peer_type:
+ portal = cls(tgid, peer_type=peer_type, tg_receiver=tg_receiver)
+ portal.db_instance.insert()
+ return portal
+
+ return None
+
+ @classmethod
+ def get_by_entity(cls, entity: Union[TypeChat, TypePeer, TypeUser, TypeUserFull,
+ TypeInputPeer],
+ receiver_id: Optional[TelegramID] = None, create: bool = True
+ ) -> Optional['Portal']:
+ entity_type = type(entity)
+ if entity_type in (Chat, ChatFull):
+ type_name = "chat"
+ entity_id = entity.id
+ elif entity_type in (PeerChat, InputPeerChat):
+ type_name = "chat"
+ entity_id = entity.chat_id
+ elif entity_type in (Channel, ChannelFull):
+ type_name = "channel"
+ entity_id = entity.id
+ elif entity_type in (PeerChannel, InputPeerChannel, InputChannel):
+ type_name = "channel"
+ entity_id = entity.channel_id
+ elif entity_type in (User, UserFull):
+ type_name = "user"
+ entity_id = entity.id
+ elif entity_type in (PeerUser, InputPeerUser, InputUser):
+ type_name = "user"
+ entity_id = entity.user_id
+ else:
+ raise ValueError(f"Unknown entity type {entity_type.__name__}")
+ return cls.get_by_tgid(TelegramID(entity_id),
+ receiver_id if type_name == "user" else entity_id,
+ type_name if create else None)
+
+ # endregion
+ # region Abstract methods (cross-called in matrix/metadata/telegram classes)
+
+ @abstractmethod
+ async def update_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User],
+ direct: bool, puppet: p.Puppet = None,
+ levels: PowerLevelStateEventContent = None,
+ users: List[User] = None,
+ participants: List[TypeParticipant] = None) -> None:
+ pass
+
+ @abstractmethod
+ async def create_matrix_room(self, user: 'AbstractUser', entity: TypeChat = None,
+ invites: InviteList = None, update_if_exists: bool = True,
+ synchronous: bool = False) -> Optional[str]:
+ pass
+
+ @abstractmethod
+ async def _add_telegram_user(self, user_id: TelegramID, source: Optional['AbstractUser'] = None
+ ) -> None:
+ pass
+
+ @abstractmethod
+ async def _delete_telegram_user(self, user_id: TelegramID, sender: p.Puppet) -> None:
+ pass
+
+ @abstractmethod
+ async def _update_title(self, title: str, save: bool = False) -> bool:
+ pass
+
+ @abstractmethod
+ async def _update_avatar(self, user: 'AbstractUser', photo: Union[TypeChatPhoto],
+ save: bool = False) -> bool:
+ pass
+
+ @abstractmethod
+ def _migrate_and_save_telegram(self, new_id: TelegramID) -> None:
+ pass
+
+ @abstractmethod
+ def handle_matrix_power_levels(self, sender: 'u.User', new_levels: Dict[UserID, int],
+ old_levels: Dict[UserID, int]) -> Awaitable[None]:
+ pass
+
+ # endregion
+
+
+def init(context: Context) -> None:
+ global config
+ BasePortal.az, config, BasePortal.loop, BasePortal.bot = context.core
+ 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"]
+ BasePortal.public_portals = config["bridge.public_portals"]
+ BasePortal.filter_mode = config["bridge.filter.mode"]
+ BasePortal.filter_list = config["bridge.filter.list"]
+ BasePortal.hs_domain = config["homeserver.domain"]
+ BasePortal.alias_template = SimpleTemplate(config["bridge.alias_template"], "groupname",
+ prefix="#", suffix=f":{BasePortal.hs_domain}")
diff --git a/mautrix_telegram/portal/deduplication.py b/mautrix_telegram/portal/deduplication.py
new file mode 100644
index 00000000..d22b5927
--- /dev/null
+++ b/mautrix_telegram/portal/deduplication.py
@@ -0,0 +1,133 @@
+# 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 Optional, Deque, Dict, Tuple, TYPE_CHECKING
+from collections import deque
+import hashlib
+
+from telethon.tl.patched import Message, MessageService
+from telethon.tl.types import (MessageMediaContact, MessageMediaDocument, MessageMediaGeo,
+ MessageMediaPhoto, TypeMessage, TypeUpdates, UpdateNewMessage,
+ UpdateNewChannelMessage)
+
+from mautrix.types import EventID
+
+from ..context import Context
+from ..types import TelegramID
+
+if TYPE_CHECKING:
+ from .base import BasePortal
+
+DedupMXID = Tuple[EventID, TelegramID]
+
+
+class PortalDedup:
+ pre_db_check: bool = False
+ cache_queue_length: int = 20
+
+ _dedup: Deque[str]
+ _dedup_mxid: Dict[str, DedupMXID]
+ _dedup_action: Deque[str]
+ _portal: 'BasePortal'
+
+ def __init__(self, portal: 'BasePortal') -> None:
+ self._dedup = deque()
+ self._dedup_mxid = {}
+ self._dedup_action = deque()
+ self._portal = portal
+
+ @property
+ def _always_force_hash(self) -> bool:
+ return self._portal.peer_type != 'channel'
+
+ @staticmethod
+ def _hash_event(event: TypeMessage) -> str:
+ # Non-channel messages are unique per-user (wtf telegram), so we have no other choice than
+ # to deduplicate based on a hash of the message content.
+
+ # The timestamp is only accurate to the second, so we can't rely solely on that either.
+ if isinstance(event, MessageService):
+ hash_content = [event.date.timestamp(), event.from_id, event.action]
+ else:
+ hash_content = [event.date.timestamp(), event.message]
+ if event.fwd_from:
+ hash_content += [event.fwd_from.from_id, event.fwd_from.channel_id]
+ elif isinstance(event, Message) and event.media:
+ try:
+ hash_content += {
+ MessageMediaContact: lambda media: [media.user_id],
+ MessageMediaDocument: lambda media: [media.document.id],
+ MessageMediaPhoto: lambda media: [media.photo.id],
+ MessageMediaGeo: lambda media: [media.geo.long, media.geo.lat],
+ }[type(event.media)](event.media)
+ except KeyError:
+ pass
+ return hashlib.md5("-"
+ .join(str(a) for a in hash_content)
+ .encode("utf-8")
+ ).hexdigest()
+
+ def check_action(self, event: TypeMessage) -> bool:
+ evt_hash = self._hash_event(event) if self._always_force_hash else event.id
+ if evt_hash in self._dedup_action:
+ return True
+
+ self._dedup_action.append(evt_hash)
+
+ if len(self._dedup_action) > self.cache_queue_length:
+ self._dedup_action.popleft()
+ return False
+
+ def update(self, event: TypeMessage, mxid: DedupMXID = None,
+ expected_mxid: Optional[DedupMXID] = None, force_hash: bool = False
+ ) -> Optional[DedupMXID]:
+ evt_hash = self._hash_event(event) if self._always_force_hash or force_hash else event.id
+ try:
+ found_mxid = self._dedup_mxid[evt_hash]
+ except KeyError:
+ return EventID("None"), TelegramID(0)
+
+ if found_mxid != expected_mxid:
+ return found_mxid
+ self._dedup_mxid[evt_hash] = mxid
+ return None
+
+ def check(self, event: TypeMessage, mxid: DedupMXID = None, force_hash: bool = False
+ ) -> Optional[DedupMXID]:
+ evt_hash = (self._hash_event(event)
+ if self._always_force_hash or force_hash
+ else event.id)
+ if evt_hash in self._dedup:
+ return self._dedup_mxid[evt_hash]
+
+ self._dedup_mxid[evt_hash] = mxid
+ self._dedup.append(evt_hash)
+
+ if len(self._dedup) > self.cache_queue_length:
+ del self._dedup_mxid[self._dedup.popleft()]
+ return None
+
+ def register_outgoing_actions(self, response: TypeUpdates) -> None:
+ for update in response.updates:
+ check_dedup = (isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage))
+ and isinstance(update.message, MessageService))
+ if check_dedup:
+ self.check(update.message)
+
+
+def init(context: Context) -> None:
+ cfg = context.config
+ PortalDedup.dedup_pre_db_check = cfg["bridge.deduplication.pre_db_check"]
+ PortalDedup.dedup_cache_queue_length = cfg["bridge.deduplication.cache_queue_length"]
diff --git a/mautrix_telegram/portal/matrix.py b/mautrix_telegram/portal/matrix.py
new file mode 100644
index 00000000..aee53ed8
--- /dev/null
+++ b/mautrix_telegram/portal/matrix.py
@@ -0,0 +1,503 @@
+# 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 Awaitable, Dict, List, Optional, Tuple, Union, Any, TYPE_CHECKING
+from html import escape as escape_html
+from string import Template
+from abc import ABC
+import mimetypes
+
+import magic
+
+from telethon.tl.functions.messages import (EditChatPhotoRequest, EditChatTitleRequest,
+ UpdatePinnedMessageRequest, SetTypingRequest,
+ EditChatAboutRequest)
+from telethon.tl.functions.channels import EditPhotoRequest, EditTitleRequest, JoinChannelRequest
+from telethon.errors import (ChatNotModifiedError, PhotoExtInvalidError,
+ PhotoInvalidDimensionsError, PhotoSaveFileInvalidError)
+from telethon.tl.patched import Message, MessageService
+from telethon.tl.types import (
+ DocumentAttributeFilename, DocumentAttributeImageSize, GeoPoint,
+ InputChatUploadedPhoto, MessageActionChatEditPhoto, MessageMediaGeo,
+ SendMessageCancelAction, SendMessageTypingAction, TypeInputPeer, TypeMessageEntity,
+ UpdateNewMessage, InputMediaUploadedDocument)
+
+from mautrix.types import (EventID, RoomID, UserID, ContentURI, MessageType, MessageEventContent,
+ TextMessageEventContent, MediaMessageEventContent, Format,
+ LocationMessageEventContent)
+from mautrix.bridge import BasePortal as MautrixBasePortal
+
+from ..types import TelegramID
+from ..db import Message as DBMessage
+from ..util import sane_mimetypes
+from ..context import Context
+from .. import puppet as p, user as u, formatter, util
+from .base import BasePortal
+
+if TYPE_CHECKING:
+ from ..abstract_user import AbstractUser
+ from ..tgclient import MautrixTelegramClient
+ from ..config import Config
+
+TypeMessage = Union[Message, MessageService]
+
+config: Optional['Config'] = None
+
+
+class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
+ @staticmethod
+ def _get_file_meta(body: str, mime: str) -> str:
+ try:
+ current_extension = body[body.rindex("."):].lower()
+ body = body[:body.rindex(".")]
+ if mimetypes.types_map[current_extension] == mime:
+ return body + current_extension
+ except (ValueError, KeyError):
+ pass
+ if mime:
+ return f"matrix_upload{sane_mimetypes.guess_extension(mime)}"
+ return ""
+
+ async def _get_state_change_message(self, event: str, user: 'u.User', **kwargs: Any
+ ) -> Optional[str]:
+ tpl = self.get_config(f"state_event_formats.{event}")
+ if len(tpl) == 0:
+ # Empty format means they don't want the message
+ return None
+ displayname = await self.get_displayname(user)
+
+ tpl_args = {
+ "mxid": user.mxid,
+ "username": user.mxid_localpart,
+ "displayname": escape_html(displayname),
+ **kwargs,
+ }
+ return Template(tpl).safe_substitute(tpl_args)
+
+ async def _send_state_change_message(self, event: str, user: 'u.User', event_id: EventID,
+ **kwargs: Any) -> None:
+ if not self.has_bot:
+ return
+ async with self.send_lock(self.bot.tgid):
+ message = await self._get_state_change_message(event, user, **kwargs)
+ if not message:
+ return
+ response = await self.bot.client.send_message(
+ self.peer, message,
+ parse_mode=self._matrix_event_to_entities)
+ space = self.tgid if self.peer_type == "channel" else self.bot.tgid
+ self.dedup.check(response, (event_id, space))
+
+ async def name_change_matrix(self, user: 'u.User', displayname: str, prev_displayname: str,
+ event_id: EventID) -> None:
+ await self._send_state_change_message("name_change", user, event_id,
+ displayname=displayname,
+ prev_displayname=prev_displayname)
+
+ async def get_displayname(self, user: 'u.User') -> str:
+ return await self.main_intent.get_room_displayname(self.mxid, user.mxid) or user.mxid
+
+ def set_typing(self, user: 'u.User', typing: bool = True,
+ action: type = SendMessageTypingAction) -> Awaitable[bool]:
+ return user.client(SetTypingRequest(
+ self.peer, action() if typing else SendMessageCancelAction()))
+
+ async def mark_read(self, user: 'u.User', event_id: EventID) -> None:
+ if user.is_bot:
+ return
+ space = self.tgid if self.peer_type == "channel" else user.tgid
+ message = DBMessage.get_by_mxid(event_id, self.mxid, space)
+ if not message:
+ return
+ await user.client.send_read_acknowledge(self.peer, max_id=message.tgid,
+ clear_mentions=True)
+
+ async def kick_matrix(self, user: Union['u.User', 'p.Puppet'], source: 'u.User') -> None:
+ if user.tgid == source.tgid:
+ return
+ if self.peer_type == "user" and user.tgid == self.tgid:
+ self.delete()
+ try:
+ del self.by_tgid[self.tgid_full]
+ del self.by_mxid[self.mxid]
+ except KeyError:
+ pass
+ return
+ if isinstance(user, u.User) and await user.needs_relaybot(self):
+ if not self.bot:
+ return
+ # TODO kick and ban message
+ return
+ if await source.needs_relaybot(self):
+ if not self.has_bot:
+ return
+ source = self.bot
+ await source.client.kick_participant(self.peer, user.peer)
+
+ async def leave_matrix(self, user: 'u.User', event_id: EventID) -> None:
+ if await user.needs_relaybot(self):
+ await self._send_state_change_message("leave", user, event_id)
+ return
+
+ if self.peer_type == "user":
+ await self.main_intent.leave_room(self.mxid)
+ self.delete()
+ try:
+ del self.by_tgid[self.tgid_full]
+ del self.by_mxid[self.mxid]
+ except KeyError:
+ pass
+ else:
+ await user.client.delete_dialog(self.peer)
+
+ async def join_matrix(self, user: 'u.User', event_id: EventID) -> None:
+ if await user.needs_relaybot(self):
+ await self._send_state_change_message("join", user, event_id)
+ return
+
+ if self.peer_type == "channel" and not user.is_bot:
+ await user.client(JoinChannelRequest(channel=await self.get_input_entity(user)))
+ else:
+ # We'll just assume the user is already in the chat.
+ pass
+
+ async def _apply_msg_format(self, sender: 'u.User', content: MessageEventContent
+ ) -> None:
+ if isinstance(content, TextMessageEventContent) and content.format != Format.HTML:
+ content.format = Format.HTML
+ content.formatted_body = escape_html(content.body).replace("\n", "
")
+
+ tpl = (self.get_config(f"message_formats.[{content.msgtype.value}]")
+ or "$sender_displayname: $message")
+ displayname = await self.get_displayname(sender)
+ tpl_args = dict(sender_mxid=sender.mxid,
+ sender_username=sender.mxid_localpart,
+ sender_displayname=escape_html(displayname),
+ body=content.body)
+ if isinstance(content, TextMessageEventContent):
+ tpl_args["formatted_body"] = content.formatted_body
+ tpl_args["message"] = content.formatted_body
+ content.formatted_body = Template(tpl).safe_substitute(tpl_args)
+ else:
+ tpl_args["message"] = content.body
+ content.body = Template(tpl).safe_substitute(tpl_args)
+
+ async def _pre_process_matrix_message(self, sender: 'u.User', use_relaybot: bool,
+ content: MessageEventContent) -> None:
+ if content.msgtype == MessageType.EMOTE:
+ await self._apply_msg_format(sender, content)
+ content.msgtype = MessageType.TEXT
+ elif use_relaybot:
+ await self._apply_msg_format(sender, content)
+
+ @staticmethod
+ def _matrix_event_to_entities(event: Union[str, MessageEventContent]
+ ) -> Tuple[str, Optional[List[TypeMessageEntity]]]:
+ try:
+ if isinstance(event, str):
+ message, entities = formatter.matrix_to_telegram(event)
+ elif isinstance(event, TextMessageEventContent) and event.format == Format.HTML:
+ message, entities = formatter.matrix_to_telegram(event.formatted_body)
+ else:
+ message, entities = formatter.matrix_text_to_telegram(event.body)
+ except KeyError:
+ message, entities = None, None
+ return message, entities
+
+ async def _handle_matrix_text(self, sender_id: TelegramID, event_id: EventID,
+ space: TelegramID, client: 'MautrixTelegramClient',
+ content: TextMessageEventContent, reply_to: TelegramID) -> None:
+ async with self.send_lock(sender_id):
+ lp = self.get_config("telegram_link_preview")
+ if content.get_edit():
+ orig_msg = DBMessage.get_by_mxid(content.get_edit(), self.mxid, space)
+ if orig_msg:
+ response = await client.edit_message(self.peer, orig_msg.tgid, content,
+ parse_mode=self._matrix_event_to_entities,
+ link_preview=lp)
+ self._add_telegram_message_to_db(event_id, space, -1, response)
+ return
+ response = await client.send_message(self.peer, content, reply_to=reply_to,
+ parse_mode=self._matrix_event_to_entities,
+ link_preview=lp)
+ self._add_telegram_message_to_db(event_id, space, 0, response)
+
+ async def _handle_matrix_file(self, sender_id: TelegramID, event_id: EventID,
+ space: TelegramID, client: 'MautrixTelegramClient',
+ content: MediaMessageEventContent, reply_to: TelegramID) -> None:
+ file = await self.main_intent.download_media(content.url)
+
+ mime = content.info.mimetype
+
+ w, h = content.info.width, content.info.height
+
+ if content.msgtype == MessageType.STICKER:
+ if mime != "image/gif":
+ mime, file, w, h = util.convert_image(file, source_mime=mime, target_type="webp")
+ else:
+ # Remove sticker description
+ content["net.maunium.telegram.internal.filename"] = "sticker.gif"
+ content.body = ""
+
+ file_name = self._get_file_meta(content["net.maunium.telegram.internal.filename"], mime)
+ attributes = [DocumentAttributeFilename(file_name=file_name)]
+ if w and h:
+ attributes.append(DocumentAttributeImageSize(w, h))
+
+ caption = content.body if content.body.lower() != file_name.lower() else None
+
+ media = await client.upload_file_direct(
+ file, mime, attributes, file_name,
+ max_image_size=config["bridge.image_as_file_size"] * 1000 ** 2)
+ async with self.send_lock(sender_id):
+ if await self._matrix_document_edit(client, content, space, caption, media, event_id):
+ return
+ try:
+ response = await client.send_media(self.peer, media, reply_to=reply_to,
+ caption=caption)
+ except (PhotoInvalidDimensionsError, PhotoSaveFileInvalidError, PhotoExtInvalidError):
+ media = InputMediaUploadedDocument(file=media.file, mime_type=mime,
+ attributes=attributes)
+ response = await client.send_media(self.peer, media, reply_to=reply_to,
+ caption=caption)
+ self._add_telegram_message_to_db(event_id, space, 0, response)
+
+ async def _matrix_document_edit(self, client: 'MautrixTelegramClient',
+ content: MessageEventContent, space: TelegramID,
+ caption: str, media: Any, event_id: EventID) -> bool:
+ if content.get_edit():
+ orig_msg = DBMessage.get_by_mxid(content.get_edit(), self.mxid, space)
+ if orig_msg:
+ response = await client.edit_message(self.peer, orig_msg.tgid,
+ caption, file=media)
+ self._add_telegram_message_to_db(event_id, space, -1, response)
+ return True
+ return False
+
+ async def _handle_matrix_location(self, sender_id: TelegramID, event_id: EventID,
+ space: TelegramID, client: 'MautrixTelegramClient',
+ content: LocationMessageEventContent, reply_to: TelegramID
+ ) -> None:
+ try:
+ lat, long = content.geo_uri[len("geo:"):].split(",")
+ lat, long = float(lat), float(long)
+ except (KeyError, ValueError):
+ self.log.exception("Failed to parse location")
+ return None
+ caption, entities = self._matrix_event_to_entities(content)
+ media = MessageMediaGeo(geo=GeoPoint(lat, long, access_hash=0))
+
+ async with self.send_lock(sender_id):
+ if await self._matrix_document_edit(client, content, space, caption, media, event_id):
+ return
+ response = await client.send_media(self.peer, media, reply_to=reply_to,
+ caption=caption, entities=entities)
+ self._add_telegram_message_to_db(event_id, space, 0, response)
+
+ def _add_telegram_message_to_db(self, event_id: EventID, space: TelegramID,
+ edit_index: int, response: TypeMessage) -> None:
+ self.log.debug("Handled Matrix message: %s", response)
+ self.dedup.check(response, (event_id, space), force_hash=edit_index != 0)
+ if edit_index < 0:
+ prev_edit = DBMessage.get_one_by_tgid(TelegramID(response.id), space, -1)
+ edit_index = prev_edit.edit_index + 1
+ DBMessage(
+ tgid=TelegramID(response.id),
+ tg_space=space,
+ mx_room=self.mxid,
+ mxid=event_id,
+ edit_index=edit_index).insert()
+
+ async def handle_matrix_message(self, sender: 'u.User', content: MessageEventContent,
+ event_id: EventID) -> None:
+ if not content.body or not content.msgtype:
+ self.log.debug(f"Ignoring message {event_id} in {self.mxid} without body or msgtype")
+ return
+
+ puppet = p.Puppet.get_by_custom_mxid(sender.mxid)
+ if puppet and content.get("net.maunium.telegram.puppet", False):
+ self.log.debug("Ignoring puppet-sent message by confirmed puppet user %s", sender.mxid)
+ return
+
+ logged_in = not await sender.needs_relaybot(self)
+ client = sender.client if logged_in else self.bot.client
+ sender_id = sender.tgid if logged_in else self.bot.tgid
+ space = (self.tgid if self.peer_type == "channel" # Channels have their own ID space
+ else (sender.tgid if logged_in else self.bot.tgid))
+ reply_to = formatter.matrix_reply_to_telegram(content, space, room_id=self.mxid)
+
+ content["net.maunium.telegram.internal.filename"] = content.body
+ await self._pre_process_matrix_message(sender, not logged_in, content)
+
+ if content.msgtype == MessageType.NOTICE:
+ bridge_notices = self.get_config("bridge_notices.default")
+ excepted = sender.mxid in self.get_config("bridge_notices.exceptions")
+ if not bridge_notices and not excepted:
+ return
+
+ if content.msgtype in (MessageType.TEXT, MessageType.NOTICE):
+ await self._handle_matrix_text(sender_id, event_id, space, client, content, reply_to)
+ elif content.msgtype == MessageType.LOCATION:
+ await self._handle_matrix_location(sender_id, event_id, space, client, content,
+ reply_to)
+ elif content.msgtype in (MessageType.STICKER, MessageType.IMAGE, MessageType.FILE,
+ MessageType.AUDIO, MessageType.VIDEO):
+ await self._handle_matrix_file(sender_id, event_id, space, client, content, reply_to)
+ else:
+ self.log.debug(f"Unhandled Matrix event: {content}")
+
+ async def handle_matrix_pin(self, sender: 'u.User',
+ pinned_message: Optional[EventID]) -> None:
+ if self.peer_type != "chat" and self.peer_type != "channel":
+ return
+ try:
+ if not pinned_message:
+ await sender.client(UpdatePinnedMessageRequest(peer=self.peer, id=0))
+ else:
+ tg_space = self.tgid if self.peer_type == "channel" else sender.tgid
+ message = DBMessage.get_by_mxid(pinned_message, self.mxid, tg_space)
+ if message is None:
+ self.log.warning(f"Could not find pinned {pinned_message} in {self.mxid}")
+ return
+ await sender.client(UpdatePinnedMessageRequest(peer=self.peer, id=message.tgid))
+ except ChatNotModifiedError:
+ pass
+
+ async def handle_matrix_deletion(self, deleter: 'u.User', event_id: EventID) -> None:
+ real_deleter = deleter if not await deleter.needs_relaybot(self) else self.bot
+ space = self.tgid if self.peer_type == "channel" else real_deleter.tgid
+ message = DBMessage.get_by_mxid(event_id, self.mxid, space)
+ if not message:
+ return
+ if message.edit_index == 0:
+ await real_deleter.client.delete_messages(self.peer, [message.tgid])
+ else:
+ self.log.debug(f"Ignoring deletion of edit event {message.mxid} in {message.mx_room}")
+
+ async def _update_telegram_power_level(self, sender: 'u.User', user_id: TelegramID,
+ level: int) -> None:
+ moderator = level >= 50
+ admin = level >= 75
+ await sender.client.edit_admin(self.peer, user_id,
+ change_info=moderator, post_messages=moderator,
+ edit_messages=moderator, delete_messages=moderator,
+ ban_users=moderator, invite_users=moderator,
+ pin_messages=moderator, add_admins=admin)
+
+ async def handle_matrix_power_levels(self, sender: 'u.User', new_users: Dict[UserID, int],
+ old_users: Dict[UserID, int]) -> None:
+ # TODO handle all power level changes and bridge exact admin rights to supergroups/channels
+ for user, level in new_users.items():
+ if not user or user == self.main_intent.mxid or user == sender.mxid:
+ continue
+ user_id = p.Puppet.get_id_from_mxid(user)
+ if not user_id:
+ mx_user = u.User.get_by_mxid(user, create=False)
+ if not mx_user or not mx_user.tgid:
+ continue
+ user_id = mx_user.tgid
+ if not user_id or user_id == sender.tgid:
+ continue
+ if user not in old_users or level != old_users[user]:
+ await self._update_telegram_power_level(sender, user_id, level)
+
+ async def handle_matrix_about(self, sender: 'u.User', about: str) -> None:
+ if self.peer_type not in ("chat", "channel"):
+ return
+ peer = await self.get_input_entity(sender)
+ await sender.client(EditChatAboutRequest(peer=peer, about=about))
+ self.about = about
+ self.save()
+
+ async def handle_matrix_title(self, sender: 'u.User', title: str) -> None:
+ if self.peer_type not in ("chat", "channel"):
+ return
+
+ if self.peer_type == "chat":
+ response = await sender.client(EditChatTitleRequest(chat_id=self.tgid, title=title))
+ else:
+ channel = await self.get_input_entity(sender)
+ response = await sender.client(EditTitleRequest(channel=channel, title=title))
+ self.dedup.register_outgoing_actions(response)
+ self.title = title
+ self.save()
+
+ async def handle_matrix_avatar(self, sender: 'u.User', url: ContentURI) -> None:
+ if self.peer_type not in ("chat", "channel"):
+ # Invalid peer type
+ return
+
+ file = await self.main_intent.download_media(url)
+ mime = magic.from_buffer(file, mime=True)
+ ext = sane_mimetypes.guess_extension(mime)
+ uploaded = await sender.client.upload_file(file, file_name=f"avatar{ext}")
+ photo = InputChatUploadedPhoto(file=uploaded)
+
+ if self.peer_type == "chat":
+ response = await sender.client(EditChatPhotoRequest(chat_id=self.tgid, photo=photo))
+ else:
+ channel = await self.get_input_entity(sender)
+ response = await sender.client(EditPhotoRequest(channel=channel, photo=photo))
+ self.dedup.register_outgoing_actions(response)
+ for update in response.updates:
+ is_photo_update = (isinstance(update, UpdateNewMessage)
+ and isinstance(update.message, MessageService)
+ and isinstance(update.message.action, MessageActionChatEditPhoto))
+ if is_photo_update:
+ loc, size = self._get_largest_photo_size(update.message.action.photo)
+ self.photo_id = f"{size.location.volume_id}-{size.location.local_id}"
+ self.save()
+ break
+
+ async def handle_matrix_upgrade(self, new_room: RoomID) -> None:
+ old_room = self.mxid
+ self.migrate_and_save_matrix(new_room)
+ await self.main_intent.join_room(new_room)
+ entity: Optional[TypeInputPeer] = None
+ user: Optional[AbstractUser] = None
+ if self.bot and self.has_bot:
+ user = self.bot
+ entity = await self.get_input_entity(self.bot)
+ if not entity:
+ user_mxids = await self.main_intent.get_room_members(self.mxid)
+ for user_str in user_mxids:
+ user_id = UserID(user_str)
+ if user_id == self.az.bot_mxid:
+ continue
+ user = u.User.get_by_mxid(user_id, create=False)
+ if user and user.tgid:
+ entity = await self.get_input_entity(user)
+ if entity:
+ break
+ if not entity:
+ self.log.error("Failed to fully migrate to upgraded Matrix room: "
+ "no Telegram user found.")
+ return
+ await self.update_matrix_room(user, entity, direct=self.peer_type == "user")
+ self.log.info(f"Upgraded room from {old_room} to {self.mxid}")
+
+ def migrate_and_save_matrix(self, new_id: RoomID) -> None:
+ try:
+ del self.by_mxid[self.mxid]
+ except KeyError:
+ pass
+ self.mxid = new_id
+ self.db_instance.edit(mxid=self.mxid)
+ self.by_mxid[self.mxid] = self
+
+
+def init(context: Context) -> None:
+ global config
+ config = context.config
diff --git a/mautrix_telegram/portal/metadata.py b/mautrix_telegram/portal/metadata.py
new file mode 100644
index 00000000..e5f3ccea
--- /dev/null
+++ b/mautrix_telegram/portal/metadata.py
@@ -0,0 +1,666 @@
+# 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 List, Optional, Tuple, Union, TYPE_CHECKING
+from abc import ABC
+import asyncio
+
+from telethon.tl.functions.messages import (AddChatUserRequest, CreateChatRequest,
+ GetFullChatRequest, MigrateChatRequest)
+from telethon.tl.functions.channels import (CreateChannelRequest, GetParticipantsRequest,
+ InviteToChannelRequest, UpdateUsernameRequest)
+from telethon.errors import ChatAdminRequiredError
+from telethon.tl.types import (
+ Channel, ChatBannedRights, ChannelParticipantsRecent, ChannelParticipantsSearch, ChatPhoto,
+ PhotoEmpty, InputChannel, InputUser, ChatPhotoEmpty, PeerUser, Photo, TypeChat, TypeInputPeer,
+ TypeUser, User, InputPeerPhotoFileLocation, ChatParticipantAdmin, ChannelParticipantAdmin,
+ ChatParticipantCreator, ChannelParticipantCreator)
+
+from mautrix.errors import MForbidden
+from mautrix.types import (RoomID, UserID, RoomCreatePreset, EventType, Membership, Member,
+ PowerLevelStateEventContent, RoomAlias)
+
+from ..types import TelegramID
+from ..context import Context
+from .. import puppet as p, user as u, util
+from .base import BasePortal, InviteList, TypeParticipant, TypeChatPhoto
+
+if TYPE_CHECKING:
+ from ..abstract_user import AbstractUser
+ from ..config import Config
+
+config: Optional['Config'] = None
+
+
+class PortalMetadata(BasePortal, ABC):
+ _room_create_lock: asyncio.Lock
+
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self._room_create_lock = asyncio.Lock()
+
+ # region Matrix -> Telegram
+
+ async def _get_telegram_users_in_matrix_room(self) -> List[Union[InputUser, PeerUser]]:
+ user_tgids = set()
+ user_mxids = await self.main_intent.get_room_members(self.mxid, (Membership.JOIN,
+ Membership.INVITE))
+ for user_str in user_mxids:
+ user = UserID(user_str)
+ if user == self.az.bot_mxid:
+ continue
+ mx_user = u.User.get_by_mxid(user, create=False)
+ if mx_user and mx_user.tgid:
+ user_tgids.add(mx_user.tgid)
+ puppet_id = p.Puppet.get_id_from_mxid(user)
+ if puppet_id:
+ user_tgids.add(puppet_id)
+ return [PeerUser(user_id) for user_id in user_tgids]
+
+ async def upgrade_telegram_chat(self, source: 'u.User') -> None:
+ if self.peer_type != "chat":
+ raise ValueError("Only normal group chats are upgradable to supergroups.")
+
+ response = await source.client(MigrateChatRequest(chat_id=self.tgid))
+ entity = None
+ for chat in response.chats:
+ if isinstance(chat, Channel):
+ entity = chat
+ break
+ if not entity:
+ raise ValueError("Upgrade may have failed: output channel not found.")
+ self.peer_type = "channel"
+ self._migrate_and_save_telegram(TelegramID(entity.id))
+ await self.update_info(source, entity)
+
+ def _migrate_and_save_telegram(self, new_id: TelegramID) -> None:
+ try:
+ del self.by_tgid[self.tgid_full]
+ except KeyError:
+ pass
+ try:
+ existing = self.by_tgid[(new_id, new_id)]
+ existing.delete()
+ except KeyError:
+ pass
+ self.db_instance.edit(tgid=new_id, tg_receiver=new_id, peer_type=self.peer_type)
+ old_id = self.tgid
+ self.tgid = new_id
+ self.tg_receiver = new_id
+ self.by_tgid[self.tgid_full] = self
+ self.log = self.base_log.getChild(self.tgid_log)
+ self.log.info(f"Telegram chat upgraded from {old_id}")
+
+ async def set_telegram_username(self, source: 'u.User', username: str) -> None:
+ if self.peer_type != "channel":
+ raise ValueError("Only channels and supergroups have usernames.")
+ await source.client(
+ UpdateUsernameRequest(await self.get_input_entity(source), username))
+ if await self._update_username(username):
+ self.save()
+
+ async def create_telegram_chat(self, source: 'u.User', supergroup: bool = False) -> None:
+ if not self.mxid:
+ raise ValueError("Can't create Telegram chat for portal without Matrix room.")
+ elif self.tgid:
+ raise ValueError("Can't create Telegram chat for portal with existing Telegram chat.")
+
+ invites = await self._get_telegram_users_in_matrix_room()
+ if len(invites) < 2:
+ if self.bot is not None:
+ info, mxid = await self.bot.get_me()
+ raise ValueError("Not enough Telegram users to create a chat. "
+ "Invite more Telegram ghost users to the room, such as the "
+ f"relaybot ([{info.first_name}](https://matrix.to/#/{mxid})).")
+ raise ValueError("Not enough Telegram users to create a chat. "
+ "Invite more Telegram ghost users to the room.")
+ if self.peer_type == "chat":
+ response = await source.client(CreateChatRequest(title=self.title, users=invites))
+ entity = response.chats[0]
+ elif self.peer_type == "channel":
+ response = await source.client(CreateChannelRequest(title=self.title,
+ about=self.about or "",
+ megagroup=supergroup))
+ entity = response.chats[0]
+ await source.client(InviteToChannelRequest(
+ channel=await source.client.get_input_entity(entity),
+ users=invites))
+ else:
+ raise ValueError("Invalid peer type for Telegram chat creation")
+
+ self.tgid = entity.id
+ self.tg_receiver = self.tgid
+ self.by_tgid[self.tgid_full] = self
+ await self.update_info(source, entity)
+ self.db_instance.insert()
+ self.log = self.base_log.getChild(self.tgid_log)
+
+ if self.bot and self.bot.tgid in invites:
+ self.bot.add_chat(self.tgid, self.peer_type)
+
+ levels = await self.main_intent.get_power_levels(self.mxid)
+ if levels.get_user_level(self.main_intent.mxid) == 100:
+ levels = self._get_base_power_levels(levels, entity)
+ await self.main_intent.set_power_levels(self.mxid, levels)
+ await self.handle_matrix_power_levels(source, levels.users, {})
+
+ async def invite_telegram(self, source: 'u.User',
+ puppet: Union[p.Puppet, 'AbstractUser']) -> None:
+ if self.peer_type == "chat":
+ await source.client(
+ AddChatUserRequest(chat_id=self.tgid, user_id=puppet.tgid, fwd_limit=0))
+ elif self.peer_type == "channel":
+ await source.client(InviteToChannelRequest(channel=self.peer, users=[puppet.tgid]))
+ else:
+ raise ValueError("Invalid peer type for Telegram user invite")
+
+ async def sync_matrix_members(self) -> None:
+ resp = await self.main_intent.get_room_joined_memberships(self.mxid)
+ members = resp["joined"]
+ for mxid, info in members.items():
+ member = Member(membership=Membership.JOIN)
+ if "display_name" in info:
+ member.displayname = info["display_name"]
+ if "avatar_url" in info:
+ member.avatar_url = info["avatar_url"]
+ self.az.state_store.set_member(self.mxid, mxid, member)
+
+ # endregion
+ # region Telegram -> Matrix
+
+ async def invite_to_matrix(self, users: InviteList) -> None:
+ if isinstance(users, list):
+ for user in users:
+ await self.main_intent.invite_user(self.mxid, user, check_cache=True)
+ else:
+ await self.main_intent.invite_user(self.mxid, users, check_cache=True)
+
+ async def update_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User],
+ direct: bool = None, puppet: p.Puppet = None,
+ levels: PowerLevelStateEventContent = None,
+ users: List[User] = None,
+ participants: List[TypeParticipant] = None) -> None:
+ if direct is None:
+ direct = self.peer_type == "user"
+ try:
+ await self._update_matrix_room(user, entity, direct, puppet, levels, users,
+ participants)
+ except Exception:
+ self.log.exception("Fatal error updating Matrix room")
+
+ async def _update_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User],
+ direct: bool, puppet: p.Puppet = None,
+ levels: PowerLevelStateEventContent = None,
+ users: List[User] = None,
+ participants: List[TypeParticipant] = None) -> None:
+ if not direct:
+ await self.update_info(user, entity)
+ if not users or not participants:
+ users, participants = await self._get_users(user, entity)
+ await self._sync_telegram_users(user, users)
+ await self.update_telegram_participants(participants, levels)
+ else:
+ if not puppet:
+ puppet = p.Puppet.get(self.tgid)
+ await puppet.update_info(user, entity)
+ await puppet.intent_for(self).join_room(self.mxid)
+ if self.sync_matrix_state:
+ await self.sync_matrix_members()
+
+ async def create_matrix_room(self, user: 'AbstractUser', entity: TypeChat = None,
+ invites: InviteList = None, update_if_exists: bool = True,
+ synchronous: bool = False) -> Optional[str]:
+ if self.mxid:
+ if update_if_exists:
+ if not entity:
+ entity = await self.get_entity(user)
+ update = self.update_matrix_room(user, entity, self.peer_type == "user")
+ if synchronous:
+ await update
+ else:
+ asyncio.ensure_future(update, loop=self.loop)
+ await self.invite_to_matrix(invites or [])
+ return self.mxid
+ async with self._room_create_lock:
+ try:
+ return await self._create_matrix_room(user, entity, invites)
+ except Exception:
+ self.log.exception("Fatal error creating Matrix room")
+
+ async def _create_matrix_room(self, user: 'AbstractUser', entity: TypeChat, invites: InviteList
+ ) -> Optional[RoomID]:
+ direct = self.peer_type == "user"
+
+ if self.mxid:
+ return self.mxid
+
+ if not self.allow_bridging:
+ return None
+
+ if not entity:
+ entity = await self.get_entity(user)
+ self.log.debug(f"Fetched data: {entity}")
+
+ self.log.debug("Creating room")
+
+ try:
+ self.title = entity.title
+ except AttributeError:
+ self.title = None
+
+ if direct and self.tgid == user.tgid:
+ self.title = "Telegram Saved Messages"
+ self.about = "Your Telegram cloud storage chat"
+
+ puppet = p.Puppet.get(self.tgid) if direct else None
+ self._main_intent = puppet.intent_for(self) if direct else self.az.intent
+
+ if self.peer_type == "channel":
+ self.megagroup = entity.megagroup
+
+ if self.peer_type == "channel" and entity.username:
+ preset = RoomCreatePreset.PUBLIC
+ alias = self._get_alias_localpart(entity.username)
+ self.username = entity.username
+ else:
+ preset = RoomCreatePreset.PRIVATE
+ # TODO invite link alias?
+ alias = None
+
+ if alias:
+ # TODO? properly handle existing room aliases
+ await self.main_intent.remove_room_alias(alias)
+
+ power_levels = self._get_base_power_levels(entity=entity)
+ users = participants = None
+ if not direct:
+ users, participants = await self._get_users(user, entity)
+ self._participants_to_power_levels(participants, power_levels)
+ initial_state = [{
+ "type": EventType.ROOM_POWER_LEVELS.serialize(),
+ "content": power_levels.serialize(),
+ }]
+ if config["appservice.community_id"]:
+ initial_state.append({
+ "type": "m.room.related_groups",
+ "content": {"groups": [config["appservice.community_id"]]},
+ })
+
+ room_id = await self.main_intent.create_room(alias_localpart=alias, preset=preset,
+ is_direct=direct, invitees=invites or [],
+ name=self.title, topic=self.about,
+ initial_state=initial_state)
+ if not room_id:
+ raise Exception(f"Failed to create room")
+
+ self.mxid = RoomID(room_id)
+ self.by_mxid[self.mxid] = self
+ self.save()
+ self.az.state_store.set_power_levels(self.mxid, power_levels)
+ user.register_portal(self)
+ asyncio.ensure_future(self.update_matrix_room(user, entity, direct, puppet,
+ levels=power_levels, users=users,
+ participants=participants), loop=self.loop)
+
+ return self.mxid
+
+ def _get_base_power_levels(self, levels: PowerLevelStateEventContent = None,
+ entity: TypeChat = None) -> PowerLevelStateEventContent:
+ levels = levels or PowerLevelStateEventContent()
+ if self.peer_type == "user":
+ levels.ban = 100
+ levels.kick = 100
+ levels.invite = 100
+ levels.redact = 0
+ levels.events[EventType.ROOM_NAME] = 0
+ levels.events[EventType.ROOM_AVATAR] = 0
+ levels.events[EventType.ROOM_TOPIC] = 0
+ levels.state_default = 0
+ levels.users_default = 0
+ levels.events_default = 0
+ else:
+ dbr = entity.default_banned_rights
+ if not dbr:
+ self.log.debug(f"default_banned_rights is None in {entity}")
+ dbr = ChatBannedRights(invite_users=True, change_info=True, pin_messages=True,
+ send_stickers=False, send_messages=False, until_date=None)
+ levels.ban = 99
+ levels.kick = 50
+ levels.redact = 50
+ levels.invite = 50 if dbr.invite_users else 0
+ levels.events[EventType.ROOM_ENCRYPTED] = 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
+ levels.events[EventType.ROOM_TOPIC] = 50 if dbr.change_info else 0
+ levels.events[EventType.ROOM_PINNED_EVENTS] = 50 if dbr.pin_messages else 0
+ levels.events[EventType.ROOM_POWER_LEVELS] = 75
+ levels.events[EventType.ROOM_HISTORY_VISIBILITY] = 75
+ levels.state_default = 50
+ levels.users_default = 0
+ levels.events_default = (50 if (self.peer_type == "channel" and not entity.megagroup
+ or entity.default_banned_rights.send_messages)
+ else 0)
+ levels.events[EventType.STICKER] = 50 if dbr.send_stickers else levels.events_default
+ levels.users[self.main_intent.mxid] = 100
+ return levels
+
+ @staticmethod
+ def _get_level_from_participant(participant: TypeParticipant) -> int:
+ # TODO use the power level requirements to get better precision in channels
+ if isinstance(participant, (ChatParticipantAdmin, ChannelParticipantAdmin)):
+ return 50
+ elif isinstance(participant, (ChatParticipantCreator, ChannelParticipantCreator)):
+ return 95
+ return 0
+
+ @staticmethod
+ def _participant_to_power_levels(levels: PowerLevelStateEventContent,
+ user: Union['u.User', p.Puppet], new_level: int,
+ bot_level: int) -> bool:
+ new_level = min(new_level, bot_level)
+ user_level = levels.get_user_level(user.mxid)
+ if user_level != new_level and user_level < bot_level:
+ levels.users[user.mxid] = new_level
+ return True
+ return False
+
+ def _participants_to_power_levels(self, participants: List[TypeParticipant],
+ levels: PowerLevelStateEventContent) -> bool:
+ bot_level = levels.get_user_level(self.main_intent.mxid)
+ if bot_level < levels.get_event_level(EventType.ROOM_POWER_LEVELS):
+ return False
+ changed = False
+ admin_power_level = min(75 if self.peer_type == "channel" else 50, bot_level)
+ if levels.events[EventType.ROOM_POWER_LEVELS] != admin_power_level:
+ changed = True
+ levels.events[EventType.ROOM_POWER_LEVELS] = admin_power_level
+
+ for participant in participants:
+ puppet = p.Puppet.get(TelegramID(participant.user_id))
+ user = u.User.get_by_tgid(TelegramID(participant.user_id))
+ new_level = self._get_level_from_participant(participant)
+
+ if user:
+ user.register_portal(self)
+ changed = self._participant_to_power_levels(levels, user, new_level,
+ bot_level) or changed
+
+ if puppet:
+ changed = self._participant_to_power_levels(levels, puppet, new_level,
+ bot_level) or changed
+ return changed
+
+ async def update_telegram_participants(self, participants: List[TypeParticipant],
+ levels: PowerLevelStateEventContent = None) -> None:
+ if not levels:
+ levels = await self.main_intent.get_power_levels(self.mxid)
+ if self._participants_to_power_levels(participants, levels):
+ await self.main_intent.set_power_levels(self.mxid, levels)
+
+ @property
+ def alias(self) -> Optional[RoomAlias]:
+ if not self.username:
+ return None
+ return RoomAlias(f"#{self._get_alias_localpart()}:{self.hs_domain}")
+
+ def _get_alias_localpart(self, username: Optional[str] = None) -> Optional[str]:
+ username = username or self.username
+ if not username:
+ return None
+ return self.alias_template.format(username)
+
+ def _add_bot_chat(self, bot: User) -> None:
+ if self.bot and bot.id == self.bot.tgid:
+ self.bot.add_chat(self.tgid, self.peer_type)
+ return
+
+ user = u.User.get_by_tgid(TelegramID(bot.id))
+ if user and user.is_bot:
+ user.register_portal(self)
+
+ async def _sync_telegram_users(self, source: 'AbstractUser', users: List[User]) -> None:
+ allowed_tgids = set()
+ skip_deleted = config["bridge.skip_deleted_members"]
+ for entity in users:
+ if skip_deleted and entity.deleted:
+ continue
+ puppet = p.Puppet.get(TelegramID(entity.id))
+ if entity.bot:
+ self._add_bot_chat(entity)
+ allowed_tgids.add(entity.id)
+ await puppet.intent_for(self).ensure_joined(self.mxid)
+ await puppet.update_info(source, entity)
+
+ user = u.User.get_by_tgid(TelegramID(entity.id))
+ if user:
+ await self.invite_to_matrix(user.mxid)
+
+ # We can't trust the member list if any of the following cases is true:
+ # * There are close to 10 000 users, because Telegram might not be sending all members.
+ # * The member sync count is limited, because then we might ignore some members.
+ # * It's a channel, because non-admins don't have access to the member list.
+ trust_member_list = (len(allowed_tgids) < 9900
+ and self.max_initial_member_sync == -1
+ and (self.megagroup or self.peer_type != "channel"))
+ if trust_member_list:
+ joined_mxids = await self.main_intent.get_room_members(self.mxid)
+ for user_mxid in joined_mxids:
+ if user_mxid == self.az.bot_mxid:
+ continue
+ puppet_id = p.Puppet.get_id_from_mxid(user_mxid)
+ if puppet_id and puppet_id not in allowed_tgids:
+ if self.bot and puppet_id == self.bot.tgid:
+ self.bot.remove_chat(self.tgid)
+ await self.main_intent.kick_user(self.mxid, user_mxid,
+ "User had left this Telegram chat.")
+ continue
+ mx_user = u.User.get_by_mxid(user_mxid, create=False)
+ if mx_user and mx_user.is_bot and mx_user.tgid not in allowed_tgids:
+ mx_user.unregister_portal(self)
+
+ if mx_user and not self.has_bot and mx_user.tgid not in allowed_tgids:
+ await self.main_intent.kick_user(self.mxid, mx_user.mxid,
+ "You had left this Telegram chat.")
+ continue
+
+ async def _add_telegram_user(self, user_id: TelegramID, source: Optional['AbstractUser'] = None
+ ) -> None:
+ puppet = p.Puppet.get(user_id)
+ if source:
+ entity: User = await source.client.get_entity(PeerUser(user_id))
+ await puppet.update_info(source, entity)
+ await puppet.intent_for(self).ensure_joined(self.mxid)
+
+ user = u.User.get_by_tgid(user_id)
+ if user:
+ user.register_portal(self)
+ await self.invite_to_matrix(user.mxid)
+
+ async def _delete_telegram_user(self, user_id: TelegramID, sender: p.Puppet) -> None:
+ puppet = p.Puppet.get(user_id)
+ user = u.User.get_by_tgid(user_id)
+ kick_message = (f"Kicked by {sender.displayname}"
+ if sender and sender.tgid != puppet.tgid
+ else "Left Telegram chat")
+ if sender.tgid != puppet.tgid:
+ try:
+ await sender.intent_for(self).kick_user(self.mxid, puppet.mxid)
+ except MForbidden:
+ await self.main_intent.kick_user(self.mxid, puppet.mxid, kick_message)
+ else:
+ await puppet.intent_for(self).leave_room(self.mxid)
+ if user:
+ user.unregister_portal(self)
+ if sender.tgid != puppet.tgid:
+ try:
+ await sender.intent_for(self).kick_user(self.mxid, puppet.mxid)
+ return
+ except MForbidden:
+ pass
+ try:
+ await self.main_intent.kick_user(self.mxid, user.mxid, kick_message)
+ except MForbidden as e:
+ self.log.warning(f"Failed to kick {user.mxid}: {e}")
+
+ async def update_info(self, user: 'AbstractUser', entity: TypeChat = None) -> None:
+ if self.peer_type == "user":
+ self.log.warning("Called update_info() for direct chat portal")
+ return
+
+ self.log.debug("Updating info")
+ if not entity:
+ entity = await self.get_entity(user)
+ self.log.debug(f"Fetched data: {entity}")
+ changed = False
+
+ if self.peer_type == "channel":
+ changed = await self._update_username(entity.username) or changed
+
+ if hasattr(entity, "about"):
+ changed = self._update_about(entity.about) or changed
+
+ changed = await self._update_title(entity.title) or changed
+
+ if isinstance(entity.photo, ChatPhoto):
+ changed = await self._update_avatar(user, entity.photo) or changed
+
+ if changed:
+ self.save()
+
+ async def _update_username(self, username: str, save: bool = False) -> bool:
+ if self.username == username:
+ return False
+
+ if self.username:
+ await self.main_intent.remove_room_alias(self._get_alias_localpart())
+ self.username = username or None
+ if self.username:
+ await self.main_intent.add_room_alias(self.mxid, self._get_alias_localpart(),
+ override=True)
+ if self.public_portals:
+ await self.main_intent.set_join_rule(self.mxid, "public")
+ else:
+ await self.main_intent.set_join_rule(self.mxid, "invite")
+
+ if save:
+ self.save()
+ return True
+
+ async def _update_about(self, about: str, save: bool = False) -> bool:
+ if self.about == about:
+ return False
+
+ self.about = about
+ await self.main_intent.set_room_topic(self.mxid, self.about)
+ if save:
+ self.save()
+ return True
+
+ async def _update_title(self, title: str, save: bool = False) -> bool:
+ if self.title == title:
+ return False
+
+ self.title = title
+ await self.main_intent.set_room_name(self.mxid, self.title)
+ if save:
+ self.save()
+ return True
+
+ async def _update_avatar(self, user: 'AbstractUser', photo: TypeChatPhoto, save: bool = False
+ ) -> bool:
+ if isinstance(photo, ChatPhoto):
+ loc = InputPeerPhotoFileLocation(
+ peer=await self.get_input_entity(user),
+ local_id=photo.photo_big.local_id,
+ volume_id=photo.photo_big.volume_id,
+ big=True
+ )
+ photo_id = f"{loc.volume_id}-{loc.local_id}"
+ elif isinstance(photo, Photo):
+ loc, largest = self._get_largest_photo_size(photo)
+ photo_id = f"{largest.location.volume_id}-{largest.location.local_id}"
+ elif isinstance(photo, (ChatPhotoEmpty, PhotoEmpty)):
+ photo_id = ""
+ loc = None
+ else:
+ raise ValueError(f"Unknown photo type {type(photo)}")
+ if self.photo_id != photo_id:
+ if not photo_id:
+ await self.main_intent.set_room_avatar(self.mxid, None)
+ self.photo_id = ""
+ if save:
+ self.save()
+ return True
+ file = await util.transfer_file_to_matrix(user.client, self.main_intent, loc)
+ if file:
+ await self.main_intent.set_room_avatar(self.mxid, file.mxc)
+ self.photo_id = photo_id
+ if save:
+ self.save()
+ return True
+ return False
+
+ async def _get_users(self, user: 'AbstractUser',
+ entity: Union[TypeInputPeer, InputUser, TypeChat, TypeUser, InputChannel]
+ ) -> Tuple[List[TypeUser], List[TypeParticipant]]:
+ # TODO replace with client.get_participants
+ if self.peer_type == "chat":
+ chat = await user.client(GetFullChatRequest(chat_id=self.tgid))
+ return chat.users, chat.full_chat.participants.participants
+ elif self.peer_type == "channel":
+ if not self.megagroup and not self.sync_channel_members:
+ return [], []
+
+ limit = self.max_initial_member_sync
+ if limit == 0:
+ return [], []
+
+ try:
+ if 0 < limit <= 200:
+ response = await user.client(GetParticipantsRequest(
+ entity, ChannelParticipantsRecent(), offset=0, limit=limit, hash=0))
+ return response.users, response.participants
+ elif limit > 200 or limit == -1:
+ users: List[TypeUser] = []
+ participants: List[TypeParticipant] = []
+ offset = 0
+ remaining_quota = limit if limit > 0 else 1000000
+ query = (ChannelParticipantsSearch("") if limit == -1
+ else ChannelParticipantsRecent())
+ while True:
+ if remaining_quota <= 0:
+ break
+ response = await user.client(GetParticipantsRequest(
+ entity, query, offset=offset, limit=min(remaining_quota, 100), hash=0))
+ if not response.users:
+ break
+ participants += response.participants
+ users += response.users
+ offset += len(response.participants)
+ remaining_quota -= len(response.participants)
+ return users, participants
+ except ChatAdminRequiredError:
+ return [], []
+ elif self.peer_type == "user":
+ return [entity], []
+ return [], []
+
+ # endregion
+
+
+def init(context: Context) -> None:
+ global config
+ config = context.config
diff --git a/mautrix_telegram/portal/send_lock.py b/mautrix_telegram/portal/send_lock.py
new file mode 100644
index 00000000..c760f44b
--- /dev/null
+++ b/mautrix_telegram/portal/send_lock.py
@@ -0,0 +1,44 @@
+# 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 Dict
+from asyncio import Lock
+
+from ..types import TelegramID
+
+
+class FakeLock:
+ async def __aenter__(self) -> None:
+ pass
+
+ async def __aexit__(self, exc_type, exc, tb) -> None:
+ pass
+
+
+class PortalSendLock:
+ _send_locks: Dict[int, Lock]
+ _noop_lock: Lock = FakeLock()
+
+ def __init__(self) -> None:
+ self._send_locks = {}
+
+ def __call__(self, user_id: TelegramID, required: bool = True) -> Lock:
+ if user_id is None and required:
+ raise ValueError("Required send lock for none id")
+ try:
+ return self._send_locks[user_id]
+ except KeyError:
+ return (self._send_locks.setdefault(user_id, Lock())
+ if required else self._noop_lock)
diff --git a/mautrix_telegram/portal/telegram.py b/mautrix_telegram/portal/telegram.py
new file mode 100644
index 00000000..1aee58b8
--- /dev/null
+++ b/mautrix_telegram/portal/telegram.py
@@ -0,0 +1,556 @@
+# 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 Awaitable, Dict, List, Optional, Tuple, Union, NamedTuple, TYPE_CHECKING
+from html import escape as escape_html
+from abc import ABC
+import random
+import mimetypes
+import codecs
+import unicodedata
+import base64
+
+from sqlalchemy.exc import IntegrityError
+
+from telethon.tl.patched import Message, MessageService
+from telethon.tl.types import (
+ Poll, DocumentAttributeFilename, DocumentAttributeSticker, DocumentAttributeVideo,
+ MessageMediaPoll, MessageActionChannelCreate, MessageActionChatAddUser,
+ MessageActionChatCreate, MessageActionChatDeletePhoto, MessageActionChatDeleteUser,
+ MessageActionChatEditPhoto, MessageActionChatEditTitle, MessageActionChatJoinedByLink,
+ MessageActionChatMigrateTo, MessageActionPinMessage, MessageActionGameScore,
+ MessageMediaDocument, MessageMediaGeo, MessageMediaPhoto, MessageMediaUnsupported,
+ MessageMediaGame, PeerUser, PhotoCachedSize, TypeChannelParticipant, TypeChatParticipant,
+ TypeDocumentAttribute, TypeMessageAction, TypePhotoSize, PhotoSize, UpdateChatUserTyping,
+ UpdateUserTyping, MessageEntityPre, ChatPhotoEmpty)
+
+from mautrix.appservice import IntentAPI
+from mautrix.types import (EventID, UserID, ImageInfo, ThumbnailInfo, RelatesTo, MessageType,
+ EventType, MediaMessageEventContent, TextMessageEventContent,
+ LocationMessageEventContent, Format)
+
+from ..types import TelegramID
+from ..db import Message as DBMessage, TelegramFile as DBTelegramFile
+from ..util import sane_mimetypes
+from ..context import Context
+from .. import puppet as p, user as u, formatter, util
+from .base import BasePortal
+
+if TYPE_CHECKING:
+ from ..abstract_user import AbstractUser
+ from ..config import Config
+
+InviteList = Union[UserID, List[UserID]]
+TypeParticipant = Union[TypeChatParticipant, TypeChannelParticipant]
+DocAttrs = NamedTuple("DocAttrs", name=Optional[str], mime_type=Optional[str], is_sticker=bool,
+ sticker_alt=Optional[str], width=int, height=int)
+
+config: Optional['Config'] = None
+
+
+class PortalTelegram(BasePortal, ABC):
+ _temp_pinned_message_id: Optional[TelegramID]
+ _temp_pinned_message_id_space: Optional[TelegramID]
+ _temp_pinned_message_sender: Optional['p.Puppet']
+
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self._temp_pinned_message_id = None
+ self._temp_pinned_message_id_space = None
+ self._temp_pinned_message_sender = None
+
+ async def handle_telegram_typing(self, user: p.Puppet,
+ _: Union[UpdateUserTyping, UpdateChatUserTyping]) -> None:
+ await user.intent_for(self).set_typing(self.mxid, is_typing=True)
+
+ def _get_external_url(self, evt: Message) -> Optional[str]:
+ if self.peer_type == "channel" and self.username is not None:
+ return f"https://t.me/{self.username}/{evt.id}"
+ elif self.peer_type != "user":
+ return f"https://t.me/c/{self.tgid}/{evt.id}"
+ return None
+
+ 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)
+ file = await util.transfer_file_to_matrix(source.client, intent, loc)
+ if not file:
+ return None
+ if self.get_config("inline_images") and (evt.message
+ or evt.fwd_from or evt.reply_to_msg_id):
+ content = await formatter.telegram_to_matrix(
+ evt, source, self.main_intent,
+ prefix_html=f"
",
+ 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)
+ 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))
+ else largest_size.size))
+ name = f"image{sane_mimetypes.guess_extension(file.mime_type)}"
+ await intent.set_typing(self.mxid, is_typing=False)
+ 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)
+ 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)
+ return result
+
+ @staticmethod
+ def _parse_telegram_document_attributes(attributes: List[TypeDocumentAttribute]) -> DocAttrs:
+ name, mime_type, is_sticker, sticker_alt, width, height = None, None, False, None, 0, 0
+ for attr in attributes:
+ if isinstance(attr, DocumentAttributeFilename):
+ name = name or attr.file_name
+ mime_type, _ = mimetypes.guess_type(attr.file_name)
+ elif isinstance(attr, DocumentAttributeSticker):
+ is_sticker = True
+ sticker_alt = attr.alt
+ elif isinstance(attr, DocumentAttributeVideo):
+ width, height = attr.w, attr.h
+ return DocAttrs(name, mime_type, is_sticker, sticker_alt, width, height)
+
+ @staticmethod
+ def _parse_telegram_document_meta(evt: Message, file: DBTelegramFile, attrs: DocAttrs,
+ thumb_size: TypePhotoSize) -> Tuple[ImageInfo, str]:
+ document = evt.media.document
+ name = evt.message or attrs.name
+ if attrs.is_sticker:
+ alt = attrs.sticker_alt
+ if len(alt) > 0:
+ try:
+ name = f"{alt} ({unicodedata.name(alt[0]).lower()})"
+ except ValueError:
+ name = alt
+
+ generic_types = ("text/plain", "application/octet-stream")
+ if file.mime_type in generic_types and document.mime_type not in generic_types:
+ mime_type = document.mime_type or file.mime_type
+ else:
+ mime_type = file.mime_type or document.mime_type
+ info = ImageInfo(size=file.size, mimetype=mime_type)
+
+ if attrs.mime_type and not file.was_converted:
+ file.mime_type = attrs.mime_type or file.mime_type
+ if file.width and file.height:
+ info.width, info.height = file.width, file.height
+ elif attrs.width and attrs.height:
+ info.width, info.height = attrs.width, attrs.height
+
+ if file.thumbnail:
+ info.thumbnail_url = file.thumbnail.mxc
+ info.thumbnail_info = ThumbnailInfo(mimetype=file.thumbnail.mime_type,
+ height=file.thumbnail.height or thumb_size.h,
+ width=file.thumbnail.width or thumb_size.w,
+ size=file.thumbnail.size)
+
+ return info, name
+
+ async def handle_telegram_document(self, source: 'AbstractUser', intent: IntentAPI,
+ evt: Message, relates_to: RelatesTo = None
+ ) -> Optional[EventID]:
+ document = evt.media.document
+
+ attrs = self._parse_telegram_document_attributes(document.attributes)
+
+ if document.size > config["bridge.max_document_size"] * 1000 ** 2:
+ name = attrs.name or ""
+ caption = f"\n{evt.message}" if evt.message else ""
+ return await intent.send_notice(self.mxid, f"Too large file {name}{caption}")
+
+ thumb_loc, thumb_size = self._get_largest_photo_size(document)
+ if thumb_size and not isinstance(thumb_size, (PhotoSize, PhotoCachedSize)):
+ self.log.debug(f"Unsupported thumbnail type {type(thumb_size)}")
+ thumb_loc = None
+ thumb_size = None
+ file = await util.transfer_file_to_matrix(source.client, intent, document, thumb_loc,
+ is_sticker=attrs.is_sticker)
+ if not file:
+ return None
+
+ info, name = self._parse_telegram_document_meta(evt, file, attrs, thumb_size)
+
+ await intent.set_typing(self.mxid, is_typing=False)
+
+ event_type = EventType.STICKER if attrs.is_sticker else EventType.ROOM_MESSAGE
+ content = MediaMessageEventContent(
+ body=name or "unnamed file", info=info, url=file.mxc, relates_to=relates_to,
+ external_url=self._get_external_url(evt),
+ msgtype={
+ "video/": MessageType.VIDEO,
+ "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)
+
+ def handle_telegram_location(self, _: 'AbstractUser', intent: IntentAPI, evt: Message,
+ relates_to: dict = None) -> Awaitable[EventID]:
+ long = evt.media.geo.long
+ lat = evt.media.geo.lat
+ long_char = "E" if long > 0 else "W"
+ lat_char = "N" if lat > 0 else "S"
+
+ body = f"{round(lat, 5)}° {lat_char}, {round(long, 5)}° {long_char}"
+ url = f"https://maps.google.com/?q={lat},{long}"
+
+ content = LocationMessageEventContent(
+ msgtype=MessageType.LOCATION, geo_uri=f"geo:{lat},{long}",
+ body=f"Location: {body}\n{url}",
+ relates_to=relates_to, external_url=self._get_external_url(evt))
+ content["format"] = Format.HTML
+ content["formatted_body"] = f"Location: {body}"
+
+ return intent.send_message(self.mxid, content, timestamp=evt.date)
+
+ async def handle_telegram_text(self, source: 'AbstractUser', intent: IntentAPI, is_bot: bool,
+ evt: Message) -> EventID:
+ self.log.debug(f"Sending {evt.message} to {self.mxid} by {intent.mxid}")
+ content = await formatter.telegram_to_matrix(evt, source, self.main_intent)
+ content.external_url = self._get_external_url(evt)
+ 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)
+
+ async def handle_telegram_unsupported(self, source: 'AbstractUser', intent: IntentAPI,
+ evt: Message, relates_to: dict = None) -> EventID:
+ override_text = ("This message is not supported on your version of Mautrix-Telegram. "
+ "Please check https://github.com/tulir/mautrix-telegram or ask your "
+ "bridge administrator about possible updates.")
+ content = await formatter.telegram_to_matrix(
+ evt, source, self.main_intent, override_text=override_text)
+ content.msgtype = MessageType.NOTICE
+ 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)
+
+ async def handle_telegram_poll(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
+ relates_to: RelatesTo) -> EventID:
+ poll: Poll = evt.media.poll
+ poll_id = self._encode_msgid(source, evt)
+
+ _n = 0
+
+ def n() -> int:
+ nonlocal _n
+ _n += 1
+ return _n
+
+ text_answers = "\n".join(f"{n()}. {answer.text}" for answer in poll.answers)
+ html_answers = "\n".join(f"{answer.text} " for answer in poll.answers)
+ content = TextMessageEventContent(
+ msgtype=MessageType.TEXT, format=Format.HTML,
+ body=f"Poll: {poll.question}\n{text_answers}\n"
+ f"Vote with !tg vote {poll_id} ",
+ formatted_body=f"Poll: {poll.question}
\n"
+ f"{html_answers}
\n"
+ f"Vote with !tg vote {poll_id} <choice number>",
+ 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)
+
+ @staticmethod
+ def _int_to_bytes(i: int) -> bytes:
+ hex_value = "{0:010x}".format(i)
+ return codecs.decode(hex_value, "hex_codec")
+
+ def _encode_msgid(self, source: 'AbstractUser', evt: Message) -> str:
+ if self.peer_type == "channel":
+ play_id = (b"c"
+ + self._int_to_bytes(self.tgid)
+ + self._int_to_bytes(evt.id))
+ elif self.peer_type == "chat":
+ play_id = (b"g"
+ + self._int_to_bytes(self.tgid)
+ + self._int_to_bytes(evt.id)
+ + self._int_to_bytes(source.tgid))
+ elif self.peer_type == "user":
+ play_id = (b"u"
+ + self._int_to_bytes(self.tgid)
+ + self._int_to_bytes(evt.id))
+ else:
+ raise ValueError("Portal has invalid peer type")
+ return base64.b64encode(play_id).decode("utf-8").rstrip("=")
+
+ async def handle_telegram_game(self, source: 'AbstractUser', intent: IntentAPI,
+ evt: Message, relates_to: RelatesTo = None) -> EventID:
+ game = evt.media.game
+ play_id = self._encode_msgid(source, evt)
+ command = f"!tg play {play_id}"
+ override_text = f"Run {command} in your bridge management room to play {game.title}"
+ override_entities = [
+ MessageEntityPre(offset=len("Run "), length=len(command), language="")]
+
+ content = await formatter.telegram_to_matrix(
+ evt, source, self.main_intent,
+ override_text=override_text, override_entities=override_entities)
+ content.msgtype = MessageType.NOTICE
+ content.external_url = self._get_external_url(evt)
+ 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)
+
+ async def handle_telegram_edit(self, source: 'AbstractUser', sender: p.Puppet, evt: Message
+ ) -> None:
+ if not self.mxid:
+ return
+ elif hasattr(evt, "media") and isinstance(evt.media, MessageMediaGame):
+ self.log.debug("Ignoring game message edit event")
+ return
+
+ async with self.send_lock(sender.tgid if sender else None, required=False):
+ tg_space = self.tgid if self.peer_type == "channel" else source.tgid
+
+ temporary_identifier = EventID(
+ f"${random.randint(1000000000000, 9999999999999)}TGBRIDGEDITEMP")
+ duplicate_found = self.dedup.check(evt, (temporary_identifier, tg_space),
+ force_hash=True)
+ if duplicate_found:
+ mxid, other_tg_space = duplicate_found
+ if tg_space != other_tg_space:
+ prev_edit_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space, -1)
+ if not prev_edit_msg:
+ return
+ DBMessage(mxid=mxid, mx_room=self.mxid, tg_space=tg_space,
+ tgid=TelegramID(evt.id), edit_index=prev_edit_msg.edit_index + 1
+ ).insert()
+ return
+
+ content = await formatter.telegram_to_matrix(evt, source, self.main_intent,
+ no_reply_fallback=True)
+ editing_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space)
+ if not editing_msg:
+ self.log.info(f"Didn't find edited message {evt.id}@{tg_space} (src {source.tgid}) "
+ "in database.")
+ return
+
+ content.msgtype = (MessageType.NOTICE if (sender and sender.is_bot
+ and self.get_config("bot_messages_as_notices"))
+ else MessageType.TEXT)
+ content.external_url = self._get_external_url(evt)
+ content.set_edit(editing_msg.mxid)
+
+ # TODO remove this stuff once mautrix-python generates m.new_content
+ new_content = content.serialize()
+ del new_content["m.relates_to"]
+ content["m.new_content"] = new_content
+ content.body = f"Edit: {content.body}"
+ content.format = Format.HTML
+ content.formatted_body = (f"Edit: "
+ f"{content.formatted_body or escape_html(content.body)}")
+
+ 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)
+
+ 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),
+ edit_index=prev_edit_msg.edit_index + 1).insert()
+ DBMessage.update_by_mxid(temporary_identifier, self.mxid, mxid=event_id)
+
+ async def handle_telegram_message(self, source: 'AbstractUser', sender: p.Puppet,
+ evt: Message) -> None:
+ if not self.mxid:
+ await self.create_matrix_room(source, invites=[source.mxid], update_if_exists=False)
+
+ if (self.peer_type == "user" and sender.tgid == self.tg_receiver
+ and not sender.is_real_user and not self.az.state_store.is_joined(self.mxid,
+ sender.mxid)):
+ self.log.debug(f"Ignoring private chat message {evt.id}@{source.tgid} as receiver does"
+ " not have matrix puppeting and their default puppet isn't in the room")
+
+ async with self.send_lock(sender.tgid if sender else None, required=False):
+ tg_space = self.tgid if self.peer_type == "channel" else source.tgid
+
+ temporary_identifier = EventID(
+ f"${random.randint(1000000000000, 9999999999999)}TGBRIDGETEMP")
+ duplicate_found = self.dedup.check(evt, (temporary_identifier, tg_space))
+ if duplicate_found:
+ mxid, other_tg_space = duplicate_found
+ self.log.debug(f"Ignoring message {evt.id}@{tg_space} (src {source.tgid}) "
+ f"as it was already handled (in space {other_tg_space})")
+ if tg_space != other_tg_space:
+ DBMessage(tgid=TelegramID(evt.id), mx_room=self.mxid, mxid=mxid,
+ tg_space=tg_space, edit_index=0).insert()
+ return
+
+ if self.dedup.pre_db_check and self.peer_type == "channel":
+ msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space)
+ if msg:
+ self.log.debug(f"Ignoring message {evt.id} (src {source.tgid}) as it was already"
+ f"handled into {msg.mxid}. This duplicate was catched in the db "
+ "check. If you get this message often, consider increasing"
+ "bridge.deduplication.cache_queue_length in the config.")
+ return
+
+ if sender and not sender.displayname:
+ self.log.debug(f"Telegram user {sender.tgid} sent a message, but doesn't have a "
+ "displayname, updating info...")
+ entity = await source.client.get_entity(PeerUser(sender.tgid))
+ await sender.update_info(source, entity)
+
+ allowed_media = (MessageMediaPhoto, MessageMediaDocument, MessageMediaGeo,
+ MessageMediaGame, MessageMediaPoll, MessageMediaUnsupported)
+ media = evt.media if hasattr(evt, "media") and isinstance(evt.media,
+ allowed_media) else None
+ intent = sender.intent_for(self) if sender else self.main_intent
+ if not media and evt.message:
+ is_bot = sender.is_bot if sender else False
+ event_id = await self.handle_telegram_text(source, intent, is_bot, evt)
+ elif media:
+ event_id = await {
+ MessageMediaPhoto: self.handle_telegram_photo,
+ MessageMediaDocument: self.handle_telegram_document,
+ MessageMediaGeo: self.handle_telegram_location,
+ MessageMediaPoll: self.handle_telegram_poll,
+ MessageMediaUnsupported: self.handle_telegram_unsupported,
+ MessageMediaGame: self.handle_telegram_game,
+ }[type(media)](source, intent, evt,
+ relates_to=formatter.telegram_reply_to_matrix(evt, source))
+ else:
+ self.log.debug("Unhandled Telegram message: %s", evt)
+ return
+
+ if not event_id:
+ return
+
+ prev_id = self.dedup.update(evt, (event_id, tg_space), (temporary_identifier, tg_space))
+ if prev_id:
+ self.log.debug(f"Sent message {evt.id}@{tg_space} to Matrix as {event_id}. "
+ f"Temporary dedup identifier was {temporary_identifier}, "
+ f"but dedup map contained {prev_id[1]} instead! -- "
+ "This was probably a race condition caused by Telegram sending updates"
+ "to other clients before responding to the sender. I'll just redact "
+ "the likely duplicate message now.")
+ await intent.redact(self.mxid, event_id)
+ return
+
+ self.log.debug("Handled Telegram message: %s", evt)
+ try:
+ DBMessage(tgid=TelegramID(evt.id), mx_room=self.mxid, mxid=event_id,
+ tg_space=tg_space, edit_index=0).insert()
+ DBMessage.update_by_mxid(temporary_identifier, self.mxid, mxid=event_id)
+ except IntegrityError as e:
+ self.log.exception(f"{e.__class__.__name__} while saving message mapping. "
+ "This might mean that an update was handled after it left the "
+ "dedup cache queue. You can try enabling bridge.deduplication."
+ "pre_db_check in the config.")
+ await intent.redact(self.mxid, event_id)
+
+ async def _create_room_on_action(self, source: 'AbstractUser',
+ action: TypeMessageAction) -> bool:
+ if source.is_relaybot:
+ return False
+ create_and_exit = (MessageActionChatCreate, MessageActionChannelCreate)
+ create_and_continue = (MessageActionChatAddUser, MessageActionChatJoinedByLink)
+ if isinstance(action, create_and_exit) or isinstance(action, create_and_continue):
+ await self.create_matrix_room(source, invites=[source.mxid],
+ update_if_exists=isinstance(action, create_and_exit))
+ if not isinstance(action, create_and_continue):
+ return False
+ return True
+
+ async def handle_telegram_action(self, source: 'AbstractUser', sender: p.Puppet,
+ update: MessageService) -> None:
+ action = update.action
+ should_ignore = ((not self.mxid and not await self._create_room_on_action(source, action))
+ or self.dedup.check_action(update))
+ if should_ignore or not self.mxid:
+ return
+ if isinstance(action, MessageActionChatEditTitle):
+ await self._update_title(action.title, save=True)
+ elif isinstance(action, MessageActionChatEditPhoto):
+ await self._update_avatar(source, action.photo, save=True)
+ elif isinstance(action, MessageActionChatDeletePhoto):
+ await self._update_avatar(source, ChatPhotoEmpty(), save=True)
+ elif isinstance(action, MessageActionChatAddUser):
+ for user_id in action.users:
+ await self._add_telegram_user(TelegramID(user_id), source)
+ elif isinstance(action, MessageActionChatJoinedByLink):
+ await self._add_telegram_user(sender.id, source)
+ elif isinstance(action, MessageActionChatDeleteUser):
+ await self._delete_telegram_user(TelegramID(action.user_id), sender)
+ elif isinstance(action, MessageActionChatMigrateTo):
+ self.peer_type = "channel"
+ self._migrate_and_save_telegram(TelegramID(action.channel_id))
+ await sender.intent_for(self).send_emote(self.mxid,
+ "upgraded this group to a supergroup.")
+ elif isinstance(action, MessageActionPinMessage):
+ await self.receive_telegram_pin_sender(sender)
+ elif isinstance(action, MessageActionGameScore):
+ # TODO handle game score
+ pass
+ else:
+ self.log.debug("Unhandled Telegram action in %s: %s", self.title, action)
+
+ async def set_telegram_admin(self, user_id: TelegramID) -> None:
+ puppet = p.Puppet.get(user_id)
+ user = u.User.get_by_tgid(user_id)
+
+ levels = await self.main_intent.get_power_levels(self.mxid)
+ if user:
+ levels.users[user.mxid] = 50
+ if puppet:
+ levels.users[puppet.mxid] = 50
+ await self.main_intent.set_power_levels(self.mxid, levels)
+
+ async def receive_telegram_pin_sender(self, sender: p.Puppet) -> None:
+ self._temp_pinned_message_sender = sender
+ if self._temp_pinned_message_id:
+ await self.update_telegram_pin()
+
+ async def update_telegram_pin(self) -> None:
+ intent = (self._temp_pinned_message_sender.intent_for(self)
+ if self._temp_pinned_message_sender else self.main_intent)
+ msg_id = self._temp_pinned_message_id
+ self._temp_pinned_message_id = None
+ self._temp_pinned_message_sender = None
+
+ message = DBMessage.get_one_by_tgid(msg_id, self._temp_pinned_message_id_space)
+ if message:
+ await intent.set_pinned_messages(self.mxid, [message.mxid])
+ else:
+ await intent.set_pinned_messages(self.mxid, [])
+
+ async def receive_telegram_pin_id(self, msg_id: TelegramID, receiver: TelegramID) -> None:
+ if msg_id == 0:
+ return await self.update_telegram_pin()
+ self._temp_pinned_message_id = msg_id
+ self._temp_pinned_message_id_space = receiver if self.peer_type != "channel" else self.tgid
+ if self._temp_pinned_message_sender:
+ await self.update_telegram_pin()
+
+ async def set_telegram_admins_enabled(self, enabled: bool) -> None:
+ level = 50 if enabled else 10
+ levels = await self.main_intent.get_power_levels(self.mxid)
+ levels.invite = level
+ levels.events[EventType.ROOM_NAME] = level
+ levels.events[EventType.ROOM_AVATAR] = level
+ await self.main_intent.set_power_levels(self.mxid, levels)
+
+
+def init(context: Context) -> None:
+ global config
+ config = context.config
diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py
index ede800b0..cb3c9d9f 100644
--- a/mautrix_telegram/puppet.py
+++ b/mautrix_telegram/puppet.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,21 +13,24 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Awaitable, Any, Dict, List, Iterable, Optional, Pattern, Union, TYPE_CHECKING
+from typing import Awaitable, Any, Dict, Iterable, Optional, Union, TYPE_CHECKING
from difflib import SequenceMatcher
-from enum import Enum
-from aiohttp import ServerDisconnectedError
+import unicodedata
import asyncio
import logging
-import re
from telethon.tl.types import (UserProfilePhoto, User, UpdateUserName, PeerUser, TypeInputPeer,
- InputPeerPhotoFileLocation, UserProfilePhotoEmpty)
-from mautrix_appservice import AppService, IntentAPI, IntentError, MatrixRequestError
+ InputPeerPhotoFileLocation, UserProfilePhotoEmpty, TypeInputUser)
-from .types import MatrixUserID, TelegramID
+from mautrix.appservice import AppService, IntentAPI
+from mautrix.errors import MatrixRequestError
+from mautrix.bridge import CustomPuppetMixin
+from mautrix.types import UserID, SyncToken
+from mautrix.util.simple_template import SimpleTemplate
+
+from .types import TelegramID
from .db import Puppet as DBPuppet
-from . import util
+from . import util, portal as p
if TYPE_CHECKING:
from .matrix import MatrixHandler
@@ -36,26 +38,47 @@ if TYPE_CHECKING:
from .context import Context
from .abstract_user import AbstractUser
-PuppetError = Enum('PuppetError', 'Success OnlyLoginSelf InvalidAccessToken')
-
-config = None # type: Config
+config: Optional['Config'] = None
-class Puppet:
- log = logging.getLogger("mau.puppet") # type: logging.Logger
- az = None # type: AppService
- mx = None # type: MatrixHandler
- loop = None # type: asyncio.AbstractEventLoop
- mxid_regex = None # type: Pattern
- username_template = None # type: str
- hs_domain = None # type: str
- cache = {} # type: Dict[TelegramID, Puppet]
- by_custom_mxid = {} # type: Dict[str, Puppet]
+class Puppet(CustomPuppetMixin):
+ log: logging.Logger = logging.getLogger("mau.puppet")
+ az: AppService
+ mx: 'MatrixHandler'
+ loop: asyncio.AbstractEventLoop
+ hs_domain: str
+ mxid_template: SimpleTemplate[TelegramID]
+ displayname_template: SimpleTemplate[str]
+
+ cache: Dict[TelegramID, 'Puppet'] = {}
+ by_custom_mxid: Dict[UserID, 'Puppet'] = {}
+
+ id: TelegramID
+ access_token: Optional[str]
+ custom_mxid: Optional[UserID]
+ _next_batch: Optional[SyncToken]
+ default_mxid: UserID
+
+ username: Optional[str]
+ displayname: Optional[str]
+ displayname_source: Optional[TelegramID]
+ photo_id: Optional[str]
+ is_bot: bool
+ is_registered: bool
+ disable_updates: bool
+
+ default_mxid_intent: IntentAPI
+ intent: IntentAPI
+
+ sync_task: Optional[asyncio.Future]
+
+ _db_instance: Optional[DBPuppet]
def __init__(self,
id: TelegramID,
access_token: Optional[str] = None,
- custom_mxid: Optional[MatrixUserID] = None,
+ custom_mxid: Optional[UserID] = None,
+ next_batch: Optional[SyncToken] = None,
username: Optional[str] = None,
displayname: Optional[str] = None,
displayname_source: Optional[TelegramID] = None,
@@ -64,40 +87,47 @@ class Puppet:
is_registered: bool = False,
disable_updates: bool = False,
db_instance: Optional[DBPuppet] = None) -> None:
- self.id = id # type: TelegramID
- self.access_token = access_token # type: Optional[str]
- self.custom_mxid = custom_mxid # type: Optional[MatrixUserID]
- self.default_mxid = self.get_mxid_from_id(self.id) # type: MatrixUserID
+ self.id = id
+ self.access_token = access_token
+ self.custom_mxid = custom_mxid
+ self._next_batch = next_batch
+ self.default_mxid = self.get_mxid_from_id(self.id)
- self.username = username # type: Optional[str]
- self.displayname = displayname # type: Optional[str]
- self.displayname_source = displayname_source # type: Optional[TelegramID]
- self.photo_id = photo_id # type: Optional[str]
- self.is_bot = is_bot # type: bool
- self.is_registered = is_registered # type: bool
- self.disable_updates = disable_updates # type: bool
- self._db_instance = db_instance # type: Optional[DBPuppet]
+ self.username = username
+ self.displayname = displayname
+ self.displayname_source = displayname_source
+ self.photo_id = photo_id
+ self.is_bot = is_bot
+ self.is_registered = is_registered
+ self.disable_updates = disable_updates
+ self._db_instance = db_instance
self.default_mxid_intent = self.az.intent.user(self.default_mxid)
- self.intent = self._fresh_intent() # type: IntentAPI
- self.sync_task = None # type: Optional[asyncio.Future]
+ self.intent = self._fresh_intent()
+ self.sync_task = None
self.cache[id] = self
if self.custom_mxid:
self.by_custom_mxid[self.custom_mxid] = self
- @property
- def mxid(self) -> MatrixUserID:
- return self.custom_mxid or self.default_mxid
+ self.log = self.log.getChild(str(self.id))
@property
def tgid(self) -> TelegramID:
return self.id
@property
- def is_real_user(self) -> bool:
- """ Is True when the puppet is a real Matrix user. """
- return bool(self.custom_mxid and self.access_token)
+ def peer(self) -> PeerUser:
+ return PeerUser(user_id=self.tgid)
+
+ @property
+ def next_batch(self) -> SyncToken:
+ return self._next_batch
+
+ @next_batch.setter
+ def next_batch(self, value: SyncToken) -> None:
+ self._next_batch = value
+ self.db_instance.edit(next_batch=self._next_batch)
@staticmethod
async def is_logged_in() -> bool:
@@ -106,175 +136,17 @@ class Puppet:
@property
def plain_displayname(self) -> str:
- tpl = config["bridge.displayname_template"]
- if tpl == "{displayname}":
- # Template has no extra stuff, no need to parse.
- return self.displayname
- regex = re.compile("^" + re.escape(tpl).replace(re.escape("{displayname}"), "(.+?)") + "$")
- match = regex.match(self.displayname)
- return match.group(1) or self.displayname
+ return self.displayname_template.parse(self.displayname) or self.displayname
- def get_input_entity(self, user: 'AbstractUser') -> Awaitable[TypeInputPeer]:
- return user.client.get_input_entity(PeerUser(user_id=self.tgid))
+ def get_input_entity(self, user: 'AbstractUser'
+ ) -> Awaitable[Union[TypeInputPeer, TypeInputUser]]:
+ return user.client.get_input_entity(self.peer)
- # region Custom puppet management
- def _fresh_intent(self) -> IntentAPI:
- return (self.az.intent.user(self.custom_mxid, self.access_token)
- if self.is_real_user else self.default_mxid_intent)
+ def intent_for(self, portal: 'p.Portal') -> IntentAPI:
+ if portal.tgid == self.tgid:
+ return self.default_mxid_intent
+ return self.intent
- async def switch_mxid(self, access_token: Optional[str],
- mxid: Optional[MatrixUserID]) -> PuppetError:
- prev_mxid = self.custom_mxid
- self.custom_mxid = mxid
- self.access_token = access_token
- self.intent = self._fresh_intent()
-
- err = await self.init_custom_mxid()
- if err != PuppetError.Success:
- return err
-
- try:
- del self.by_custom_mxid[prev_mxid] # type: ignore
- except KeyError:
- pass
- if self.mxid != self.default_mxid:
- self.by_custom_mxid[self.mxid] = self
- await self.leave_rooms_with_default_user()
- self.save()
- return PuppetError.Success
-
- async def init_custom_mxid(self) -> PuppetError:
- if not self.is_real_user:
- return PuppetError.Success
-
- mxid = await self.intent.whoami()
- if not mxid or mxid != self.custom_mxid:
- self.custom_mxid = None
- self.access_token = None
- self.intent = self._fresh_intent()
- if mxid != self.custom_mxid:
- return PuppetError.OnlyLoginSelf
- return PuppetError.InvalidAccessToken
- if config["bridge.sync_with_custom_puppets"]:
- self.sync_task = asyncio.ensure_future(self.sync(), loop=self.loop)
- return PuppetError.Success
-
- async def leave_rooms_with_default_user(self) -> None:
- for room_id in await self.default_mxid_intent.get_joined_rooms():
- try:
- await self.default_mxid_intent.leave_room(room_id)
- await self.intent.ensure_joined(room_id)
- except (IntentError, MatrixRequestError):
- pass
-
- def create_sync_filter(self) -> Awaitable[str]:
- return self.intent.client.create_filter(self.custom_mxid, {
- "room": {
- "include_leave": False,
- "state": {
- "types": []
- },
- "timeline": {
- "types": [],
- },
- "ephemeral": {
- "types": ["m.typing", "m.receipt"],
- },
- "account_data": {
- "types": []
- }
- },
- "account_data": {
- "types": [],
- },
- "presence": {
- "types": ["m.presence"],
- "senders": [self.custom_mxid],
- },
- })
-
- def filter_events(self, events: List[Dict]) -> List:
- new_events = []
- for event in events:
- evt_type = event.get("type", None)
- event.setdefault("content", {})
- if evt_type == "m.typing":
- is_typing = self.custom_mxid in event["content"].get("user_ids", [])
- event["content"]["user_ids"] = [self.custom_mxid] if is_typing else []
- elif evt_type == "m.receipt":
- val = None
- evt = None
- for event_id in event["content"]:
- try:
- val = event["content"][event_id]["m.read"][self.custom_mxid]
- evt = event_id
- break
- except KeyError:
- pass
- if val and evt:
- event["content"] = {evt: {"m.read": {
- self.custom_mxid: val
- }}}
- else:
- continue
- new_events.append(event)
- return new_events
-
- def handle_sync(self, presence: List, ephemeral: Dict) -> None:
- presence_events = [self.mx.try_handle_ephemeral_event(event) for event in presence]
-
- for room_id, events in ephemeral.items():
- for event in events:
- event["room_id"] = room_id
-
- ephemeral_events = [self.mx.try_handle_ephemeral_event(event)
- for events in ephemeral.values()
- for event in self.filter_events(events)]
-
- events = ephemeral_events + presence_events # List[Callable[[int], Awaitable[None]]]
- coro = asyncio.gather(*events, loop=self.loop)
- asyncio.ensure_future(coro, loop=self.loop)
-
- async def sync(self) -> None:
- try:
- await self._sync()
- except asyncio.CancelledError:
- self.log.info("Syncing cancelled")
- except Exception:
- self.log.exception("Fatal error syncing")
-
- async def _sync(self) -> None:
- if not self.is_real_user:
- self.log.warning("Called sync() for non-custom puppet.")
- return
- custom_mxid = self.custom_mxid
- access_token_at_start = self.access_token
- errors = 0
- next_batch = None
- filter_id = await self.create_sync_filter()
- self.log.debug(f"Starting syncer for {custom_mxid} with sync filter {filter_id}.")
- while access_token_at_start == self.access_token:
- try:
- sync_resp = await self.intent.client.sync(filter=filter_id, since=next_batch,
- set_presence="offline") # type: Dict
- errors = 0
- if next_batch is not None:
- presence = sync_resp.get("presence", {}).get("events", []) # type: List
- ephemeral = {room: data.get("ephemeral", {}).get("events", [])
- for room, data
- in sync_resp.get("rooms", {}).get("join", {}).items()
- } # type: Dict
- self.handle_sync(presence, ephemeral)
- next_batch = sync_resp.get("next_batch", None)
- except (MatrixRequestError, ServerDisconnectedError) as e:
- wait = min(errors, 11) ** 2
- self.log.warning(f"Syncer for {custom_mxid} errored: {e}. "
- f"Waiting for {wait} seconds...")
- errors += 1
- await asyncio.sleep(wait)
- self.log.debug(f"Syncer for custom puppet {custom_mxid} stopped.")
-
- # endregion
# region DB conversion
@property
@@ -283,26 +155,27 @@ class Puppet:
self._db_instance = self.new_db_instance()
return self._db_instance
+ @property
+ def _fields(self) -> Dict[str, Any]:
+ return dict(access_token=self.access_token, next_batch=self._next_batch,
+ custom_mxid=self.custom_mxid, username=self.username, is_bot=self.is_bot,
+ displayname=self.displayname, displayname_source=self.displayname_source,
+ photo_id=self.photo_id, matrix_registered=self.is_registered,
+ disable_updates=self.disable_updates)
+
def new_db_instance(self) -> DBPuppet:
- return DBPuppet(id=self.id, access_token=self.access_token, custom_mxid=self.custom_mxid,
- username=self.username, displayname=self.displayname,
- displayname_source=self.displayname_source, photo_id=self.photo_id,
- is_bot=self.is_bot, matrix_registered=self.is_registered,
- disable_updates=self.disable_updates)
+ return DBPuppet(id=self.id, **self._fields)
+
+ def save(self) -> None:
+ self.db_instance.edit(**self._fields)
@classmethod
def from_db(cls, db_puppet: DBPuppet) -> 'Puppet':
return Puppet(db_puppet.id, db_puppet.access_token, db_puppet.custom_mxid,
- db_puppet.username, db_puppet.displayname, db_puppet.displayname_source,
- db_puppet.photo_id, db_puppet.is_bot, db_puppet.matrix_registered,
- db_puppet.disable_updates, db_instance=db_puppet)
-
- def save(self) -> None:
- self.db_instance.update(access_token=self.access_token, custom_mxid=self.custom_mxid,
- username=self.username, displayname=self.displayname,
- displayname_source=self.displayname_source, photo_id=self.photo_id,
- is_bot=self.is_bot, matrix_registered=self.is_registered,
- disable_updates=self.disable_updates)
+ db_puppet.next_batch, db_puppet.username, db_puppet.displayname,
+ db_puppet.displayname_source, db_puppet.photo_id, db_puppet.is_bot,
+ db_puppet.matrix_registered, db_puppet.disable_updates,
+ db_instance=db_puppet)
# endregion
# region Info updating
@@ -319,10 +192,10 @@ class Puppet:
def _filter_name(name: str) -> str:
if not name:
return ""
- whitespace = ("\ufeff", "\u3164", "\u2063", "\u200b", "\u180e", "\u034f", "\u2800",
- "\u180e", "\u200b", "\u202f", "\u205f", "\u3000")
- name = "".join(char for char in name if char not in whitespace)
- name = name.strip()
+ whitespace = ("\t\n\r\v\f \u00a0\u034f\u180e\u2063\u202f\u205f\u2800\u3000\u3164\ufeff"
+ "\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u200b"
+ "\u200c\u200d\u200e\u200f")
+ name = "".join(c for c in name.strip(whitespace) if unicodedata.category(c) != 'Cf')
return name
@classmethod
@@ -351,8 +224,7 @@ class Puppet:
if not enable_format:
return name
- return config["bridge.displayname_template"].format(
- displayname=name)
+ return cls.displayname_template.format_full(name)
async def update_info(self, source: 'AbstractUser', info: User) -> None:
if self.disable_updates:
@@ -391,7 +263,8 @@ class Puppet:
self.displayname = displayname
self.displayname_source = source.tgid
try:
- await self.default_mxid_intent.set_display_name(displayname[:100])
+ await self.default_mxid_intent.set_displayname(
+ displayname[:config["bridge.displayname_max_length"]])
except MatrixRequestError:
self.log.exception("Failed to set displayname")
self.displayname = ""
@@ -415,7 +288,7 @@ class Puppet:
if not photo_id:
self.photo_id = ""
try:
- await self.default_mxid_intent.set_avatar("")
+ await self.default_mxid_intent.set_avatar_url("")
except MatrixRequestError:
self.log.exception("Failed to set avatar")
self.photo_id = ""
@@ -431,7 +304,7 @@ class Puppet:
if file:
self.photo_id = photo_id
try:
- await self.default_mxid_intent.set_avatar(file.mxc)
+ await self.default_mxid_intent.set_avatar_url(file.mxc)
except MatrixRequestError:
self.log.exception("Failed to set avatar")
self.photo_id = ""
@@ -460,7 +333,7 @@ class Puppet:
return None
@classmethod
- def get_by_mxid(cls, mxid: MatrixUserID, create: bool = True) -> Optional['Puppet']:
+ def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['Puppet']:
tgid = cls.get_id_from_mxid(mxid)
if tgid:
return cls.get(tgid, create)
@@ -468,7 +341,7 @@ class Puppet:
return None
@classmethod
- def get_by_custom_mxid(cls, mxid: MatrixUserID) -> Optional['Puppet']:
+ def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
if not mxid:
raise ValueError("Matrix ID can't be empty")
@@ -492,15 +365,12 @@ class Puppet:
for puppet in DBPuppet.all_with_custom_mxid())
@classmethod
- def get_id_from_mxid(cls, mxid: MatrixUserID) -> Optional[TelegramID]:
- match = cls.mxid_regex.match(mxid)
- if match:
- return TelegramID(int(match.group(1)))
- return None
+ def get_id_from_mxid(cls, mxid: UserID) -> Optional[TelegramID]:
+ return cls.mxid_template.parse(mxid)
@classmethod
- def get_mxid_from_id(cls, tgid: TelegramID) -> MatrixUserID:
- return MatrixUserID(f"@{cls.username_template.format(userid=tgid)}:{cls.hs_domain}")
+ def get_mxid_from_id(cls, tgid: TelegramID) -> UserID:
+ return UserID(cls.mxid_template.format_full(tgid))
@classmethod
def find_by_username(cls, username: str) -> Optional['Puppet']:
@@ -534,12 +404,15 @@ class Puppet:
# endregion
-def init(context: 'Context') -> List[Awaitable[Any]]: # [None, None, PuppetError]
+def init(context: 'Context') -> Iterable[Awaitable[Any]]:
global config
Puppet.az, config, Puppet.loop, _ = context.core
Puppet.mx = context.mx
- Puppet.username_template = config.get("bridge.username_template", "telegram_{userid}")
Puppet.hs_domain = config["homeserver"]["domain"]
- Puppet.mxid_regex = re.compile(
- f"@{Puppet.username_template.format(userid='([0-9]+)')}:{Puppet.hs_domain}")
- return [puppet.init_custom_mxid() for puppet in Puppet.all_with_custom_mxid()]
+
+ Puppet.mxid_template = SimpleTemplate(config["bridge.username_template"], "userid",
+ prefix="@", suffix=f":{Puppet.hs_domain}", type=int)
+ Puppet.displayname_template = SimpleTemplate(config["bridge.displayname_template"],
+ "displayname")
+
+ return (puppet.start() for puppet in Puppet.all_with_custom_mxid())
diff --git a/mautrix_telegram/scripts/dbms_migrate/__main__.py b/mautrix_telegram/scripts/dbms_migrate/__main__.py
index e9edfffd..09d88eab 100644
--- a/mautrix_telegram/scripts/dbms_migrate/__main__.py
+++ b/mautrix_telegram/scripts/dbms_migrate/__main__.py
@@ -1,7 +1,9 @@
+from typing import Union
import argparse
-import sqlalchemy as sql
+
from sqlalchemy import orm
from sqlalchemy.ext.declarative import declarative_base
+import sqlalchemy as sql
from alchemysession import AlchemySessionContainer
@@ -22,16 +24,19 @@ def log(message, end="\n"):
def connect(to):
- import mautrix_telegram.db.base as base
- base.Base = declarative_base(cls=base.BaseBase)
- from mautrix_telegram.db import (Portal, Message, UserPortal, User, RoomState, UserProfile,
- Contact, Puppet, BotChat, TelegramFile)
+ from mautrix.bridge.db import Base, RoomState, UserProfile
+ from mautrix_telegram.db import (Portal, Message, UserPortal, User, Contact, Puppet, BotChat,
+ TelegramFile)
+
db_engine = sql.create_engine(to)
db_factory = orm.sessionmaker(bind=db_engine)
- db_session = orm.scoped_session(db_factory) # type: orm.Session
- base.Base.metadata.bind = db_engine
+ db_session: Union[orm.Session, orm.scoped_session] = orm.scoped_session(db_factory)
+ Base.metadata.bind = db_engine
+
+ new_base = declarative_base()
+ new_base.metadata.bind = db_engine
session_container = AlchemySessionContainer(engine=db_engine, session=db_session,
- table_base=base.Base, table_prefix="telethon_",
+ table_base=new_base, table_prefix="telethon_",
manage_tables=False)
return db_session, {
@@ -52,6 +57,7 @@ def connect(to):
"TelegramFile": TelegramFile,
}
+
log("Connecting to old database")
session, tables = connect(args.from_url)
diff --git a/mautrix_telegram/scripts/telematrix_import/__main__.py b/mautrix_telegram/scripts/telematrix_import/__main__.py
index 53a3e204..5324cc8b 100644
--- a/mautrix_telegram/scripts/telematrix_import/__main__.py
+++ b/mautrix_telegram/scripts/telematrix_import/__main__.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -15,11 +14,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
-from sqlalchemy import orm
-import sqlalchemy as sql
import argparse
-from mautrix_telegram.db import Base, Portal, Message, Puppet, BotChat
+from sqlalchemy import orm
+import sqlalchemy as sql
+
+from mautrix.bridge.db import Base
+
+from mautrix_telegram.db import Portal, Message, Puppet, BotChat
from mautrix_telegram.config import Config
from .models import ChatLink, TgUser, MatrixUser, Message as TMMessage, Base as TelematrixBase
@@ -38,8 +40,7 @@ args = parser.parse_args()
config = Config(args.config, None, None)
config.load()
-mxtg_db_engine = sql.create_engine(
- config.get("appservice.database", "sqlite:///mautrix-telegram.db"))
+mxtg_db_engine = sql.create_engine(config["appservice.database"])
mxtg = orm.sessionmaker(bind=mxtg_db_engine)()
Base.metadata.bind = mxtg_db_engine
@@ -55,18 +56,18 @@ tm_messages = telematrix.query(TMMessage).all()
telematrix.close()
telematrix_db_engine.dispose()
-portals_by_tgid = {} # type: Dict[int, Portal]
-portals_by_mxid = {} # type: Dict[str, Portal]
-chats = {} # type: Dict[int, BotChat]
-messages = {} # type: Dict[str, Message]
-puppets = {} # type: Dict[int, Puppet]
+portals_by_tgid: Dict[int, Portal] = {}
+portals_by_mxid: Dict[str, Portal] = {}
+chats: Dict[int, BotChat] = {}
+messages: Dict[str, Message] = {}
+puppets: Dict[int, Puppet] = {}
for chat_link in chat_links:
if type(chat_link.tg_room) is str:
- print("Expected tg_room to be a number, got a string. Ignoring %s" % chat_link.tg_room)
+ print(f"Expected tg_room to be a number, got a string. Ignoring {chat_link.tg_room}")
continue
if chat_link.tg_room >= 0:
- print("Unexpected unprefixed telegram chat ID: %s, ignoring..." % chat_link.tg_room)
+ print(f"Unexpected unprefixed telegram chat ID: {chat_link.tg_room}, ignoring...")
continue
tgid = str(chat_link.tg_room)
if tgid.startswith("-100"):
diff --git a/mautrix_telegram/sqlstatestore.py b/mautrix_telegram/sqlstatestore.py
index 82a305ee..3b8c91ad 100644
--- a/mautrix_telegram/sqlstatestore.py
+++ b/mautrix_telegram/sqlstatestore.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,106 +13,26 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Dict, Tuple
+from mautrix.types import UserID
+from mautrix.bridge.db import SQLStateStore as BaseSQLStateStore
-from mautrix_appservice import StateStore
-
-from .types import MatrixUserID, MatrixRoomID
from . import puppet as pu
-from .db import RoomState, UserProfile
-class SQLStateStore(StateStore):
- def __init__(self) -> None:
- super().__init__()
- self.profile_cache = {} # type: Dict[Tuple[str, str], UserProfile]
- self.room_state_cache = {} # type: Dict[str, RoomState]
+class SQLStateStore(BaseSQLStateStore):
+ def is_registered(self, user_id: UserID) -> bool:
+ puppet = pu.Puppet.get_by_mxid(user_id, create=False)
+ if puppet:
+ return puppet.is_registered
+ custom_puppet = pu.Puppet.get_by_custom_mxid(user_id)
+ if custom_puppet:
+ return True
+ return super().is_registered(user_id)
- @staticmethod
- def is_registered(user: MatrixUserID) -> bool:
- puppet = pu.Puppet.get_by_mxid(user)
- return puppet.is_registered if puppet else False
-
- @staticmethod
- def registered(user: MatrixUserID) -> None:
- puppet = pu.Puppet.get_by_mxid(user)
+ def registered(self, user_id: UserID) -> None:
+ puppet = pu.Puppet.get_by_mxid(user_id, create=True)
if puppet:
puppet.is_registered = True
puppet.save()
-
- def update_state(self, event: Dict) -> None:
- event_type = event["type"]
- if event_type == "m.room.power_levels":
- self.set_power_levels(event["room_id"], event["content"])
- elif event_type == "m.room.member":
- self.set_member(event["room_id"], event["state_key"], event["content"])
-
- def _get_user_profile(self, room_id: MatrixRoomID, user_id: MatrixUserID, create: bool = True
- ) -> UserProfile:
- key = (room_id, user_id)
- try:
- return self.profile_cache[key]
- except KeyError:
- pass
-
- profile = UserProfile.get(*key)
- if profile:
- self.profile_cache[key] = profile
- elif create:
- profile = UserProfile(room_id=room_id, user_id=user_id, membership="leave")
- profile.insert()
- self.profile_cache[key] = profile
- return profile
-
- def get_member(self, room: MatrixRoomID, user: MatrixUserID) -> Dict:
- return self._get_user_profile(room, user).dict()
-
- def set_member(self, room: MatrixRoomID, user: MatrixUserID, member: Dict) -> None:
- profile = self._get_user_profile(room, user)
- profile.membership = member.get("membership", profile.membership or "leave")
- profile.displayname = member.get("displayname", profile.displayname)
- profile.avatar_url = member.get("avatar_url", profile.avatar_url)
- profile.update()
-
- def set_membership(self, room: MatrixRoomID, user: MatrixUserID, membership: str) -> None:
- self.set_member(room, user, {
- "membership": membership,
- })
-
- def _get_room_state(self, room_id: MatrixRoomID, create: bool = True) -> RoomState:
- try:
- return self.room_state_cache[room_id]
- except KeyError:
- pass
-
- room = RoomState.get(room_id)
- if room:
- self.room_state_cache[room_id] = room
- elif create:
- room = RoomState(room_id=room_id)
- room.insert()
- self.room_state_cache[room_id] = room
- return room
-
- def has_power_levels(self, room: MatrixRoomID) -> bool:
- return bool(self._get_room_state(room).power_levels)
-
- def get_power_levels(self, room: MatrixRoomID) -> Dict:
- return self._get_room_state(room).power_levels
-
- def set_power_level(self, room: MatrixRoomID, user: MatrixUserID, level: int) -> None:
- room_state = self._get_room_state(room)
- power_levels = room_state.power_levels
- if not power_levels:
- power_levels = {
- "users": {},
- "events": {},
- }
- power_levels[room]["users"][user] = level
- room_state.power_levels = power_levels
- room_state.update()
-
- def set_power_levels(self, room: MatrixRoomID, content: Dict) -> None:
- state = self._get_room_state(room)
- state.power_levels = content
- state.update()
+ else:
+ super().registered(user_id)
diff --git a/mautrix_telegram/tgclient.py b/mautrix_telegram/tgclient.py
index 43e0a1a6..09a7a2fc 100644
--- a/mautrix_telegram/tgclient.py
+++ b/mautrix_telegram/tgclient.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -18,18 +17,21 @@ from typing import List, Union, Optional
from telethon import TelegramClient, utils
from telethon.tl.functions.messages import SendMediaRequest
-from telethon.tl.types import (
- InputMediaUploadedDocument, InputMediaUploadedPhoto, TypeDocumentAttribute, TypeInputMedia,
- TypeInputPeer, TypeMessageEntity, TypeMessageMedia, TypePeer)
+from telethon.tl.types import (InputMediaUploadedDocument, InputMediaUploadedPhoto,
+ TypeDocumentAttribute, TypeInputMedia, TypeInputPeer,
+ TypeMessageEntity, TypeMessageMedia, TypePeer)
from telethon.tl.patched import Message
+from telethon.sessions.abstract import Session
class MautrixTelegramClient(TelegramClient):
+ session: Session
+
async def upload_file_direct(self, file: bytes, mime_type: str = None,
attributes: List[TypeDocumentAttribute] = None,
file_name: str = None, max_image_size: float = 10 * 1000 ** 2,
) -> Union[InputMediaUploadedDocument, InputMediaUploadedPhoto]:
- file_handle = await super().upload_file(file, file_name=file_name, use_cache=False)
+ file_handle = await super().upload_file(file, file_name=file_name)
if (mime_type == "image/png" or mime_type == "image/jpeg") and len(file) < max_image_size:
return InputMediaUploadedPhoto(file_handle)
diff --git a/mautrix_telegram/types.py b/mautrix_telegram/types.py
index 15cc7094..f5cb2145 100644
--- a/mautrix_telegram/types.py
+++ b/mautrix_telegram/types.py
@@ -1,9 +1,3 @@
-from typing import Dict, NewType
-
-MatrixUserID = NewType('MatrixUserID', str)
-MatrixRoomID = NewType('MatrixRoomID', str)
-MatrixEventID = NewType('MatrixEventID', str)
-
-MatrixEvent = NewType('MatrixEvent', Dict)
+from typing import NewType
TelegramID = NewType('TelegramID', int)
diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py
index 0de66515..008ad57d 100644
--- a/mautrix_telegram/user.py
+++ b/mautrix_telegram/user.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,20 +13,22 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Awaitable, Dict, List, Iterable, Match, NewType, Optional, Tuple, TYPE_CHECKING
+from typing import (Awaitable, Dict, List, Iterable, NewType, Optional, Tuple, Any, cast,
+ TYPE_CHECKING)
import logging
import asyncio
-import re
-from telethon.tl.types import (
- TypeUpdate, UpdateNewMessage, UpdateNewChannelMessage, PeerUser,
- UpdateShortChatMessage, UpdateShortMessage, User as TLUser)
+from telethon.tl.types import (TypeUpdate, UpdateNewMessage, UpdateNewChannelMessage, PeerUser,
+ UpdateShortChatMessage, UpdateShortMessage, User as TLUser, Chat)
from telethon.tl.types.contacts import ContactsNotModified
from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest
from telethon.tl.functions.account import UpdateStatusRequest
-from mautrix_appservice import MatrixRequestError
-from .types import MatrixUserID, TelegramID
+from mautrix.client import Client
+from mautrix.errors import MatrixRequestError
+from mautrix.types import UserID
+
+from .types import TelegramID
from .db import User as DBUser
from .abstract_user import AbstractUser
from . import portal as po, puppet as pu
@@ -36,36 +37,46 @@ if TYPE_CHECKING:
from .config import Config
from .context import Context
-config = None # type: Config
+config: Optional['Config'] = None
SearchResult = NewType('SearchResult', Tuple['pu.Puppet', int])
class User(AbstractUser):
- log = logging.getLogger("mau.user") # type: logging.Logger
- by_mxid = {} # type: Dict[str, User]
- by_tgid = {} # type: Dict[int, User]
+ log: logging.Logger = logging.getLogger("mau.user")
+ by_mxid: Dict[str, 'User'] = {}
+ by_tgid: Dict[int, 'User'] = {}
- def __init__(self, mxid: MatrixUserID, tgid: Optional[TelegramID] = None,
+ phone: Optional[str]
+ contacts: List['pu.Puppet']
+ saved_contacts: int
+ portals: Dict[Tuple[TelegramID, TelegramID], 'po.Portal']
+ command_status: Optional[Dict[str, Any]]
+
+ _db_instance: Optional[DBUser]
+ _ensure_started_lock: asyncio.Lock
+
+ def __init__(self, mxid: UserID, tgid: Optional[TelegramID] = None,
username: Optional[str] = None, phone: Optional[str] = None,
db_contacts: Optional[Iterable[TelegramID]] = None,
saved_contacts: int = 0, is_bot: bool = False,
db_portals: Optional[Iterable[Tuple[TelegramID, TelegramID]]] = None,
db_instance: Optional[DBUser] = None) -> None:
super().__init__()
- self.mxid = mxid # type: MatrixUserID
- self.tgid = tgid # type: TelegramID
- self.is_bot = is_bot # type: bool
- self.username = username # type: str
- self.phone = phone # type: str
- self.contacts = [] # type: List[pu.Puppet]
- self.saved_contacts = saved_contacts # type: int
+ self.mxid = mxid
+ self.tgid = tgid
+ self.is_bot = is_bot
+ self.username = username
+ self.phone = phone
+ self.contacts = []
+ self.saved_contacts = saved_contacts
self.db_contacts = db_contacts
- self.portals = {} # type: Dict[Tuple[TelegramID, TelegramID], po.Portal]
+ self.portals = {}
self.db_portals = db_portals or []
- self._db_instance = db_instance # type: Optional[DBUser]
+ self._db_instance = db_instance
+ self._ensure_started_lock = asyncio.Lock()
- self.command_status = None # type: Optional[Dict]
+ self.command_status = None
(self.relaybot_whitelisted,
self.whitelisted,
@@ -78,14 +89,16 @@ class User(AbstractUser):
if tgid:
self.by_tgid[tgid] = self
+ self.log = self.log.getChild(self.mxid)
+
@property
def name(self) -> str:
return self.mxid
@property
def mxid_localpart(self) -> str:
- match = re.compile("@(.+):(.+)").match(self.mxid) # type: Match
- return match.group(1)
+ localpart, server = Client.parse_user_id(self.mxid)
+ return localpart
@property
def human_tg_id(self) -> str:
@@ -136,8 +149,8 @@ class User(AbstractUser):
saved_contacts=self.saved_contacts, portals=self.db_portals)
def save(self, contacts: bool = False, portals: bool = False) -> None:
- self.db_instance.update(tgid=self.tgid, tg_username=self.username, tg_phone=self.phone,
- saved_contacts=self.saved_contacts)
+ self.db_instance.edit(tgid=self.tgid, tg_username=self.username, tg_phone=self.phone,
+ saved_contacts=self.saved_contacts)
if contacts:
self.db_instance.contacts = self.db_contacts
if portals:
@@ -161,8 +174,11 @@ class User(AbstractUser):
# endregion
# region Telegram connection management
- def ensure_started(self, even_if_no_session=False) -> Awaitable['User']:
- return super().ensure_started(even_if_no_session)
+ async def ensure_started(self, even_if_no_session=False) -> 'User':
+ if not self.puppet_whitelisted or self.connected:
+ return self
+ async with self._ensure_started_lock:
+ return cast(User, await super().ensure_started(even_if_no_session))
async def start(self, delete_unless_authenticated: bool = False) -> 'User':
await super().start()
@@ -229,7 +245,7 @@ class User(AbstractUser):
self.phone = info.phone
changed = True
if self.tgid != info.id:
- self.tgid = info.id
+ self.tgid = TelegramID(info.id)
self.by_tgid[self.tgid] = self
if changed:
self.save()
@@ -242,7 +258,8 @@ class User(AbstractUser):
if not portal or portal.deleted or not portal.mxid or portal.has_bot:
continue
try:
- await portal.main_intent.kick(portal.mxid, self.mxid, "Logged out of Telegram.")
+ await portal.main_intent.kick_user(portal.mxid, self.mxid,
+ "Logged out of Telegram.")
except MatrixRequestError:
pass
self.portals = {}
@@ -263,7 +280,7 @@ class User(AbstractUser):
def _search_local(self, query: str, max_results: int = 5, min_similarity: int = 45
) -> List[SearchResult]:
- results = [] # type: List[SearchResult]
+ results: List[SearchResult] = []
for contact in self.contacts:
similarity = contact.similarity(query)
if similarity >= min_similarity:
@@ -275,7 +292,7 @@ class User(AbstractUser):
if len(query) < 5:
return []
server_results = await self.client(SearchRequest(q=query, limit=max_results))
- results = [] # type: List[SearchResult]
+ results: List[SearchResult] = []
for user in server_results.users:
puppet = pu.Puppet.get(user.id)
await puppet.update_info(self, user)
@@ -295,8 +312,19 @@ class User(AbstractUser):
return await self._search_remote(query), True
async def sync_dialogs(self, synchronous_create: bool = False) -> None:
+ if self.is_bot:
+ return
creators = []
- for entity in await self.get_dialogs(limit=config["bridge.sync_dialog_limit"] or None):
+ limit = config["bridge.sync_dialog_limit"] or None
+ self.log.debug(f"Syncing dialogs (limit={limit}, synchronous_create={synchronous_create})")
+ async for dialog in self.client.iter_dialogs(limit=limit, ignore_migrated=True,
+ archived=False):
+ entity = dialog.entity
+ if isinstance(entity, Chat) and (entity.deactivated or entity.left):
+ self.log.warning(f"Ignoring deactivated or left chat {entity} while syncing")
+ continue
+ elif isinstance(entity, TLUser) and not config["bridge.sync_direct_chats"]:
+ continue
portal = po.Portal.get_by_entity(entity)
self.portals[portal.tgid_full] = portal
creators.append(
@@ -304,6 +332,7 @@ class User(AbstractUser):
synchronous=synchronous_create))
self.save(portals=True)
await asyncio.gather(*creators, loop=self.loop)
+ self.log.debug("Dialog syncing complete")
def register_portal(self, portal: po.Portal) -> None:
try:
@@ -323,7 +352,7 @@ class User(AbstractUser):
async def needs_relaybot(self, portal: po.Portal) -> bool:
return not await self.is_logged_in() or (
- (portal.has_bot or self.bot) and portal.tgid_full not in self.portals)
+ (portal.has_bot or self.is_bot) and portal.tgid_full not in self.portals)
def _hash_contacts(self) -> int:
acc = 0
@@ -348,7 +377,7 @@ class User(AbstractUser):
# region Class instance lookup
@classmethod
- def get_by_mxid(cls, mxid: MatrixUserID, create: bool = True) -> Optional['User']:
+ def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['User']:
if not mxid:
raise ValueError("Matrix ID can't be empty")
@@ -400,9 +429,9 @@ class User(AbstractUser):
# endregion
-def init(context: 'Context') -> List[Awaitable['User']]:
+def init(context: 'Context') -> Iterable[Awaitable['User']]:
global config
config = context.config
- users = [User.from_db(user) for user in DBUser.all()]
- return [user.ensure_started() for user in users if user.tgid]
+ return (User.from_db(db_user).ensure_started()
+ for db_user in DBUser.all_with_tgid())
diff --git a/mautrix_telegram/util/__init__.py b/mautrix_telegram/util/__init__.py
index 2ba35c28..727224bb 100644
--- a/mautrix_telegram/util/__init__.py
+++ b/mautrix_telegram/util/__init__.py
@@ -1,7 +1,4 @@
from .file_transfer import transfer_file_to_matrix, convert_image
from .format_duration import format_duration
-from .signed_token import sign_token, verify_token
from .recursive_dict import recursive_del, recursive_set, recursive_get
-
-def ignore_coro(coro):
- pass
+from .color_log import ColorFormatter
diff --git a/mautrix_telegram/util/color_log.py b/mautrix_telegram/util/color_log.py
new file mode 100644
index 00000000..860720c2
--- /dev/null
+++ b/mautrix_telegram/util/color_log.py
@@ -0,0 +1,29 @@
+# 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 mautrix.util.color_log import ColorFormatter as BaseColorFormatter, PREFIX, MXID_COLOR, RESET
+
+TELETHON_COLOR = PREFIX + "35;1m" # magenta
+TELETHON_MODULE_COLOR = PREFIX + "35m"
+
+
+class ColorFormatter(BaseColorFormatter):
+ def _color_name(self, module: str) -> str:
+ if module.startswith("telethon"):
+ prefix, user_id, module = module.split(".", 2)
+ return (f"{TELETHON_COLOR}{prefix}{RESET}."
+ f"{MXID_COLOR}{user_id}{RESET}."
+ f"{TELETHON_MODULE_COLOR}{module}{RESET}")
+ return super()._color_name(module)
diff --git a/mautrix_telegram/util/file_transfer.py b/mautrix_telegram/util/file_transfer.py
index ddd6bfc5..02a3e7ca 100644
--- a/mautrix_telegram/util/file_transfer.py
+++ b/mautrix_telegram/util/file_transfer.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -28,7 +27,8 @@ from telethon.tl.types import (Document, InputFileLocation, InputDocumentFileLoc
InputPeerPhotoFileLocation)
from telethon.errors import (AuthBytesInvalidError, AuthKeyInvalidError, LocationInvalidError,
SecurityError, FileIdInvalidError)
-from mautrix_appservice import IntentAPI
+
+from mautrix.appservice import IntentAPI
from ..tgclient import MautrixTelegramClient
from ..db import TelegramFile as DBTelegramFile
@@ -38,6 +38,7 @@ try:
from PIL import Image
except ImportError:
Image = None
+
try:
from moviepy.editor import VideoFileClip
import random
@@ -47,7 +48,7 @@ try:
except ImportError:
VideoFileClip = random = string = os = mimetypes = None
-log = logging.getLogger("mau.util") # type: logging.Logger
+log: logging.Logger = logging.getLogger("mau.util")
TypeLocation = Union[Document, InputDocumentFileLocation, InputPeerPhotoFileLocation,
InputFileLocation, InputPhotoFileLocation]
@@ -59,7 +60,7 @@ def convert_image(file: bytes, source_mime: str = "image/webp", target_type: str
if not Image:
return source_mime, file, None, None
try:
- image = Image.open(BytesIO(file)).convert("RGBA") # type: Image.Image
+ image: Image.Image = Image.open(BytesIO(file)).convert("RGBA")
if thumbnail_to:
image.thumbnail(thumbnail_to, Image.ANTIALIAS)
new_file = BytesIO()
@@ -102,8 +103,10 @@ def _read_video_thumbnail(data: bytes, video_ext: str = "mp4", frame_ext: str =
def _location_to_id(location: TypeLocation) -> str:
- if isinstance(location, (Document, InputDocumentFileLocation, InputPhotoFileLocation)):
+ if isinstance(location, Document):
return f"{location.id}-{location.access_hash}"
+ elif isinstance(location, (InputDocumentFileLocation, InputPhotoFileLocation)):
+ return f"{location.id}-{location.access_hash}-{location.thumb_size}"
elif isinstance(location, (InputFileLocation, InputPeerPhotoFileLocation)):
return f"{location.volume_id}-{location.local_id}"
@@ -134,7 +137,7 @@ async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: In
width, height = None, None
mime_type = magic.from_buffer(file, mime=True)
- content_uri = await intent.upload_file(file, mime_type)
+ content_uri = await intent.upload_media(file, mime_type)
db_file = DBTelegramFile(id=loc_id, mxc=content_uri, mime_type=mime_type,
was_converted=False, timestamp=int(time.time()), size=len(file),
@@ -148,7 +151,7 @@ async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: In
return db_file
-transfer_locks = {} # type: Dict[str, asyncio.Lock]
+transfer_locks: Dict[str, asyncio.Lock] = {}
TypeThumbnail = Optional[Union[TypeLocation, TypePhotoSize]]
@@ -202,7 +205,7 @@ async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, inten
mime_type = new_mime_type
thumbnail = None
- content_uri = await intent.upload_file(file, mime_type)
+ content_uri = await intent.upload_media(file, mime_type)
db_file = DBTelegramFile(id=loc_id, mxc=content_uri,
mime_type=mime_type, was_converted=image_converted,
diff --git a/mautrix_telegram/util/format_duration.py b/mautrix_telegram/util/format_duration.py
index b98c6e71..d079cc0d 100644
--- a/mautrix_telegram/util/format_duration.py
+++ b/mautrix_telegram/util/format_duration.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
diff --git a/mautrix_telegram/util/recursive_dict.py b/mautrix_telegram/util/recursive_dict.py
index ef76fe3e..6fb0b7e2 100644
--- a/mautrix_telegram/util/recursive_dict.py
+++ b/mautrix_telegram/util/recursive_dict.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -15,11 +14,12 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
from typing import Dict, Any
-from ..config import DictWithRecursion
+
+from mautrix.util.config import RecursiveDict
def recursive_set(data: Dict[str, Any], key: str, value: Any) -> bool:
- key, next_key = DictWithRecursion._parse_key(key)
+ key, next_key = RecursiveDict.parse_key(key)
if next_key is not None:
if key not in data:
data[key] = {}
@@ -32,7 +32,7 @@ def recursive_set(data: Dict[str, Any], key: str, value: Any) -> bool:
def recursive_get(data: Dict[str, Any], key: str) -> Any:
- key, next_key = DictWithRecursion._parse_key(key)
+ key, next_key = RecursiveDict.parse_key(key)
if next_key is not None:
next_data = data.get(key, None)
if not next_data:
@@ -42,7 +42,7 @@ def recursive_get(data: Dict[str, Any], key: str) -> Any:
def recursive_del(data: Dict[str, any], key: str) -> bool:
- key, next_key = DictWithRecursion._parse_key(key)
+ key, next_key = RecursiveDict.parse_key(key)
if next_key is not None:
if key not in data:
return False
diff --git a/mautrix_telegram/util/sane_mimetypes.py b/mautrix_telegram/util/sane_mimetypes.py
index 2d79087b..272e514e 100644
--- a/mautrix_telegram/util/sane_mimetypes.py
+++ b/mautrix_telegram/util/sane_mimetypes.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
diff --git a/mautrix_telegram/util/signed_token.py b/mautrix_telegram/util/signed_token.py
deleted file mode 100644
index c8ba55d3..00000000
--- a/mautrix_telegram/util/signed_token.py
+++ /dev/null
@@ -1,53 +0,0 @@
-# -*- coding: future_fstrings -*-
-# 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 Dict, Optional
-import json
-import base64
-import hashlib
-
-
-def _get_checksum(key: str, payload: bytes) -> str:
- hasher = hashlib.sha256()
- hasher.update(payload)
- hasher.update(key.encode("utf-8"))
- checksum = hasher.hexdigest()
- return checksum
-
-
-def sign_token(key: str, payload: Dict) -> str:
- payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8"))
- checksum = _get_checksum(key, payload_b64)
- return f"{checksum}:{payload_b64.decode('utf-8')}"
-
-
-def verify_token(key: str, data: str) -> Optional[Dict]:
- if not data:
- return None
-
- try:
- checksum, payload = data.split(":", 1)
- except ValueError:
- return None
-
- if checksum != _get_checksum(key, payload.encode("utf-8")):
- return None
-
- payload = base64.urlsafe_b64decode(payload).decode("utf-8")
- try:
- return json.loads(payload)
- except json.JSONDecodeError:
- return None
diff --git a/mautrix_telegram/web/common/auth_api.py b/mautrix_telegram/web/common/auth_api.py
index ccb4e53c..39bdab4f 100644
--- a/mautrix_telegram/web/common/auth_api.py
+++ b/mautrix_telegram/web/common/auth_api.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,27 +13,30 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from abc import abstractmethod
from typing import Optional
-
-from aiohttp import web
+from abc import abstractmethod
import abc
import asyncio
import logging
+from aiohttp import web
+
from telethon.errors import *
+from mautrix.bridge import OnlyLoginSelf, InvalidAccessToken
+
from ...commands.telegram.auth import enter_password
-from ...util import format_duration, ignore_coro
-from ...puppet import Puppet, PuppetError
+from ...util import format_duration
+from ...puppet import Puppet
from ...user import User
class AuthAPI(abc.ABC):
- log = logging.getLogger("mau.web.auth") # type: logging.Logger
+ log: logging.Logger = logging.getLogger("mau.web.auth")
+ loop: asyncio.AbstractEventLoop
def __init__(self, loop: asyncio.AbstractEventLoop):
- self.loop = loop # type: asyncio.AbstractEventLoop
+ self.loop = loop
@abstractmethod
def get_login_response(self, status: int = 200, state: str = "", username: str = "",
@@ -56,15 +58,14 @@ class AuthAPI(abc.ABC):
error="You have already logged in with your Matrix "
"account.", errcode="already-logged-in")
- resp = await puppet.switch_mxid(token.strip(), user.mxid)
- if resp == PuppetError.OnlyLoginSelf:
+ try:
+ await puppet.switch_mxid(token.strip(), user.mxid)
+ except OnlyLoginSelf:
return self.get_mx_login_response(status=403, errcode="only-login-self",
error="You can only log in as your own Matrix user.")
- elif resp == PuppetError.InvalidAccessToken:
+ except InvalidAccessToken:
return self.get_mx_login_response(status=401, errcode="invalid-access-token",
error="Failed to verify access token.")
- assert resp == PuppetError.Success, "Encountered an unhandled PuppetError."
-
return self.get_mx_login_response(mxid=user.mxid, status=200, state="logged-in")
async def post_matrix_password(self, user: User, password: str) -> web.Response:
@@ -118,7 +119,7 @@ class AuthAPI(abc.ABC):
existing_user = User.get_by_tgid(user_info.id)
if existing_user and existing_user != user:
await existing_user.log_out()
- ignore_coro(asyncio.ensure_future(user.post_login(user_info), loop=self.loop))
+ asyncio.ensure_future(user.post_login(user_info), loop=self.loop)
if user.command_status and user.command_status["action"] == "Login":
user.command_status = None
diff --git a/mautrix_telegram/web/provisioning/__init__.py b/mautrix_telegram/web/provisioning/__init__.py
index 5b793fdf..31b01a87 100644
--- a/mautrix_telegram/web/provisioning/__init__.py
+++ b/mautrix_telegram/web/provisioning/__init__.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,20 +13,23 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from aiohttp import web
from typing import Awaitable, Callable, Dict, Optional, Tuple, TYPE_CHECKING
import asyncio
import logging
import json
+from aiohttp import web
+
from telethon.utils import get_peer_id, resolve_id
from telethon.tl.types import ChatForbidden, ChannelForbidden, TypeChat
-from mautrix_appservice import AppService, MatrixRequestError, IntentError
-from ...types import MatrixUserID, TelegramID
+from mautrix.appservice import AppService
+from mautrix.errors import MatrixRequestError, IntentError
+from mautrix.types import UserID
+
+from ...types import TelegramID
from ...user import User
from ...portal import Portal
-from ...util import ignore_coro
from ...commands.portal.util import user_has_power_level, get_initial_state
from ..common import AuthAPI
@@ -36,16 +38,19 @@ if TYPE_CHECKING:
class ProvisioningAPI(AuthAPI):
- log = logging.getLogger("mau.web.provisioning") # type: logging.Logger
+ log: logging.Logger = logging.getLogger("mau.web.provisioning")
+ secret: str
+ az: AppService
+ context: 'Context'
+ app: web.Application
def __init__(self, context: "Context") -> None:
super().__init__(context.loop)
- self.secret = context.config["appservice.provisioning.shared_secret"] # type: str
- self.az = context.az # type: AppService
- self.context = context # type: Context
+ self.secret = context.config["appservice.provisioning.shared_secret"]
+ self.az = context.az
+ self.context = context
- self.app = web.Application(loop=context.loop, middlewares=[self.error_middleware]
- ) # type: web.Application
+ self.app = web.Application(loop=context.loop, middlewares=[self.error_middleware])
portal_prefix = "/portal/{mxid:![^/]+}"
self.app.router.add_route("GET", f"{portal_prefix}", self.get_portal_by_mxid)
@@ -77,18 +82,7 @@ class ProvisioningAPI(AuthAPI):
if not portal:
return self.get_error_response(404, "portal_not_found",
"Portal with given Matrix ID not found.")
- user, _ = await self.get_user(request.query.get("user_id", None), expect_logged_in=None,
- require_puppeting=False)
- return web.json_response({
- "mxid": portal.mxid,
- "chat_id": get_peer_id(portal.peer),
- "peer_type": portal.peer_type,
- "title": portal.title,
- "about": portal.about,
- "username": portal.username,
- "megagroup": portal.megagroup,
- "can_unbridge": (await portal.can_user_perform(user, "unbridge")) if user else False,
- })
+ return await self._get_portal_response(UserID(request.query.get("user_id", "")), portal)
async def get_portal_by_tgid(self, request: web.Request) -> web.Response:
err = self.check_authorization(request)
@@ -104,8 +98,10 @@ class ProvisioningAPI(AuthAPI):
if not portal:
return self.get_error_response(404, "portal_not_found",
"Portal to given Telegram chat not found.")
- user, _ = await self.get_user(request.query.get("user_id", None), expect_logged_in=None,
- require_puppeting=False)
+ return await self._get_portal_response(UserID(request.query.get("user_id", "")), portal)
+
+ async def _get_portal_response(self, user_id: UserID, portal: Portal) -> web.Response:
+ user, _ = await self.get_user(user_id, expect_logged_in=None, require_puppeting=False)
return web.json_response({
"mxid": portal.mxid,
"chat_id": get_peer_id(portal.peer),
@@ -169,7 +165,7 @@ class ProvisioningAPI(AuthAPI):
return self.get_login_response(status=403, errcode="not_logged_in",
error="You are not logged in and there is no relay bot.")
- entity = None # type: Optional[TypeChat]
+ entity: Optional[TypeChat] = None
try:
entity = await acting_user.client.get_entity(portal.peer)
except Exception:
@@ -191,9 +187,8 @@ class ProvisioningAPI(AuthAPI):
portal.photo_id = ""
portal.save()
- ignore_coro(asyncio.ensure_future(portal.update_matrix_room(user, entity, direct,
- levels=levels),
- loop=self.loop))
+ asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, levels=levels),
+ loop=self.loop)
return web.Response(status=202, body="{}")
@@ -272,7 +267,8 @@ class ProvisioningAPI(AuthAPI):
require_puppeting=False, require_user=False)
if err is not None:
return err
- elif user and not await user_has_power_level(portal.mxid, self.az.intent, user, "unbridge"):
+ elif user and not await user_has_power_level(portal.mxid, self.az.intent, user,
+ "unbridge"):
return self.get_error_response(403, "not_enough_permissions",
"You do not have the permissions to unbridge that room.")
@@ -287,7 +283,7 @@ class ProvisioningAPI(AuthAPI):
self.log.exception("Failed to disconnect chat")
return self.get_error_response(500, "exception", "Failed to disconnect chat")
else:
- ignore_coro(asyncio.ensure_future(coro, loop=self.loop))
+ asyncio.ensure_future(coro, loop=self.loop)
return web.json_response({}, status=200 if sync else 202)
async def get_user_info(self, request: web.Request) -> web.Response:
@@ -320,11 +316,10 @@ class ProvisioningAPI(AuthAPI):
return err
if not user.is_bot:
- chats = await user.get_dialogs()
return web.json_response([{
"id": get_peer_id(chat),
"title": chat.title,
- } for chat in chats])
+ } async for chat in user.client.get_dialogs(ignore_migrated=True, archived=False)])
else:
return web.json_response([{
"id": get_peer_id(chat.peer),
@@ -365,7 +360,8 @@ class ProvisioningAPI(AuthAPI):
async def bridge_info(self, request: web.Request) -> web.Response:
return web.json_response({
- "relaybot_username": self.context.bot.username if self.context.bot is not None else None,
+ "relaybot_username": (self.context.bot.username
+ if self.context.bot is not None else None),
}, status=200)
@staticmethod
@@ -431,7 +427,7 @@ class ProvisioningAPI(AuthAPI):
except json.JSONDecodeError:
return None
- async def get_user(self, mxid: MatrixUserID, expect_logged_in: Optional[bool] = False,
+ async def get_user(self, mxid: Optional[UserID], expect_logged_in: Optional[bool] = False,
require_puppeting: bool = True, require_user: bool = True
) -> Tuple[Optional[User], Optional[web.Response]]:
if not mxid:
@@ -460,8 +456,7 @@ class ProvisioningAPI(AuthAPI):
expect_logged_in: Optional[bool] = False,
require_puppeting: bool = False,
want_data: bool = True,
- ) -> (Tuple[Optional[Dict],
- Optional[User],
+ ) -> (Tuple[Optional[Dict], Optional[User],
Optional[web.Response]]):
err = self.check_authorization(request)
if err is not None:
diff --git a/mautrix_telegram/web/public/__init__.py b/mautrix_telegram/web/public/__init__.py
index d1871c20..71f7de88 100644
--- a/mautrix_telegram/web/public/__init__.py
+++ b/mautrix_telegram/web/public/__init__.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -15,37 +14,42 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
from typing import Optional
-from aiohttp import web
-from mako.template import Template
-import pkg_resources
import asyncio
import logging
import random
import string
import time
-from ...types import MatrixUserID
-from ...util import sign_token, verify_token
+from mako.template import Template
+from aiohttp import web
+import pkg_resources
+
+from mautrix.types import UserID
+from mautrix.util.signed_token import sign_token, verify_token
+
from ...user import User
from ...puppet import Puppet
from ..common import AuthAPI
class PublicBridgeWebsite(AuthAPI):
- log = logging.getLogger("mau.web.public") # type: logging.Logger
+ log: logging.Logger = logging.getLogger("mau.web.public")
+ secret_key: str
+ login: Template
+ mx_login: Template
+ app: web.Application
def __init__(self, loop: asyncio.AbstractEventLoop):
super().__init__(loop)
- self.secret_key = "".join(
- random.choice(string.ascii_lowercase + string.digits) for _ in range(64)) # type: str
+ self.secret_key = "".join(random.choices(string.ascii_lowercase + string.digits, k=64))
self.login = Template(pkg_resources.resource_string(
- "mautrix_telegram", "web/public/login.html.mako")) # type: Template
+ "mautrix_telegram", "web/public/login.html.mako"))
self.mx_login = Template(pkg_resources.resource_string(
- "mautrix_telegram", "web/public/matrix-login.html.mako")) # type: Template
+ "mautrix_telegram", "web/public/matrix-login.html.mako"))
- self.app = web.Application(loop=loop) # type: web.Application
+ self.app = web.Application(loop=loop)
self.app.router.add_route("GET", "/login", self.get_login)
self.app.router.add_route("POST", "/login", self.post_login)
self.app.router.add_route("GET", "/matrix-login", self.get_matrix_login)
@@ -60,11 +64,11 @@ class PublicBridgeWebsite(AuthAPI):
"expiry": int(time.time()) + expires_in,
})
- def verify_token(self, token: str, endpoint: str = "/login") -> Optional[MatrixUserID]:
+ def verify_token(self, token: str, endpoint: str = "/login") -> Optional[UserID]:
token = verify_token(self.secret_key, token)
if token and (token.get("expiry", 0) > int(time.time()) and
token.get("endpoint", None) == endpoint):
- return MatrixUserID(token.get("mxid", None))
+ return UserID(token.get("mxid", None))
return None
async def get_login(self, request: web.Request) -> web.Response:
diff --git a/optional-requirements.txt b/optional-requirements.txt
index 13017dc0..9e0a0ff0 100644
--- a/optional-requirements.txt
+++ b/optional-requirements.txt
@@ -1,4 +1,5 @@
cryptg
Pillow
moviepy
-prometheus-client
+prometheus_client
+psycopg2-binary
diff --git a/requirements.txt b/requirements.txt
index e9e5ccc2..f8da589e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,10 +1,10 @@
aiohttp
-mautrix-appservice
+mautrix
ruamel.yaml
python-magic
SQLAlchemy
alembic
commonmark
-future-fstrings
-telethon
+#telethon
+git+https://github.com/LonamiWebs/Telethon@master#egg=telethon
telethon-session-sqlalchemy
diff --git a/setup.py b/setup.py
index 0f757c09..a8e824a8 100644
--- a/setup.py
+++ b/setup.py
@@ -6,7 +6,8 @@ extras = {
"fast_crypto": ["cryptg>=0.1,<0.3"],
"webp_convert": ["Pillow>=4.3.0,<7"],
"hq_thumbnails": ["moviepy>=1.0,<2.0"],
- "metrics": ["prometheus-client>=0.6.0,<0.8.0"],
+ "metrics": ["prometheus_client>=0.6.0,<0.8.0"],
+ "postgres": ["psycopg2-binary>=2,<3"],
}
extras["all"] = list({dep for deps in extras.values() for dep in deps})
@@ -31,17 +32,17 @@ setuptools.setup(
install_requires=[
"aiohttp>=3.0.1,<4",
- "mautrix-appservice>=0.3.11,<0.4.0",
+ "mautrix>=0.4.0.dev53,<0.5",
"SQLAlchemy>=1.2.3,<2",
"alembic>=1.0.0,<2",
"commonmark>=0.8.1,<1",
"ruamel.yaml>=0.15.35,<0.16",
- "future-fstrings>=0.4.2",
"python-magic>=0.4.15,<0.5",
"telethon>=1.9,<1.10",
"telethon-session-sqlalchemy>=0.2.14,<0.3",
],
extras_require=extras,
+ python_requires="~=3.6",
setup_requires=["pytest-runner"],
tests_require=["pytest", "pytest-asyncio", "pytest-mock"],
@@ -53,8 +54,8 @@ setuptools.setup(
"Framework :: AsyncIO",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
+ "Programming Language :: Python :: 3.7",
],
entry_points="""
[console_scripts]
diff --git a/tests/commands/test_handler.py b/tests/commands/test_handler.py
index 1e006db6..0a796378 100644
--- a/tests/commands/test_handler.py
+++ b/tests/commands/test_handler.py
@@ -5,12 +5,14 @@ import pytest
from _pytest.fixtures import FixtureRequest
from pytest_mock import MockFixture
+from mautrix.types import EventID, RoomID, UserID
+import mautrix.bridge.commands.handler
+
import mautrix_telegram.commands.handler
from mautrix_telegram.commands.handler import (CommandEvent, CommandHandler, CommandProcessor,
- HelpSection)
+ HelpSection, HelpCacheKey)
from mautrix_telegram.config import Config
from mautrix_telegram.context import Context
-from mautrix_telegram.types import MatrixEventID, MatrixRoomID, MatrixUserID
import mautrix_telegram.user as u
from tests.utils.helpers import AsyncMock, list_true_once_each
@@ -45,9 +47,9 @@ class TestCommandEvent:
evt = CommandEvent(
processor=command_processor,
- room=MatrixRoomID("#mock_room:example.org"),
- event=MatrixEventID("$H45H:example.org"),
- sender=u.User(MatrixUserID("@sender:example.org")),
+ room_id=RoomID("#mock_room:example.org"),
+ event_id=EventID("$H45H:example.org"),
+ sender=u.User(UserID("@sender:example.org")),
command="help",
args=[],
is_management=True,
@@ -61,7 +63,7 @@ class TestCommandEvent:
# html, no markdown
evt.reply(message, allow_html=True, render_markdown=False)
mock_az.intent.send_notice.assert_called_with(
- MatrixRoomID("#mock_room:example.org"),
+ RoomID("#mock_room:example.org"),
"**This** was
allfun*!",
html="**This** was
allfun*!\n",
)
@@ -69,7 +71,7 @@ class TestCommandEvent:
# html, markdown (default)
evt.reply(message, allow_html=True, render_markdown=True)
mock_az.intent.send_notice.assert_called_with(
- MatrixRoomID("#mock_room:example.org"),
+ RoomID("#mock_room:example.org"),
"**This** was
allfun*!",
html=(
"This was
"
@@ -80,7 +82,7 @@ class TestCommandEvent:
# no html, no markdown
evt.reply(message, allow_html=False, render_markdown=False)
mock_az.intent.send_notice.assert_called_with(
- MatrixRoomID("#mock_room:example.org"),
+ RoomID("#mock_room:example.org"),
"**This** was
allfun*!",
html=None,
)
@@ -88,7 +90,7 @@ class TestCommandEvent:
# no html, markdown
evt.reply(message, allow_html=False, render_markdown=True)
mock_az.intent.send_notice.assert_called_with(
- MatrixRoomID("#mock_room:example.org"),
+ RoomID("#mock_room:example.org"),
"**This** was
allfun*!",
html="
This <i>was</i><br/>"
"<strong>all</strong>fun*!
\n"
@@ -100,9 +102,9 @@ class TestCommandEvent:
evt = CommandEvent(
processor=command_processor,
- room=MatrixRoomID("#mock_room:example.org"),
- event=MatrixEventID("$H45H:example.org"),
- sender=u.User(MatrixUserID("@sender:example.org")),
+ room_id=RoomID("#mock_room:example.org"),
+ event_id=EventID("$H45H:example.org"),
+ sender=u.User(UserID("@sender:example.org")),
command="help",
args=[],
is_management=False,
@@ -115,7 +117,7 @@ class TestCommandEvent:
render_markdown=False)
mock_az.intent.send_notice.assert_called_with(
- MatrixRoomID("#mock_room:example.org"),
+ RoomID("#mock_room:example.org"),
"tg ....tg+sp...tg tg",
html=None,
)
@@ -126,9 +128,9 @@ class TestCommandEvent:
evt = CommandEvent(
processor=command_processor,
- room=MatrixRoomID("#mock_room:example.org"),
- event=MatrixEventID("$H45H:example.org"),
- sender=u.User(MatrixUserID("@sender:example.org")),
+ room_id=RoomID("#mock_room:example.org"),
+ event_id=EventID("$H45H:example.org"),
+ sender=u.User(UserID("@sender:example.org")),
command="help",
args=[],
is_management=True,
@@ -144,7 +146,7 @@ class TestCommandEvent:
)
mock_az.intent.send_notice.assert_called_with(
- MatrixRoomID("#mock_room:example.org"),
+ RoomID("#mock_room:example.org"),
"....tg+sp...tg tg",
html="....tg+sp...tg tg
\n",
)
@@ -195,15 +197,15 @@ class TestCommandHandler:
help_section=HelpSection("Mock Section", 42, ""),
)
- sender = u.User(MatrixUserID("@sender:example.org"))
+ sender = u.User(UserID("@sender:example.org"))
sender.puppet_whitelisted = False
sender.matrix_puppet_whitelisted = False
sender.is_admin = False
event = CommandEvent(
processor=command_processor,
- room=MatrixRoomID("#mock_room:example.org"),
- event=MatrixEventID("$H45H:example.org"),
+ room_id=RoomID("#mock_room:example.org"),
+ event_id=EventID("$H45H:example.org"),
sender=sender,
command=command,
args=[],
@@ -212,7 +214,8 @@ class TestCommandHandler:
)
assert await command_handler.get_permission_error(event)
- assert not command_handler.has_permission(False, False, False, False, False)
+ assert not command_handler.has_permission(
+ HelpCacheKey(False, False, False, False, False, False))
@pytest.mark.parametrize(
(
@@ -255,7 +258,7 @@ class TestCommandHandler:
help_section=HelpSection("Mock Section", 42, ""),
)
- sender = u.User(MatrixUserID("@sender:example.org"))
+ sender = u.User(UserID("@sender:example.org"))
sender.puppet_whitelisted = puppet_whitelisted
sender.matrix_puppet_whitelisted = matrix_puppet_whitelisted
sender.is_admin = is_admin
@@ -263,8 +266,8 @@ class TestCommandHandler:
event = CommandEvent(
processor=command_processor,
- room=MatrixRoomID("#mock_room:example.org"),
- event=MatrixEventID("$H45H:example.org"),
+ room_id=RoomID("#mock_room:example.org"),
+ event_id=EventID("$H45H:example.org"),
sender=sender,
command=command,
args=[],
@@ -274,12 +277,12 @@ class TestCommandHandler:
assert not await command_handler.get_permission_error(event)
assert command_handler.has_permission(
- is_management=is_management,
- puppet_whitelisted=puppet_whitelisted,
- matrix_puppet_whitelisted=matrix_puppet_whitelisted,
- is_admin=is_admin,
- is_logged_in=is_logged_in,
- )
+ HelpCacheKey(is_management=is_management,
+ puppet_whitelisted=puppet_whitelisted,
+ matrix_puppet_whitelisted=matrix_puppet_whitelisted,
+ is_admin=is_admin,
+ is_logged_in=is_logged_in,
+ is_portal=boolean))
class TestCommandProcessor:
@@ -292,41 +295,41 @@ class TestCommandProcessor:
mocker: MockFixture) -> None:
mocker.patch('mautrix_telegram.user.config', self.config)
mocker.patch(
- 'mautrix_telegram.commands.handler.command_handlers',
+ 'mautrix.bridge.commands.handler.command_handlers',
{"help": AsyncMock(), "unknown-command": AsyncMock()}
)
- sender = u.User(MatrixUserID("@sender:example.org"))
+ sender = u.User(UserID("@sender:example.org"))
result = await command_processor.handle(
- room=MatrixRoomID("#mock_room:example.org"),
- event_id=MatrixEventID("$H45H:example.org"),
+ room_id=RoomID("#mock_room:example.org"),
+ event_id=EventID("$H45H:example.org"),
sender=sender,
command="hElp",
args=[],
is_management=boolean2[0],
- is_portal=boolean2[1],
- )
+ is_portal=boolean2[1])
assert result is None
- command_handlers = mautrix_telegram.commands.handler.command_handlers
+ command_handlers = mautrix.bridge.commands.handler.command_handlers
command_handlers["help"].mock.assert_called_once() # type: ignore
@pytest.mark.asyncio
async def test_handle_unknown_command(self, command_processor: CommandProcessor,
- boolean2: Tuple[bool, bool], mocker: MockFixture) -> None:
+ boolean2: Tuple[bool, bool],
+ mocker: MockFixture) -> None:
mocker.patch('mautrix_telegram.user.config', self.config)
mocker.patch(
- 'mautrix_telegram.commands.handler.command_handlers',
+ 'mautrix.bridge.commands.handler.command_handlers',
{"help": AsyncMock(), "unknown-command": AsyncMock()}
)
- sender = u.User(MatrixUserID("@sender:example.org"))
+ sender = u.User(UserID("@sender:example.org"))
sender.command_status = {}
result = await command_processor.handle(
- room=MatrixRoomID("#mock_room:example.org"),
- event_id=MatrixEventID("$H45H:example.org"),
+ room_id=RoomID("#mock_room:example.org"),
+ event_id=EventID("$H45H:example.org"),
sender=sender,
command="foo",
args=[],
@@ -335,7 +338,7 @@ class TestCommandProcessor:
)
assert result is None
- command_handlers = mautrix_telegram.commands.handler.command_handlers
+ command_handlers = mautrix.bridge.commands.handler.command_handlers
command_handlers["help"].mock.assert_not_called() # type: ignore
command_handlers["unknown-command"].mock.assert_called_once() # type: ignore
@@ -345,16 +348,16 @@ class TestCommandProcessor:
mocker: MockFixture) -> None:
mocker.patch('mautrix_telegram.user.config', self.config)
mocker.patch(
- 'mautrix_telegram.commands.handler.command_handlers',
+ 'mautrix.bridge.commands.handler.command_handlers',
{"help": AsyncMock(), "unknown-command": AsyncMock()}
)
- sender = u.User(MatrixUserID("@sender:example.org"))
+ sender = u.User(UserID("@sender:example.org"))
sender.command_status = {"foo": AsyncMock(), "next": AsyncMock()}
result = await command_processor.handle(
- room=MatrixRoomID("#mock_room:example.org"),
- event_id=MatrixEventID("$H45H:example.org"),
+ room_id=RoomID("#mock_room:example.org"),
+ event_id=EventID("$H45H:example.org"),
sender=sender, # u.User
command="foo",
args=[],
@@ -363,7 +366,7 @@ class TestCommandProcessor:
)
assert result is None
- command_handlers = mautrix_telegram.commands.handler.command_handlers
+ command_handlers = mautrix.bridge.commands.handler.command_handlers
command_handlers["help"].mock.assert_not_called() # type: ignore
command_handlers["unknown-command"].mock.assert_not_called() # type: ignore
sender.command_status["foo"].mock.assert_not_called() # type: ignore