From 7c54436dffb041bc94073366a5a5c77470a8b95f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 22 Feb 2018 21:12:35 +0200 Subject: [PATCH 1/6] Initial support for creating portals without any authenticated users --- mautrix_telegram/abstract_user.py | 2 ++ mautrix_telegram/bot.py | 46 +++++++++++++++++++++++++++++-- mautrix_telegram/portal.py | 27 ++++++++++++------ mautrix_telegram/tgclient.py | 3 ++ mautrix_telegram/user.py | 2 -- 5 files changed, 68 insertions(+), 12 deletions(-) diff --git a/mautrix_telegram/abstract_user.py b/mautrix_telegram/abstract_user.py index 7619c432..abdf917a 100644 --- a/mautrix_telegram/abstract_user.py +++ b/mautrix_telegram/abstract_user.py @@ -88,6 +88,8 @@ class AbstractUser: return self.logged_in and self.whitelisted async def start(self): + if not self.client: + self._init_client() self.connected = await self.client.connect() async def ensure_started(self, even_if_no_session=False): diff --git a/mautrix_telegram/bot.py b/mautrix_telegram/bot.py index 5e29a5b2..17a6b4aa 100644 --- a/mautrix_telegram/bot.py +++ b/mautrix_telegram/bot.py @@ -23,7 +23,7 @@ from telethon.tl.functions.channels import GetChannelsRequest from .abstract_user import AbstractUser from .db import BotChat -from . import puppet as pu +from . import puppet as pu, portal as po, user as u config = None @@ -35,6 +35,7 @@ class Bot(AbstractUser): super().__init__() self.token = token self.whitelisted = True + self.username = None self._init_client() self.chats = {chat.id: chat.type for chat in BotChat.query.all()} @@ -48,6 +49,7 @@ class Bot(AbstractUser): async def post_login(self): info = await self.client.get_me() self.tgid = info.id + self.username = info.username self.mxid = pu.Puppet.get_mxid_from_id(self.tgid) chat_ids = [id for id, type in self.chats.items() if type == "chat"] @@ -85,10 +87,50 @@ class Bot(AbstractUser): self.db.delete(BotChat.query.get(id)) self.db.commit() + async def handle_command(self, message): + def reply(reply_text): + return self.client.send_message_super(message.to_id, reply_text) + + text = message.message + portal = po.Portal.get_by_entity(message.to_id) + if text == "/portal": + await portal.create_matrix_room(self) + if portal.mxid: + if portal.username: + return await reply( + f"Portal is public: [portal.alias](https://matrix.to/#/{portal.alias})") + else: + return await reply( + "Portal is not public. Use `/invite ` to get an invite.") + elif text.startswith("/invite"): + mxid = text[len("/invite "):] + if len(mxid) == 0: + return await reply("Usage: `/invite `") + elif not portal.mxid: + return await reply("Portal does not have Matrix room. " + "Create one with /portal first.") + user = await u.User.get_by_mxid(mxid).ensure_started() + if not user.whitelisted: + return await reply("That user is not whitelisted to use the bridge.") + elif user.logged_in: + displayname = f"@{user.username}" if user.username else user.displayname + return await reply("That user seems to be logged in. " + f"Just invite [{displayname}](tg://user?id={user.tgid})") + else: + await portal.main_intent.invite(portal.mxid, user.mxid) + return await reply(f"Invited `{user.mxid}` to the portal.") + async def update(self, update): if not isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)): return - elif not isinstance(update.message, MessageService): + + is_command = (isinstance(update.message, Message) + and len(update.message.entities) > 0 + and isinstance(update.message.entities[0], MessageEntityBotCommand)) + if is_command: + return await self.handle_command(update.message) + + if not isinstance(update.message, MessageService): return to_id = update.message.to_id diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 949b929a..c5bafc7c 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -45,9 +45,9 @@ class Portal: az = None bot = None bridge_notices = False + alias_template = None mx_alias_regex = None hs_domain = None - mxid_regex = None by_mxid = {} by_tgid = {} @@ -223,7 +223,7 @@ class Portal: if self.peer_type == "channel" and entity.username: public = True - alias = self._get_room_alias(entity.username) + alias = self._get_alias_localpart(entity.username) self.username = entity.username else: public = False @@ -281,8 +281,17 @@ class Portal: } return levels - def _get_room_alias(self, username=None): - return self.alias_template.format(groupname=username or self.username) + @property + def alias(self): + if not self.username: + return None + return f"#{self._get_alias_localpart()}:{self.hs_domain}" + + def _get_alias_localpart(self, username=None): + username = username or self.username + if not username: + return None + return self.alias_template.format(groupname=username) async def sync_telegram_users(self, source, users): allowed_tgids = set() @@ -361,10 +370,10 @@ class Portal: async def update_username(self, username): if self.username != username: if self.username: - await self.main_intent.remove_room_alias(self._get_room_alias()) + 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_room_alias()) + await self.main_intent.add_room_alias(self.mxid, self._get_alias_localpart()) await self.main_intent.set_join_rule(self.mxid, "public") else: await self.main_intent.set_join_rule(self.mxid, "invite") @@ -1156,7 +1165,7 @@ class Portal: return None @classmethod - def get_by_entity(cls, entity, receiver_id=None): + def get_by_entity(cls, entity, receiver_id=None, create=True): entity_type = type(entity) if entity_type in {Chat, ChatFull}: type_name = "chat" @@ -1178,7 +1187,9 @@ class Portal: id = entity.user_id else: raise ValueError(f"Unknown entity type {entity_type.__name__}") - return cls.get_by_tgid(id, receiver_id if type_name == "user" else id, type_name) + return cls.get_by_tgid(id, + receiver_id if type_name == "user" else id, + type_name if create else None) # endregion diff --git a/mautrix_telegram/tgclient.py b/mautrix_telegram/tgclient.py index a137ce9f..089cd7c0 100644 --- a/mautrix_telegram/tgclient.py +++ b/mautrix_telegram/tgclient.py @@ -22,6 +22,9 @@ from telethon.tl.types import * class MautrixTelegramClient(TelegramClient): + def send_message_super(self, *args, **kwargs): + return super().send_message(*args, **kwargs) + async def send_message(self, entity, message, reply_to=None, entities=None, link_preview=True): entity = await self.get_input_entity(entity) diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index c7406159..576108f2 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -62,8 +62,6 @@ class User(AbstractUser): if tgid: self.by_tgid[tgid] = self - self._init_client() - @property def name(self): return self.mxid From f635d87ea3087d2159ce2ff9d311065f1020a125 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 22 Feb 2018 21:59:36 +0200 Subject: [PATCH 2/6] Update Telethon to fix mention generation in markdown parser --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 7c64d91c..de030188 100644 --- a/setup.py +++ b/setup.py @@ -27,9 +27,9 @@ setuptools.setup( "python-magic>=0.4.15,<0.5", ], dependency_links=[ - ("https://github.com/LonamiWebs/Telethon/tarball/6e854325a8e0e800a4f337257293d09006946162#egg=Telethon" + ("https://github.com/LonamiWebs/Telethon/tarball/7998fd59f709ae1cd959c5cc4ab107982307f4a6#egg=Telethon" if sys.version_info > (3, 5) - else "https://github.com/tulir/Telethon/tarball/24dc21aea3305ef3bb8c7fcaef2025ae65d5c85e#egg=Telethon") + else "https://github.com/tulir/Telethon/tarball/ca08fe28800d74fd6c19fd6f473e12fbf2c258de#egg=Telethon") ], classifiers=[ From fb37af12b48dd6978fda94ee5c218b0e709fe5cd Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 22 Feb 2018 22:09:35 +0200 Subject: [PATCH 3/6] Fix bugs in command handlers and split them to separate methods --- mautrix_telegram/bot.py | 59 ++++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/mautrix_telegram/bot.py b/mautrix_telegram/bot.py index 17a6b4aa..0b3f5146 100644 --- a/mautrix_telegram/bot.py +++ b/mautrix_telegram/bot.py @@ -15,6 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . import logging +import re from telethon.tl.types import * from telethon.errors import ChannelInvalidError, ChannelPrivateError @@ -30,6 +31,7 @@ config = None class Bot(AbstractUser): log = logging.getLogger("mau.bot") + mxid_regex = re.compile("@.+:.+") def __init__(self, token): super().__init__() @@ -87,6 +89,35 @@ class Bot(AbstractUser): self.db.delete(BotChat.query.get(id)) self.db.commit() + async def handle_command_portal(self, portal, reply): + await portal.create_matrix_room(self) + if portal.mxid: + if portal.username: + return await reply( + f"Portal is public: [portal.alias](https://matrix.to/#/{portal.alias})") + else: + return await reply( + "Portal is not public. Use `/invite ` to get an invite.") + + async def handle_command_invite(self, portal, reply, mxid): + if len(mxid) == 0: + return await reply("Usage: `/invite `") + elif not portal.mxid: + return await reply("Portal does not have Matrix room. " + "Create one with /portal first.") + if not self.mxid_regex.match(mxid): + return await reply("That doesn't look like a Matrix ID.") + user = await u.User.get_by_mxid(mxid).ensure_started() + if not user.whitelisted: + return await reply("That user is not whitelisted to use the bridge.") + elif user.logged_in: + displayname = f"@{user.username}" if user.username else user.displayname + return await reply("That user seems to be logged in. " + f"Just invite [{displayname}](tg://user?id={user.tgid})") + else: + await portal.main_intent.invite(portal.mxid, user.mxid) + return await reply(f"Invited `{user.mxid}` to the portal.") + async def handle_command(self, message): def reply(reply_text): return self.client.send_message_super(message.to_id, reply_text) @@ -94,38 +125,16 @@ class Bot(AbstractUser): text = message.message portal = po.Portal.get_by_entity(message.to_id) if text == "/portal": - await portal.create_matrix_room(self) - if portal.mxid: - if portal.username: - return await reply( - f"Portal is public: [portal.alias](https://matrix.to/#/{portal.alias})") - else: - return await reply( - "Portal is not public. Use `/invite ` to get an invite.") + await self.handle_command_portal(portal, reply) elif text.startswith("/invite"): - mxid = text[len("/invite "):] - if len(mxid) == 0: - return await reply("Usage: `/invite `") - elif not portal.mxid: - return await reply("Portal does not have Matrix room. " - "Create one with /portal first.") - user = await u.User.get_by_mxid(mxid).ensure_started() - if not user.whitelisted: - return await reply("That user is not whitelisted to use the bridge.") - elif user.logged_in: - displayname = f"@{user.username}" if user.username else user.displayname - return await reply("That user seems to be logged in. " - f"Just invite [{displayname}](tg://user?id={user.tgid})") - else: - await portal.main_intent.invite(portal.mxid, user.mxid) - return await reply(f"Invited `{user.mxid}` to the portal.") + await self.handle_command_invite(portal, reply, mxid=text[len("/invite "):]) async def update(self, update): if not isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)): return is_command = (isinstance(update.message, Message) - and len(update.message.entities) > 0 + and update.message.entities and len(update.message.entities) > 0 and isinstance(update.message.entities[0], MessageEntityBotCommand)) if is_command: return await self.handle_command(update.message) From 2d2fe86757896ef3c18ba7f2ed288e58504e7043 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 23 Feb 2018 12:07:42 +0200 Subject: [PATCH 4/6] Move all permissions to single object in config --- example-config.yaml | 28 ++++++++++------ mautrix_telegram/__main__.py | 1 + mautrix_telegram/config.py | 62 ++++++++++++++++++++++++++++++++++++ mautrix_telegram/matrix.py | 6 ++-- mautrix_telegram/portal.py | 2 +- mautrix_telegram/user.py | 10 ++---- 6 files changed, 88 insertions(+), 21 deletions(-) diff --git a/example-config.yaml b/example-config.yaml index ee68b25d..7ad0c7a6 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -87,20 +87,26 @@ bridge: # Allow logging in within Matrix. If false, the only way to log in is using the out-of-Matrix # login website (see appservice.public config section) allow_matrix_login: true + # Whether or not to allow creating portals from Telegram. + authless_relaybot_portals: true # The prefix for commands. Only required in non-management rooms. command_prefix: "!tg" - # Whitelist of user IDs that are allowed to use this bridge. Leave empty to disable. - # You can enter a domain without the localpart to allow all users from that homeserver to use the bridge. - whitelist: - - "internal.example.com" - - "@user:public.example.com" - - # Admins can do things like delete portal rooms. Here you must specify the exact MXID, domains - # are not accepted. - admins: - - "@admin:internal.example.com" + # Permissions for using the bridge. + # Permitted values: + # relaybot - Only use the bridge via the relaybot, no access to commands. + # full - Full access to use the bridge via relaybot or logging in with Telegram account. + # admin - Full access to use the bridge and some extra administration commands. + # Permitted keys: + # * - All Matrix users + # domain - All users on that homeserver + # mxid - Specific user + permissions: + "*": "relaybot" + "example.com": "full" + "public.example.com": "full" + "@admin:example.com": "admin" # Telegram config telegram: @@ -109,3 +115,5 @@ telegram: api_hash: tjyd5yge35lbodk1xwzw2jstp90k55qz # (Optional) Create your own bot at https://t.me/BotFather #bot_token: 123456789:ABCD-QBPd3VrWRhg623xYh07WUWErYA9eMI + +version: 1 diff --git a/mautrix_telegram/__main__.py b/mautrix_telegram/__main__.py index 2f01e1ac..9761db8c 100644 --- a/mautrix_telegram/__main__.py +++ b/mautrix_telegram/__main__.py @@ -56,6 +56,7 @@ args = parser.parse_args() config = Config(args.config, args.registration) config.load() +config.check_updates() if args.generate_registration: config.generate_registration() diff --git a/mautrix_telegram/config.py b/mautrix_telegram/config.py index ef93094e..6e2e8f8e 100644 --- a/mautrix_telegram/config.py +++ b/mautrix_telegram/config.py @@ -19,6 +19,7 @@ import random import string yaml = YAML() +yaml.indent(4) class DictWithRecursion: @@ -59,6 +60,31 @@ class DictWithRecursion: def __setitem__(self, key, value): self.set(key, value) + def _recursive_del(self, data, key): + if '.' in key: + key, next_key = key.split('.', 1) + if key not in data: + return + next_data = data[key] + self._recursive_del(next_data, next_key) + return + try: + del data[key] + except KeyError: + pass + + def delete(self, key, allow_recursion=True): + if allow_recursion and '.' in key: + self._recursive_del(self._data, key) + return + try: + del self._data[key] + except KeyError: + pass + + def __delitem__(self, key): + self.delete(key) + class Config(DictWithRecursion): def __init__(self, path, registration_path): @@ -82,6 +108,42 @@ class Config(DictWithRecursion): def _new_token(): return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(64)) + def update_0_1(self): + permissions = self["bridge.permissions"] or {} + for entry in self["bridge.whitelist"] or []: + permissions[entry] = "full" + for entry in self["bridge.admins"] or []: + permissions[entry] = "admin" + self["bridge.permissions"] = permissions + del self["bridge.whitelist"] + del self["bridge.admins"] + self["version"] = 1 + + def check_updates(self): + if self.get("version", 0) == 0: + self.update_0_1() + else: + return + self.save() + + def _get_permissions(self, key): + level = self["bridge.permissions"].get(key, "") + admin = level == "admin" + whitelisted = level == "full" or admin + relaybot = level == "relaybot" or whitelisted + return relaybot, whitelisted, admin + + def get_permissions(self, mxid): + permissions = self["bridge.permissions"] or {} + if mxid in permissions: + return self._get_permissions(mxid) + + homeserver = mxid[mxid.index(":") + 1:] + if homeserver in permissions: + return self._get_permissions(homeserver) + + return self._get_permissions("*") + def generate_registration(self): homeserver = self["homeserver.domain"] diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index 25b2eb97..88c19c2f 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -119,7 +119,7 @@ class MatrixHandler: if not portal: return - if not user.whitelisted: + if not user.relaybot_whitelisted: await portal.main_intent.kick(room, user.mxid, "You are not whitelisted on this Telegram bridge.") return @@ -169,7 +169,7 @@ class MatrixHandler: is_command, text = self.is_command(message) sender = await User.get_by_mxid(sender).ensure_started() - if not sender.whitelisted: + if not sender.relaybot_whitelisted: return portal = Portal.get_by_mxid(room) @@ -177,7 +177,7 @@ class MatrixHandler: await portal.handle_matrix_message(sender, message, event_id) return - if message["msgtype"] != "m.text": + if not sender.whitelisted or message["msgtype"] != "m.text": return try: diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index c5bafc7c..deda5c78 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -462,7 +462,7 @@ class Portal: 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() - if (has_bot and user.whitelisted) or user.has_full_access: + if (has_bot and user.relaybot_whitelisted) or user.has_full_access: authenticated.append(user) return authenticated diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index 576108f2..605543b7 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -50,13 +50,9 @@ class User(AbstractUser): self.command_status = None - self.is_admin = self.mxid in config.get("bridge.admins", []) - - whitelist = config.get("bridge.whitelist", None) or [self.mxid] - self.whitelisted = not whitelist or self.mxid in whitelist - if not self.whitelisted: - homeserver = self.mxid[self.mxid.index(":") + 1:] - self.whitelisted = homeserver in whitelist + (self.relaybot_whitelisted, + self.whitelisted, + self.is_admin) = config.get_permissions(self.mxid) self.by_mxid[mxid] = self if tgid: From 6fced123b1538b5ccb39edec4d35adb44d3a8832 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 23 Feb 2018 17:40:15 +0200 Subject: [PATCH 5/6] Add comments in config updates --- example-config.yaml | 2 ++ mautrix_telegram/config.py | 48 ++++++++++++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/example-config.yaml b/example-config.yaml index 7ad0c7a6..2534728d 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -116,4 +116,6 @@ telegram: # (Optional) Create your own bot at https://t.me/BotFather #bot_token: 123456789:ABCD-QBPd3VrWRhg623xYh07WUWErYA9eMI +# The version of the config. The bridge will read this and automatically update the config if +# the schema has changed. For the latest version, check the example config. version: 1 diff --git a/mautrix_telegram/config.py b/mautrix_telegram/config.py index 6e2e8f8e..7bab5563 100644 --- a/mautrix_telegram/config.py +++ b/mautrix_telegram/config.py @@ -15,6 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . from ruamel.yaml import YAML +from ruamel.yaml.comments import CommentedMap import random import string @@ -24,12 +25,12 @@ yaml.indent(4) class DictWithRecursion: def __init__(self, data=None): - self._data = data or {} + self._data = data or CommentedMap() def _recursive_get(self, data, key, default_value): if '.' in key: key, next_key = key.split('.', 1) - next_data = data.get(key, {}) + next_data = data.get(key, CommentedMap()) return self._recursive_get(next_data, next_key, default_value) return data.get(key, default_value) @@ -45,8 +46,8 @@ class DictWithRecursion: if '.' in key: key, next_key = key.split('.', 1) if key not in data: - data[key] = {} - next_data = data.get(key, {}) + data[key] = CommentedMap() + next_data = data.get(key, CommentedMap()) self._recursive_set(next_data, next_key, value) return data[key] = value @@ -85,6 +86,17 @@ class DictWithRecursion: def __delitem__(self, key): self.delete(key) + def comment(self, key, message): + indent = key.count(".") * 4 + try: + path, key = key.rsplit(".", 1) + except ValueError: + path = None + entry = self[path] if path else self._data + c = self._data.ca.items.setdefault(key, [None, [], None, None]) + c[1] = [] + entry.yaml_set_comment_before_after_key(key=key, before=message, indent=indent) + class Config(DictWithRecursion): def __init__(self, path, registration_path): @@ -109,15 +121,41 @@ class Config(DictWithRecursion): return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(64)) def update_0_1(self): - permissions = self["bridge.permissions"] or {} + permissions = self["bridge.permissions"] or CommentedMap() for entry in self["bridge.whitelist"] or []: permissions[entry] = "full" for entry in self["bridge.admins"] or []: permissions[entry] = "admin" + self["bridge.permissions"] = permissions del self["bridge.whitelist"] del self["bridge.admins"] + + self["bridge.authless_relaybot_portals"] = self.get("bridge.authless_relaybot_portals", + True) + self.comment("bridge.authless_relaybot_portals", + "Whether or not to allow creating portals from Telegram.") + + self.comment("bridge.permissions", "\n".join(( + "", + "Permissions for using the bridge.", + "Permitted values:", + " relaybot - Only use the bridge via the relaybot, no access to commands.", + " full - Full access to use the bridge via relaybot or logging in with Telegram account.", + " admin - Full access to use the bridge and some extra administration commands.", + "Permitted keys:", + " * - All Matrix users", + " domain - All users on that homeserver", + " mxid - Specific user"))) + # The telegram section comment disappears for some reason 3: + self.comment("telegram", "\nTelegram config") + self["version"] = 1 + # Add newline before version + self.comment("version", + "\nThe version of the config. The bridge will read this and automatically " + "update the config if\nthe schema has changed. For the latest version, " + "check the example config.") def check_updates(self): if self.get("version", 0) == 0: From a1ba82c3b7a76ed11c74eebd8c060c1a0ada5718 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 23 Feb 2018 18:01:37 +0200 Subject: [PATCH 6/6] Actually use bridge.authless_relaybot_portals somewhere --- mautrix_telegram/bot.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mautrix_telegram/bot.py b/mautrix_telegram/bot.py index 0b3f5146..020c12ee 100644 --- a/mautrix_telegram/bot.py +++ b/mautrix_telegram/bot.py @@ -90,11 +90,14 @@ class Bot(AbstractUser): self.db.commit() async def handle_command_portal(self, portal, reply): + if not config["bridge.authless_relaybot_portals"]: + return await reply("This bridge doesn't allow portal creation/invites from Telegram.") + await portal.create_matrix_room(self) if portal.mxid: if portal.username: return await reply( - f"Portal is public: [portal.alias](https://matrix.to/#/{portal.alias})") + f"Portal is public: [{portal.alias}](https://matrix.to/#/{portal.alias})") else: return await reply( "Portal is not public. Use `/invite ` to get an invite.")