From b6d1f3b403b0046485be31ed8802bbf8fe4f922f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 12 Feb 2018 21:12:49 +0200 Subject: [PATCH 01/10] Update roadmap --- ROADMAP.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3bec6ef4..89b1763d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -77,17 +77,19 @@ * [ ] Option to use bot to relay messages for unauthenticated Matrix users * [ ] Option to use own Matrix account for messages sent from other Telegram clients * [Commands](https://github.com/tulir/mautrix-telegram/wiki/Management-commands) - * [x] Logging in and out (`login` + code entering, `logout`) + * [x] Logging in and out (`login` + code entering) + * [x] Logging out * [ ] Registering (`register`) * [x] Searching for users (`search`) - * [ ] Searching contacts locally * [x] Starting private chats (`pm`) * [x] Joining chats with invite links (`join`) * [x] Creating a Telegram chat for an existing Matrix room (`create`) * [x] Upgrading the chat of a portal room into a supergroup (`upgrade`) * [x] Change username of supergroup/channel (`groupname`) * [x] Getting the Telegram invite link to a Matrix room (`invitelink`) - * [x] Clean up and forget a portal room (`deleteportal`) + * Bridge administration + * [x] Clean up and forget a portal room (`deleteportal`) + * [ ] Setting Matrix-only power levels (`powerlevel`) † Information not automatically sent from source, i.e. implementation may not be possible ‡ Maybe, i.e. this feature may or may not be implemented at some point From e42fcd2fb393ee891367a1b77d80f5daa68274ae Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 13 Feb 2018 00:08:21 +0200 Subject: [PATCH 02/10] Move command handlers to own module and add clean-rooms command (ref #59) --- mautrix_telegram/commands.py | 523 ----------------------- mautrix_telegram/commands/__init__.py | 2 + mautrix_telegram/commands/auth.py | 118 +++++ mautrix_telegram/commands/clean_rooms.py | 161 +++++++ mautrix_telegram/commands/handler.py | 114 +++++ mautrix_telegram/commands/meta.py | 74 ++++ mautrix_telegram/commands/telegram.py | 260 +++++++++++ mautrix_telegram/portal.py | 26 ++ mautrix_telegram/user.py | 22 +- 9 files changed, 766 insertions(+), 534 deletions(-) delete mode 100644 mautrix_telegram/commands.py create mode 100644 mautrix_telegram/commands/__init__.py create mode 100644 mautrix_telegram/commands/auth.py create mode 100644 mautrix_telegram/commands/clean_rooms.py create mode 100644 mautrix_telegram/commands/handler.py create mode 100644 mautrix_telegram/commands/meta.py create mode 100644 mautrix_telegram/commands/telegram.py diff --git a/mautrix_telegram/commands.py b/mautrix_telegram/commands.py deleted file mode 100644 index e9555b41..00000000 --- a/mautrix_telegram/commands.py +++ /dev/null @@ -1,523 +0,0 @@ -# mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2018 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -import markdown -import logging -import asyncio - -from mautrix_appservice import MatrixRequestError - -from telethon.errors import * -from telethon.tl.types import * -from telethon.tl.functions.messages import ImportChatInviteRequest, CheckChatInviteRequest -from telethon.tl.functions.channels import JoinChannelRequest - -from . import puppet as pu, portal as po - -command_handlers = {} - - -def command_handler(func): - command_handlers[func.__name__] = func - return func - - -def format_duration(seconds): - def pluralize(count, singular): return singular if count == 1 else singular + "s" - - def include(count, word): return f"{count} {pluralize(count, word)}" if count > 0 else "" - - minutes, seconds = divmod(seconds, 60) - hours, minutes = divmod(minutes, 60) - days, hours = divmod(hours, 24) - parts = [a for a in [ - include(days, "day"), - include(hours, "hour"), - include(minutes, "minute"), - include(seconds, "second")] if a] - if len(parts) > 2: - return "{} and {}".format(", ".join(parts[:-1]), parts[-1]) - return " and ".join(parts) - - -class CommandEvent: - def __init__(self, az, command_prefix, room, sender, args, is_management, is_portal): - self.az = az - self.command_prefix = command_prefix - self.room_id = room - self.sender = sender - self.args = args - self.is_management = is_management - self.is_portal = is_portal - - def reply(self, message, allow_html=False, render_markdown=True): - if not self.room_id: - raise AttributeError("the reply function can only be used from within" - "the `CommandHandler.run` context manager") - - message = message.replace("$cmdprefix+sp ", - "" if self.is_management else f"{self.command_prefix} ") - message = message.replace("$cmdprefix", self.command_prefix) - html = None - if render_markdown: - html = markdown.markdown(message, safe_mode="escape" if allow_html else False) - elif allow_html: - html = message - return self.az.intent.send_notice(self.room_id, message, html=html) - - -class CommandHandler: - log = logging.getLogger("mau.commands") - - def __init__(self, context): - self.az, self.db, self.config, self.loop = context - self.command_prefix = self.config["bridge.command_prefix"] - - # region Utility functions for handling commands - - async def handle(self, room, sender, command, args, is_management, is_portal): - evt = CommandEvent(self.az, self.command_prefix, room, sender, args, is_management, - is_portal) - command = command.lower() - try: - command = command_handlers[command] - except KeyError: - if sender.command_status and "next" in sender.command_status: - args.insert(0, command) - command = sender.command_status["next"] - else: - command = command_handlers["unknown_command"] - try: - await command(self, evt) - except FloodWaitError as e: - return evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}") - except Exception: - self.log.exception(f"Fatal error handling command " - + f"'$cmdprefix {command} {''.join(args)}' from {sender.mxid}") - return evt.reply("Fatal error while handling command. Check logs for more details.") - - # endregion - # region Command handlers - - @command_handler - async def ping(self, evt): - if not evt.sender.logged_in: - return await evt.reply("You're not logged in.") - me = await evt.sender.client.get_me() - if me: - return await evt.reply(f"You're logged in as @{me.username}") - else: - return await evt.reply("You're not logged in.") - - # region Authentication commands - @command_handler - def register(self, evt): - return evt.reply("Not yet implemented.") - - @command_handler - async def login(self, evt): - if not evt.is_management: - return await evt.reply( - "`login` is a restricted command: you may only run it in management rooms.") - elif evt.sender.logged_in: - return await evt.reply("You are already logged in.") - elif len(evt.args) == 0: - return await evt.reply("**Usage:** `$cmdprefix+sp login `") - phone_number = evt.args[0] - await evt.sender.client.sign_in(phone_number) - evt.sender.command_status = { - "next": command_handlers["enter_code"], - "action": "Login", - } - return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.") - - @command_handler - async def enter_code(self, evt): - if not evt.sender.command_status: - return await evt.reply( - "Request a login code first with `$cmdprefix+sp login `") - elif len(evt.args) == 0: - return await evt.reply("**Usage:** `$cmdprefix+sp enter_code `") - - try: - user = await evt.sender.client.sign_in(code=evt.args[0]) - asyncio.ensure_future(evt.sender.post_login(user), loop=self.loop) - evt.sender.command_status = None - return await evt.reply(f"Successfully logged in as @{user.username}") - except PhoneNumberUnoccupiedError: - return await evt.reply("That phone number has not been registered." - "Please register with `$cmdprefix+sp register `.") - except PhoneCodeExpiredError: - return await evt.reply( - "Phone code expired. Try again with `$cmdprefix+sp login `.") - except PhoneCodeInvalidError: - return await evt.reply("Invalid phone code.") - except PhoneNumberAppSignupForbiddenError: - return await evt.reply( - "Your phone number does not allow 3rd party apps to sign in.") - except PhoneNumberFloodError: - return await evt.reply( - "Your phone number has been temporarily blocked for flooding. " - "The block is usually applied for around a day.") - except PhoneNumberBannedError: - return await evt.reply("Your phone number has been banned from Telegram.") - except SessionPasswordNeededError: - evt.sender.command_status = { - "next": command_handlers["enter_password"], - "action": "Login (password entry)", - } - return await evt.reply("Your account has two-factor authentication." - "Please send your password here.") - except Exception: - self.log.exception("Error sending phone code") - return await evt.reply("Unhandled exception while sending code." - "Check console for more details.") - - @command_handler - async def enter_password(self, evt): - if not evt.sender.command_status: - return await evt.reply( - "Request a login code first with `$cmdprefix+sp login `") - elif len(evt.args) == 0: - return await evt.reply("**Usage:** `$cmdprefix+sp enter_password `") - - try: - user = await evt.sender.client.sign_in(password=evt.args[0]) - asyncio.ensure_future(evt.sender.post_login(user), loop=self.loop) - evt.sender.command_status = None - return await evt.reply(f"Successfully logged in as @{user.username}") - except PasswordHashInvalidError: - return await evt.reply("Incorrect password.") - except Exception: - self.log.exception("Error sending password") - return await evt.reply("Unhandled exception while sending password. " - "Check console for more details.") - - @command_handler - async def logout(self, evt): - if not evt.sender.logged_in: - return await evt.reply("You're not logged in.") - if await evt.sender.log_out(): - return await evt.reply("Logged out successfully.") - return await evt.reply("Failed to log out.") - - # endregion - # region Telegram interaction commands - - @command_handler - async def search(self, evt): - if len(evt.args) == 0: - return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] `") - elif not evt.sender.logged_in: - return await evt.reply("This command requires you to be logged in.") - - force_remote = False - if evt.args[0] in {"-r", "--remote"}: - force_remote = True - evt.args.pop(0) - - query = " ".join(evt.args) - if force_remote and len(query) < 5: - return await evt.reply("Minimum length of query for remote search is 5 characters.") - - results, remote = await evt.sender.search(query, force_remote) - - if not results: - if len(query) < 5 and remote: - return await evt.reply("No local results. " - "Minimum length of remote query is 5 characters.") - return await evt.reply("No results 3:") - - reply = [] - if remote: - reply += ["**Results from Telegram server:**", ""] - else: - reply += ["**Results in contacts:**", ""] - reply += [(f"* [{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): " - + f"{puppet.id} ({similarity}% match)") - for puppet, similarity in results] - - # TODO somehow show remote channel results when joining by alias is possible? - - return await evt.reply("\n".join(reply)) - - @command_handler - async def pm(self, evt): - if len(evt.args) == 0: - return await evt.reply("**Usage:** `$cmdprefix+sp pm `") - elif not evt.sender.logged_in: - return await evt.reply("This command requires you to be logged in.") - - user = await evt.sender.client.get_entity(evt.args[0]) - if not user: - return await evt.reply("User not found.") - elif not isinstance(user, User): - return await evt.reply("That doesn't seem to be a user.") - portal = po.Portal.get_by_entity(user, evt.sender.tgid) - await portal.create_matrix_room(evt.sender, user, [evt.sender.mxid]) - return await evt.reply( - f"Created private chat room with {pu.Puppet.get_displayname(user, False)}") - - @command_handler - async def invitelink(self, evt): - if not evt.sender.logged_in: - return await evt.reply("This command requires you to be logged in.") - - portal = po.Portal.get_by_mxid(evt.room_id) - if not portal: - return await evt.reply("This is not a portal room.") - - if portal.peer_type == "user": - return await evt.reply("You can't invite users to private chats.") - - try: - link = await portal.get_invite_link(evt.sender) - return await evt.reply(f"Invite link to {portal.title}: {link}") - except ValueError as e: - return await evt.reply(e.args[0]) - except ChatAdminRequiredError: - return await evt.reply("You don't have the permission to create an invite link.") - - @command_handler - async def deleteportal(self, evt): - if not evt.sender.logged_in: - return await evt.reply("This command requires you to be logged in.") - elif not evt.sender.is_admin: - return await evt.reply("This is command requires administrator privileges.") - - portal = po.Portal.get_by_mxid(evt.room_id) - if not portal: - return await evt.reply("This is not a portal room.") - - for user in await portal.main_intent.get_room_members(portal.mxid): - if user != portal.main_intent.mxid: - try: - await portal.main_intent.kick(portal.mxid, user, "Portal deleted.") - except MatrixRequestError: - pass - await portal.main_intent.leave_room(portal.mxid) - portal.delete() - - @staticmethod - def _strip_prefix(value, prefixes): - for prefix in prefixes: - if value.startswith(prefix): - return value[len(prefix):] - return value - - @command_handler - async def join(self, evt): - if len(evt.args) == 0: - return await evt.reply("**Usage:** `$cmdprefix+sp join `") - elif not evt.sender.logged_in: - return await evt.reply("This command requires you to be logged in.") - - regex = re.compile(r"(?:https?://)?t(?:elegram)?\.(?:dog|me)(?:joinchat/)?/(.+)") - arg = regex.match(evt.args[0]) - if not arg: - return await evt.reply("That doesn't look like a Telegram invite link.") - arg = arg.group(1) - if arg.startswith("joinchat/"): - invite_hash = arg[len("joinchat/"):] - try: - await evt.sender.client(CheckChatInviteRequest(invite_hash)) - except InviteHashInvalidError: - return await evt.reply("Invalid invite link.") - except InviteHashExpiredError: - return await evt.reply("Invite link expired.") - try: - updates = evt.sender.client(ImportChatInviteRequest(invite_hash)) - except UserAlreadyParticipantError: - return await evt.reply("You are already in that chat.") - else: - channel = await evt.sender.client.get_entity(arg) - if not channel: - return await evt.reply("Channel/supergroup not found.") - updates = await evt.sender.client(JoinChannelRequest(channel)) - for chat in updates.chats: - portal = po.Portal.get_by_entity(chat) - if portal.mxid: - await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid]) - return await evt.reply(f"Created room for {portal.title}") - else: - await portal.invite_matrix([evt.sender.mxid]) - return await evt.reply(f"Invited you to portal of {portal.title}") - - @command_handler - async def create(self, evt): - type = evt.args[0] if len(evt.args) > 0 else "group" - if type not in {"chat", "group", "supergroup", "channel"}: - return await evt.reply( - "**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`") - elif not evt.sender.logged_in: - return await evt.reply("This command requires you to be logged in.") - - if po.Portal.get_by_mxid(evt.room_id): - return await evt.reply("This is already a portal room.") - - state = await self.az.intent.get_room_state(evt.room_id) - title = None - about = None - levels = None - for event in state: - if event["type"] == "m.room.name": - title = event["content"]["name"] - elif event["type"] == "m.room.topic": - about = event["content"]["topic"] - elif event["type"] == "m.room.power_levels": - levels = event["content"] - if not title: - return await evt.reply("Please set a title before creating a Telegram chat.") - elif (not levels or not levels["users"] or self.az.intent.mxid not in levels["users"] or - levels["users"][self.az.intent.mxid] < 100): - return await evt.reply(f"Please give " - + f"[the bridge bot](https://matrix.to/#/{self.az.intent.mxid})" - + f" a power level of 100 before creating a Telegram chat.") - else: - for user, level in levels["users"].items(): - if level >= 100 and user != self.az.intent.mxid: - return await evt.reply( - f"Please make sure only the bridge bot has power level above" - + f"99 before creating a Telegram chat.\n\n" - + f"Use power level 95 instead of 100 for admins.") - - supergroup = type == "supergroup" - type = { - "supergroup": "channel", - "channel": "channel", - "chat": "chat", - "group": "chat", - }[type] - - portal = po.Portal(tgid=None, mxid=evt.room_id, title=title, about=about, peer_type=type) - try: - await portal.create_telegram_chat(evt.sender, supergroup=supergroup) - except ValueError as e: - return await evt.reply(e.args[0]) - return await evt.reply(f"Telegram chat created. ID: {portal.tgid}") - - @command_handler - async def upgrade(self, evt): - if not evt.sender.logged_in: - return await evt.reply("This command requires you to be logged in.") - - portal = po.Portal.get_by_mxid(evt.room_id) - if not portal: - return await evt.reply("This is not a portal room.") - elif portal.peer_type == "channel": - return await evt.reply("This is already a supergroup or a channel.") - elif portal.peer_type == "user": - return await evt.reply("You can't upgrade private chats.") - - try: - await portal.upgrade_telegram_chat(evt.sender) - return await evt.reply(f"Group upgraded to supergroup. New ID: {portal.tgid}") - except ChatAdminRequiredError: - return await evt.reply("You don't have the permission to upgrade this group.") - except ValueError as e: - return await evt.reply(e.args[0]) - - @command_handler - async def groupname(self, evt): - if len(evt.args) == 0: - return await evt.reply("**Usage:** `$cmdprefix+sp groupname `") - if not evt.sender.logged_in: - return await evt.reply("This command requires you to be logged in.") - - portal = po.Portal.get_by_mxid(evt.room_id) - if not portal: - return await evt.reply("This is not a portal room.") - elif portal.peer_type != "channel": - return await evt.reply("Only channels and supergroups have usernames.") - - try: - await portal.set_telegram_username(evt.sender, - evt.args[0] if evt.args[0] != "-" else "") - if portal.username: - return await evt.reply(f"Username of channel changed to {portal.username}.") - else: - return await evt.reply(f"Channel is now private.") - except ChatAdminRequiredError: - return await evt.reply( - "You don't have the permission to set the username of this channel.") - except UsernameNotModifiedError: - if portal.username: - return await evt.reply("That is already the username of this channel.") - else: - return await evt.reply("This channel is already private") - except UsernameOccupiedError: - return await evt.reply("That username is already in use.") - except UsernameInvalidError: - return await evt.reply("Invalid username") - - # endregion - # region Command-related commands - @command_handler - def cancel(self, evt): - if evt.sender.command_status: - action = evt.sender.command_status["action"] - evt.sender.command_status = None - return evt.reply(f"{action} cancelled.") - else: - return evt.reply("No ongoing command.") - - @command_handler - def unknown_command(self, evt): - return evt.reply("Unknown command. Try `$cmdprefix+sp help` for help.") - - @command_handler - def help(self, evt): - if evt.is_management: - management_status = ("This is a management room: prefixing commands " - "with `$cmdprefix` is not required.\n") - elif evt.is_portal: - management_status = ("**This is a portal room**: you must always " - "prefix commands with `$cmdprefix`.\n" - "Management commands will not be sent to Telegram.") - else: - management_status = ("**This is not a management room**: you must " - "prefix commands with `$cmdprefix`.\n") - help = """\n -#### Generic bridge commands -**help** - Show this help message. -**cancel** - Cancel an ongoing action (such as login). - -#### Authentication -**login** <_phone_> - Request an authentication code. -**logout** - Log out from Telegram. -**ping** - Check if you're logged into Telegram. - -#### Initiating chats -**search** [_-r|--remote_] <_query_> - Search your contacts or the Telegram servers for users. -**pm** <_identifier_> - Open a private chat with the given Telegram user. The - identifier is either the internal user ID, the username or - the phone number. -**join** <_link_> - Join a chat with an invite link. -**create** [_type_] - Create a Telegram chat of the given type for the current - Matrix room. The type is either `group`, `supergroup` or - `channel` (defaults to `group`). - -#### Portal management -**upgrade** - Upgrade a normal Telegram group to a supergroup. -**invitelink** - Get a Telegram invite link to the current chat. -**deleteportal** - Forget the current portal room. Only works for group chats; to delete - a private chat portal, simply leave the room. -**groupname** <_name_|`-`> - Change the username of a supergroup/channel. To disable, use a dash - (`-`) as the name. -""" - return evt.reply(management_status + help) - - # endregion - # endregion diff --git a/mautrix_telegram/commands/__init__.py b/mautrix_telegram/commands/__init__.py new file mode 100644 index 00000000..3e456c9b --- /dev/null +++ b/mautrix_telegram/commands/__init__.py @@ -0,0 +1,2 @@ +from .handler import command_handler, CommandHandler +from . import clean_rooms, auth, meta, telegram diff --git a/mautrix_telegram/commands/auth.py b/mautrix_telegram/commands/auth.py new file mode 100644 index 00000000..ceaeeca5 --- /dev/null +++ b/mautrix_telegram/commands/auth.py @@ -0,0 +1,118 @@ +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +import asyncio + +from telethon.errors import * + +from . import command_handler + + +@command_handler(needs_auth=False) +async def ping(evt): + if not evt.sender.logged_in: + return await evt.reply("You're not logged in.") + me = await evt.sender.client.get_me() + if me: + return await evt.reply(f"You're logged in as @{me.username}") + else: + return await evt.reply("You're not logged in.") + + +@command_handler(needs_auth=False, management_only=True) +def register(evt): + return evt.reply("Not yet implemented.") + + +@command_handler(needs_auth=False, management_only=True) +async def login(evt): + if evt.sender.logged_in: + return await evt.reply("You are already logged in.") + elif len(evt.args) == 0: + return await evt.reply("**Usage:** `$cmdprefix+sp login `") + phone_number = evt.args[0] + await evt.sender.client.sign_in(phone_number) + evt.sender.command_status = { + "next": enter_code, + "action": "Login", + } + return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.") + + +@command_handler(needs_auth=False) +async def enter_code(evt): + if len(evt.args) == 0: + return await evt.reply("**Usage:** `$cmdprefix+sp enter-code `") + + try: + user = await evt.sender.client.sign_in(code=evt.args[0]) + asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop) + evt.sender.command_status = None + return await evt.reply(f"Successfully logged in as @{user.username}") + except PhoneNumberUnoccupiedError: + return await evt.reply("That phone number has not been registered." + "Please register with `$cmdprefix+sp register `.") + except PhoneCodeExpiredError: + return await evt.reply( + "Phone code expired. Try again with `$cmdprefix+sp login `.") + except PhoneCodeInvalidError: + return await evt.reply("Invalid phone code.") + except PhoneNumberAppSignupForbiddenError: + return await evt.reply( + "Your phone number does not allow 3rd party apps to sign in.") + except PhoneNumberFloodError: + return await evt.reply( + "Your phone number has been temporarily blocked for flooding. " + "The block is usually applied for around a day.") + except PhoneNumberBannedError: + return await evt.reply("Your phone number has been banned from Telegram.") + except SessionPasswordNeededError: + evt.sender.command_status = { + "next": enter_password, + "action": "Login (password entry)", + } + return await evt.reply("Your account has two-factor authentication." + "Please send your password here.") + except Exception: + evt.log.exception("Error sending phone code") + return await evt.reply("Unhandled exception while sending code." + "Check console for more details.") + + +@command_handler(needs_auth=False) +async def enter_password(evt): + if len(evt.args) == 0: + return await evt.reply("**Usage:** `$cmdprefix+sp enter-password `") + + try: + user = await evt.sender.client.sign_in(password=evt.args[0]) + asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop) + evt.sender.command_status = None + return await evt.reply(f"Successfully logged in as @{user.username}") + except PasswordHashInvalidError: + return await evt.reply("Incorrect password.") + except Exception: + evt.log.exception("Error sending password") + return await evt.reply("Unhandled exception while sending password. " + "Check console for more details.") + + +@command_handler(needs_auth=False) +async def logout(evt): + if not evt.sender.logged_in: + return await evt.reply("You're not logged in.") + 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/clean_rooms.py b/mautrix_telegram/commands/clean_rooms.py new file mode 100644 index 00000000..05971af4 --- /dev/null +++ b/mautrix_telegram/commands/clean_rooms.py @@ -0,0 +1,161 @@ +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from . import command_handler +from .. import puppet as pu, portal as po + + +async def _find_rooms(intent): + management_rooms = [] + unidentified_rooms = [] + portals = [] + empty_portals = [] + + rooms = await intent.get_joined_rooms() + for room in rooms: + portal = po.Portal.get_by_mxid(room) + if not portal: + members = await intent.get_room_members(room) + if len(members) == 2: + other_member = members[0] if members[0] != intent.mxid else members[1] + if pu.Puppet.get_id_from_mxid(other_member): + unidentified_rooms.append(room) + else: + management_rooms.append((room, other_member)) + else: + unidentified_rooms.append(room) + else: + members = await portal.get_authenticated_matrix_users() + if len(members) == 0: + empty_portals.append(portal) + else: + portals.append(portal) + + return management_rooms, unidentified_rooms, portals, empty_portals + + +@command_handler(needs_admin=True, name="clean-rooms") +async def clean_rooms(evt): + if not evt.is_management: + return await evt.reply("`clean-rooms` is a particularly spammy command. Please don't " + "run it in non-management rooms.") + + management_rooms, unidentified_rooms, portals, empty_portals = await _find_rooms(evt.az.intent) + + reply = ["#### Management rooms (M)"] + reply += ([f"{n+1}. [M{n+1}](https://matrix.to/#/{room}) (with {other_member}" + for n, (room, other_member) in enumerate(management_rooms)] + or ["No management rooms found."]) + reply.append("#### Active portal rooms (A)") + reply += ([f"{n+1}. [P{n+1}](https://matrix.to/#/{portal.mxid}) " + + f"(to Telegram chat \"{portal.title}\")" + for n, portal in enumerate(portals)] + or ["No active portal rooms found."]) + reply.append("#### Unidentified rooms (U)") + reply += ([f"{n+1}. [U{n+1}](https://matrix.to/#/{room})" + for n, room in enumerate(unidentified_rooms)] + or ["No unidentified rooms found."]) + reply.append("#### Inactive portal rooms (I)") + reply += ([f"{n}. [E{n}](https://matrix.to/#/{portal.mxid}) " + + f"(to Telegram chat \"{portal.title}\")" + for n, portal in enumerate(empty_portals)] + or ["No inactive portal rooms found."]) + + reply += ["#### Usage", + ("To clean the recommended set of rooms (unidentified & inactive portals), " + "type `$cmdprefix+sp clean-recommended`"), + "", + ("To clean other groups of rooms, type `$cmdprefix+sp clean-groups ` " + "where `letters` are the first letters of the group names (M, A, U, I)"), + "", + ("To clean specific rooms, type `$cmdprefix+sp clean-range ` " + "where `range` is the range (e.g. `5-21`) prefixed with the first letter of" + "the group name."), + "", + ("Please note that you will have to re-run `$cmdprefix+sp cleanrooms` " + "between each use of the commands above.")] + + evt.sender.command_status = { + "next": lambda clean_evt: set_rooms_to_clean(clean_evt, management_rooms, + unidentified_rooms, portals, empty_portals), + "action": "Room cleaning", + } + + return await evt.reply("\n".join(reply)) + + +async def set_rooms_to_clean(evt, management_rooms, unidentified_rooms, portals, empty_portals): + command = evt.args[0] + rooms_to_clean = [] + if command == "clean-recommended": + rooms_to_clean = empty_portals + unidentified_rooms + elif command == "clean-groups": + if len(evt.args) < 2: + return await evt.reply("**Usage:** `$cmdprefix+sp clean-groups [M][A][U][I]") + groups_to_clean = evt.args[1] + if "M" in groups_to_clean: + rooms_to_clean += management_rooms + if "A" in groups_to_clean: + rooms_to_clean += portals + if "U" in groups_to_clean: + rooms_to_clean += unidentified_rooms + if "I" in groups_to_clean: + rooms_to_clean += empty_portals + elif command == "clean-range": + try: + range = evt.args[1] + group, range = range[0], range[1:] + start, end = range.split("-") + start, end = int(start), int(end) + if group == "M": + group = management_rooms + elif group == "A": + group = portals + elif group == "U": + group = unidentified_rooms + elif group == "I": + group = empty_portals + else: + raise ValueError("Unknown group") + rooms_to_clean = group[start - 1:end] + except (KeyError, ValueError): + return await evt.reply( + "**Usage:** `$cmdprefix+sp clean-groups <_M|A|U|I_>") + else: + return await evt.reply(f"Unknown room cleaning action `{command}`. " + + "Use `$cmdprefix+sp cancel` to cancel room " + + "cleaning.") + + evt.sender.command_status = { + "next": lambda confirm: execute_room_cleanup(confirm, rooms_to_clean), + "action": "Room cleaning", + } + await evt.reply(f"To confirm cleaning up {len(rooms_to_clean)} rooms, type" + + "`$cmdprefix+sp confirm-clean`.") + + +async def execute_room_cleanup(evt, rooms_to_clean): + if len(evt.args) > 0 and evt.args[0] == "confirm-clean": + await evt.reply(f"Cleaning {len(rooms_to_clean)} rooms. " + + "This might take a while.") + for room in rooms_to_clean: + if isinstance(room, po.Portal): + await room.cleanup_and_delete() + else: + await po.Portal.cleanup_room(evt.az.intent, room, type="Room") + evt.sender.command_status = None + await evt.reply(f"{len(rooms_to_clean)} rooms cleaned up successfully.") + else: + await evt.reply("Room cleaning cancelled.") diff --git a/mautrix_telegram/commands/handler.py b/mautrix_telegram/commands/handler.py new file mode 100644 index 00000000..798d9c2b --- /dev/null +++ b/mautrix_telegram/commands/handler.py @@ -0,0 +1,114 @@ +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +import markdown +import logging + +from telethon.errors import FloodWaitError + +command_handlers = {} + + +def command_handler(needs_auth=True, management_only=False, needs_admin=False, name=None): + def decorator(func): + def wrapper(evt): + if management_only and not evt.is_management: + return evt.reply(f"`{evt.command}` is a restricted command:" + + "you may only run it in management rooms.") + elif needs_auth and not evt.sender.logged_in: + return evt.reply("This command requires you to be logged in.") + elif needs_admin and not evt.sender.is_admin: + return evt.reply("This is command requires administrator privileges.") + return func(evt) + + command_handlers[name or func.__name__.replace("_", "-")] = wrapper + return wrapper + + return decorator + + +class CommandEvent: + def __init__(self, handler, room, sender, command, args, is_management, is_portal): + self.az = handler.az + self.log = handler.log + self.loop = handler.loop + self.command_prefix = handler.command_prefix + self.room_id = room + self.sender = sender + self.command = command + self.args = args + self.is_management = is_management + self.is_portal = is_portal + + def reply(self, message, allow_html=False, render_markdown=True): + message = message.replace("$cmdprefix+sp ", + "" if self.is_management else f"{self.command_prefix} ") + message = message.replace("$cmdprefix", self.command_prefix) + html = None + if render_markdown: + html = markdown.markdown(message, safe_mode="escape" if allow_html else False) + elif allow_html: + html = message + return self.az.intent.send_notice(self.room_id, message, html=html) + + +def format_duration(seconds): + def pluralize(count, singular): return singular if count == 1 else singular + "s" + + def include(count, word): return f"{count} {pluralize(count, word)}" if count > 0 else "" + + minutes, seconds = divmod(seconds, 60) + hours, minutes = divmod(minutes, 60) + days, hours = divmod(hours, 24) + parts = [a for a in [ + include(days, "day"), + include(hours, "hour"), + include(minutes, "minute"), + include(seconds, "second")] if a] + if len(parts) > 2: + return "{} and {}".format(", ".join(parts[:-1]), parts[-1]) + return " and ".join(parts) + + +class CommandHandler: + log = logging.getLogger("mau.commands") + + def __init__(self, context): + self.az, self.db, self.config, self.loop = context + self.command_prefix = self.config["bridge.command_prefix"] + + # region Utility functions for handling commands + + async def handle(self, room, sender, command, args, is_management, is_portal): + evt = CommandEvent(self, room, sender, command, args, + is_management, is_portal) + command = command.lower() + try: + command = command_handlers[command] + except KeyError: + if sender.command_status and "next" in sender.command_status: + args.insert(0, command) + evt.command = "" + command = sender.command_status["next"] + else: + command = command_handlers["unknown_command"] + try: + await command(evt) + except FloodWaitError as e: + return evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}") + except Exception: + self.log.exception(f"Fatal error handling command " + + f"{evt.command} {' '.join(args)} from {sender.mxid}") + return evt.reply("Fatal error while handling command. Check logs for more details.") diff --git a/mautrix_telegram/commands/meta.py b/mautrix_telegram/commands/meta.py new file mode 100644 index 00000000..6f38dc3d --- /dev/null +++ b/mautrix_telegram/commands/meta.py @@ -0,0 +1,74 @@ +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from . import command_handler + + +@command_handler() +def cancel(evt): + if evt.sender.command_status: + action = evt.sender.command_status["action"] + evt.sender.command_status = None + return evt.reply(f"{action} cancelled.") + else: + return evt.reply("No ongoing command.") + + +@command_handler() +def unknown_command(evt): + return evt.reply("Unknown command. Try `$cmdprefix+sp help` for help.") + + +@command_handler() +def help(evt): + if evt.is_management: + management_status = ("This is a management room: prefixing commands " + "with `$cmdprefix` is not required.\n") + elif evt.is_portal: + management_status = ("**This is a portal room**: you must always " + "prefix commands with `$cmdprefix`.\n" + "Management commands will not be sent to Telegram.") + else: + management_status = ("**This is not a management room**: you must " + "prefix commands with `$cmdprefix`.\n") + help = """\n +#### Generic bridge commands +**help** - Show this help message. +**cancel** - Cancel an ongoing action (such as login). + +#### Authentication +**login** <_phone_> - Request an authentication code. +**logout** - Log out from Telegram. +**ping** - Check if you're logged into Telegram. + +#### Initiating chats +**search** [_-r|--remote_] <_query_> - Search your contacts or the Telegram servers for users. +**pm** <_identifier_> - Open a private chat with the given Telegram user. The + identifier is either the internal user ID, the username or + the phone number. +**join** <_link_> - Join a chat with an invite link. +**create** [_type_] - Create a Telegram chat of the given type for the current + Matrix room. The type is either `group`, `supergroup` or + `channel` (defaults to `group`). + +#### Portal management +**upgrade** - Upgrade a normal Telegram group to a supergroup. +**invite-link** - Get a Telegram invite link to the current chat. +**delete-portal** - Forget the current portal room. Only works for group chats; to delete + a private chat portal, simply leave the room. +**group-name** <_name_|`-`> - Change the username of a supergroup/channel. To disable, use a dash + (`-`) as the name. +""" + return evt.reply(management_status + help) diff --git a/mautrix_telegram/commands/telegram.py b/mautrix_telegram/commands/telegram.py new file mode 100644 index 00000000..6b157d63 --- /dev/null +++ b/mautrix_telegram/commands/telegram.py @@ -0,0 +1,260 @@ +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from telethon.errors import * +from telethon.tl.types import User as TLUser +from telethon.tl.functions.messages import ImportChatInviteRequest, CheckChatInviteRequest +from telethon.tl.functions.channels import JoinChannelRequest + +from .. import puppet as pu, portal as po +from . import command_handler + + +@command_handler() +async def search(evt): + if len(evt.args) == 0: + return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] `") + + force_remote = False + if evt.args[0] in {"-r", "--remote"}: + force_remote = True + evt.args.pop(0) + + query = " ".join(evt.args) + if force_remote and len(query) < 5: + return await evt.reply("Minimum length of query for remote search is 5 characters.") + + results, remote = await evt.sender.search(query, force_remote) + + if not results: + if len(query) < 5 and remote: + return await evt.reply("No local results. " + "Minimum length of remote query is 5 characters.") + return await evt.reply("No results 3:") + + reply = [] + if remote: + reply += ["**Results from Telegram server:**", ""] + else: + reply += ["**Results in contacts:**", ""] + reply += [(f"* [{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): " + + f"{puppet.id} ({similarity}% match)") + for puppet, similarity in results] + + # TODO somehow show remote channel results when joining by alias is possible? + + return await evt.reply("\n".join(reply)) + + +@command_handler() +async def pm(evt): + if len(evt.args) == 0: + return await evt.reply("**Usage:** `$cmdprefix+sp pm `") + + user = await evt.sender.client.get_entity(evt.args[0]) + if not user: + return await evt.reply("User not found.") + elif not isinstance(user, TLUser): + return await evt.reply("That doesn't seem to be a user.") + portal = po.Portal.get_by_entity(user, evt.sender.tgid) + await portal.create_matrix_room(evt.sender, user, [evt.sender.mxid]) + return await evt.reply( + f"Created private chat room with {pu.Puppet.get_displayname(user, False)}") + + +@command_handler() +async def invite_link(evt): + portal = po.Portal.get_by_mxid(evt.room_id) + if not portal: + return await evt.reply("This is not a portal room.") + + if portal.peer_type == "user": + return await evt.reply("You can't invite users to private chats.") + + try: + link = await portal.get_invite_link(evt.sender) + return await evt.reply(f"Invite link to {portal.title}: {link}") + except ValueError as e: + return await evt.reply(e.args[0]) + except ChatAdminRequiredError: + return await evt.reply("You don't have the permission to create an invite link.") + + +@command_handler(needs_admin=True) +async def delete_portal(evt): + room_id = evt.args[0] if len(evt.args) > 0 else evt.room_id + + portal = po.Portal.get_by_mxid(room_id) + if not portal: + that_this = "This" if room_id == evt.room_id else "That" + return await evt.reply(f"{that_this} is not a portal room.") + + async def post_confirm(_, confirm): + evt.sender.command_status = None + if len(confirm.args) > 0 and confirm.args[0] == "confirm-delete": + await portal.cleanup_and_delete() + if confirm.room_id != room_id: + return await confirm.reply("Portal successfully deleted.") + else: + return await confirm.reply("Portal deletion cancelled.") + + evt.sender.command_status = { + "next": post_confirm, + "action": "Portal deletion", + } + return await evt.reply("Please confirm deletion of portal " + + f"[{room_id}](https://matrix.to/#/{room_id}) " + + f"to Telegram chat \"{portal.title}\" " + + "by typing `$cmdprefix+sp confirm-delete`") + + +@command_handler() +async def join(evt): + if len(evt.args) == 0: + return await evt.reply("**Usage:** `$cmdprefix+sp join `") + + regex = re.compile(r"(?:https?://)?t(?:elegram)?\.(?:dog|me)(?:joinchat/)?/(.+)") + arg = regex.match(evt.args[0]) + if not arg: + return await evt.reply("That doesn't look like a Telegram invite link.") + arg = arg.group(1) + if arg.startswith("joinchat/"): + invite_hash = arg[len("joinchat/"):] + try: + await evt.sender.client(CheckChatInviteRequest(invite_hash)) + except InviteHashInvalidError: + return await evt.reply("Invalid invite link.") + except InviteHashExpiredError: + return await evt.reply("Invite link expired.") + try: + updates = evt.sender.client(ImportChatInviteRequest(invite_hash)) + except UserAlreadyParticipantError: + return await evt.reply("You are already in that chat.") + else: + channel = await evt.sender.client.get_entity(arg) + if not channel: + return await evt.reply("Channel/supergroup not found.") + updates = await evt.sender.client(JoinChannelRequest(channel)) + for chat in updates.chats: + portal = po.Portal.get_by_entity(chat) + if portal.mxid: + await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid]) + return await evt.reply(f"Created room for {portal.title}") + else: + await portal.invite_matrix([evt.sender.mxid]) + return await evt.reply(f"Invited you to portal of {portal.title}") + + +@command_handler() +async def create(evt): + type = evt.args[0] if len(evt.args) > 0 else "group" + if type not in {"chat", "group", "supergroup", "channel"}: + return await evt.reply( + "**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`") + + if po.Portal.get_by_mxid(evt.room_id): + return await evt.reply("This is already a portal room.") + + state = await evt.az.intent.get_room_state(evt.room_id) + title = None + about = None + levels = None + for event in state: + if event["type"] == "m.room.name": + title = event["content"]["name"] + elif event["type"] == "m.room.topic": + about = event["content"]["topic"] + elif event["type"] == "m.room.power_levels": + levels = event["content"] + if not title: + return await evt.reply("Please set a title before creating a Telegram chat.") + elif (not levels or not levels["users"] or evt.az.intent.mxid not in levels["users"] or + levels["users"][evt.az.intent.mxid] < 100): + return await evt.reply(f"Please give " + + f"[the bridge bot](https://matrix.to/#/{evt.az.intent.mxid})" + + f" a power level of 100 before creating a Telegram chat.") + else: + for user, level in levels["users"].items(): + if level >= 100 and user != evt.az.intent.mxid: + return await evt.reply( + f"Please make sure only the bridge bot has power level above" + + f"99 before creating a Telegram chat.\n\n" + + f"Use power level 95 instead of 100 for admins.") + + supergroup = type == "supergroup" + type = { + "supergroup": "channel", + "channel": "channel", + "chat": "chat", + "group": "chat", + }[type] + + portal = po.Portal(tgid=None, mxid=evt.room_id, title=title, about=about, peer_type=type) + try: + await portal.create_telegram_chat(evt.sender, supergroup=supergroup) + except ValueError as e: + return await evt.reply(e.args[0]) + return await evt.reply(f"Telegram chat created. ID: {portal.tgid}") + + +@command_handler() +async def upgrade(evt): + portal = po.Portal.get_by_mxid(evt.room_id) + if not portal: + return await evt.reply("This is not a portal room.") + elif portal.peer_type == "channel": + return await evt.reply("This is already a supergroup or a channel.") + elif portal.peer_type == "user": + return await evt.reply("You can't upgrade private chats.") + + try: + await portal.upgrade_telegram_chat(evt.sender) + return await evt.reply(f"Group upgraded to supergroup. New ID: {portal.tgid}") + except ChatAdminRequiredError: + return await evt.reply("You don't have the permission to upgrade this group.") + except ValueError as e: + return await evt.reply(e.args[0]) + + +@command_handler() +async def group_name(evt): + if len(evt.args) == 0: + return await evt.reply("**Usage:** `$cmdprefix+sp group-name `") + + portal = po.Portal.get_by_mxid(evt.room_id) + if not portal: + return await evt.reply("This is not a portal room.") + elif portal.peer_type != "channel": + return await evt.reply("Only channels and supergroups have usernames.") + + try: + await portal.set_telegram_username(evt.sender, + evt.args[0] if evt.args[0] != "-" else "") + if portal.username: + return await evt.reply(f"Username of channel changed to {portal.username}.") + else: + return await evt.reply(f"Channel is now private.") + except ChatAdminRequiredError: + return await evt.reply( + "You don't have the permission to set the username of this channel.") + except UsernameNotModifiedError: + if portal.username: + return await evt.reply("That is already the username of this channel.") + else: + return await evt.reply("This channel is already private") + except UsernameOccupiedError: + return await evt.reply("That username is already in use.") + except UsernameInvalidError: + return await evt.reply("Invalid username") diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index b15ebb6d..6351897a 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -29,6 +29,7 @@ from telethon.tl.functions.messages import * from telethon.tl.functions.channels import * from telethon.errors.rpc_error_list import * from telethon.tl.types import * +from mautrix_appservice import MatrixRequestError from .db import Portal as DBPortal, Message as DBMessage from . import puppet as p, user as u, formatter @@ -355,6 +356,31 @@ class Portal: return link.link + async def get_authenticated_matrix_users(self): + members = await self.main_intent.get_room_members(self.mxid) + authenticated = [] + for member in members: + if p.Puppet.get_id_from_mxid(member) or member == self.main_intent.mxid: + continue + user = u.User.get_by_mxid(member) + if user.has_full_access: + authenticated.append(user) + return authenticated + + @staticmethod + async def cleanup_room(intent, room_id, type="Portal"): + for user in await intent.get_room_members(room_id): + if user != intent.mxid: + try: + await intent.kick(room_id, user, f"{type} deleted.") + except MatrixRequestError: + pass + await intent.leave_room(room_id) + + async def cleanup_and_delete(self): + await self.cleanup_room(self.main_intent, self.mxid) + self.delete() + # endregion # region Matrix event handling diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index ba37e1ae..04ab26c4 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -26,6 +26,7 @@ from .db import User as DBUser, Message as DBMessage, Contact as DBContact from .tgclient import MautrixTelegramClient from . import portal as po, puppet as pu, __version__ + config = None @@ -47,7 +48,16 @@ class User: self.command_status = None self.connected = False - self.client = None + device = f"{platform.system()} {platform.release()}" + sysversion = MautrixTelegramClient.__version__ + self.client = MautrixTelegramClient(self.mxid, + config["telegram.api_id"], + config["telegram.api_hash"], + loop=self.loop, + app_version=__version__, + system_version=sysversion, + device_model=device) + self.client.add_update_handler(self.update_catch) self.is_admin = self.mxid in config.get("bridge.admins", []) @@ -104,16 +114,6 @@ class User: # region Telegram connection management async def start(self): - device = f"{platform.system()} {platform.release()}" - sysversion = MautrixTelegramClient.__version__ - self.client = MautrixTelegramClient(self.mxid, - config["telegram.api_id"], - config["telegram.api_hash"], - loop=self.loop, - app_version=__version__, - system_version=sysversion, - device_model=device) - self.client.add_update_handler(self.update_catch) self.connected = await self.client.connect() if self.logged_in: asyncio.ensure_future(self.post_login(), loop=self.loop) From 2064f2b2d160acc7f71e2794b081569a550a0cdf Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 13 Feb 2018 00:58:03 +0200 Subject: [PATCH 03/10] Store user portals and kick when logging out. Fixes #53 --- mautrix_telegram/db.py | 20 ++++++++- mautrix_telegram/matrix.py | 1 + mautrix_telegram/portal.py | 6 ++- mautrix_telegram/user.py | 83 ++++++++++++++++++++++++++++++-------- 4 files changed, 90 insertions(+), 20 deletions(-) diff --git a/mautrix_telegram/db.py b/mautrix_telegram/db.py index d993b02c..0b6341d8 100644 --- a/mautrix_telegram/db.py +++ b/mautrix_telegram/db.py @@ -13,7 +13,7 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from sqlalchemy import Column, UniqueConstraint, ForeignKey, Integer, String +from sqlalchemy import Column, UniqueConstraint, ForeignKey, ForeignKeyConstraint, Integer, String from sqlalchemy.orm import relationship from .base import Base @@ -50,6 +50,18 @@ class Message(Base): __table_args__ = (UniqueConstraint('mxid', 'mx_room', 'tg_space', name='_mx_id_room'),) +class UserPortal(Base): + query = None + __tablename__ = "user_portal" + + user = Column(Integer, ForeignKey("user.tgid"), primary_key=True) + portal = Column(Integer, primary_key=True) + portal_receiver = Column(Integer, primary_key=True) + + __table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"), + ("portal.tgid", "portal.tg_receiver")),) + + class User(Base): query = None __tablename__ = "user" @@ -58,7 +70,10 @@ class User(Base): tgid = Column(Integer, nullable=True) tg_username = Column(String, nullable=True) saved_contacts = Column(Integer, default=0) - contacts = relationship("Contact", uselist=True) + contacts = relationship("Contact", uselist=True, + cascade="save-update, merge, delete, delete-orphan") + portals = relationship("Portal", secondary="user_portal", single_parent=True, + cascade="save-update, merge, delete, delete-orphan") class Contact(Base): @@ -82,5 +97,6 @@ class Puppet(Base): def init(db_session): Portal.query = db_session.query_property() Message.query = db_session.query_property() + UserPortal.query = db_session.query_property() User.query = db_session.query_property() Puppet.query = db_session.query_property() diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index e631ff4f..cbca6d09 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -79,6 +79,7 @@ class MatrixHandler: pass portal.mxid = room portal.save() + inviter.register_portal(portal) await puppet.intent.send_notice(room, "Portal to private chat created.") else: await puppet.intent.join_room(room) diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 6351897a..9903b9f5 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -204,7 +204,7 @@ class Portal: if alias: # TODO properly handle existing room aliases - intent.remove_room_alias(alias) + await intent.remove_room_alias(alias) room = await intent.create_room(alias=alias, is_public=public, invitees=invites or [], name=self.title, is_direct=direct) if not room: @@ -213,6 +213,7 @@ class Portal: self.mxid = room["room_id"] self.by_mxid[self.mxid] = self self.save() + user.register_portal(self) power_level_requirement = 0 if self.peer_type == "chat" and entity.admins_enabled else 50 levels = await self.main_intent.get_power_levels(self.mxid) @@ -245,6 +246,7 @@ class Portal: user = u.User.get_by_tgid(user_id) if user: + user.register_portal(self) await self.main_intent.invite(self.mxid, user.mxid) async def delete_telegram_user(self, user_id, kick_message=None): @@ -255,6 +257,7 @@ class Portal: 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 or "Left Telegram chat") async def update_info(self, user, entity=None): @@ -840,6 +843,7 @@ class Portal: user_levels = levels["users"] if user: + user.register_portal(self) user_level_defined = user.mxid in user_levels user_has_right_level = (user_levels[user.mxid] == new_level if user_level_defined else new_level == 0) diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index 04ab26c4..d64be891 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -21,12 +21,12 @@ from telethon.tl.types import * from telethon.tl.types.contacts import ContactsNotModified from telethon.tl.types import User as TLUser from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest +from mautrix_appservice import MatrixRequestError from .db import User as DBUser, Message as DBMessage, Contact as DBContact from .tgclient import MautrixTelegramClient from . import portal as po, puppet as pu, __version__ - config = None @@ -38,26 +38,20 @@ class User: by_mxid = {} by_tgid = {} - def __init__(self, mxid, tgid=None, username=None, db_contacts=None, saved_contacts=0): + def __init__(self, mxid, tgid=None, username=None, db_contacts=None, saved_contacts=0, + db_portals=None): self.mxid = mxid self.tgid = tgid self.username = username self.contacts = [] self.saved_contacts = saved_contacts self.db_contacts = db_contacts + self.portals = {} + self.db_portals = db_portals self.command_status = None self.connected = False - device = f"{platform.system()} {platform.release()}" - sysversion = MautrixTelegramClient.__version__ - self.client = MautrixTelegramClient(self.mxid, - config["telegram.api_id"], - config["telegram.api_hash"], - loop=self.loop, - app_version=__version__, - system_version=sysversion, - device_model=device) - self.client.add_update_handler(self.update_catch) + self._init_client() self.is_admin = self.mxid in config.get("bridge.admins", []) @@ -91,6 +85,19 @@ class User: else: self.contacts = [] + @property + def db_portals(self): + return [portal.to_db(merge=False) for _, portal in self.portals.items()] + + @db_portals.setter + def db_portals(self, portals): + if portals: + self.portals = {(portal.tgid, portal.tg_receiver): + po.Portal.get_by_tgid(portal.tgid, portal.tg_receiver) + for portal in portals} + else: + self.portals = {} + def get_input_entity(self, user): return user.client.get_input_entity(InputUser(user_id=self.tgid, access_hash=0)) @@ -99,7 +106,8 @@ class User: def to_db(self): return self.db.merge( DBUser(mxid=self.mxid, tgid=self.tgid, tg_username=self.username, - contacts=self.db_contacts, saved_contacts=self.saved_contacts)) + contacts=self.db_contacts, saved_contacts=self.saved_contacts, + portals=self.db_portals)) def save(self): self.to_db() @@ -108,11 +116,23 @@ class User: @classmethod def from_db(cls, db_user): return User(db_user.mxid, db_user.tgid, db_user.tg_username, db_user.contacts, - db_user.saved_contacts) + db_user.saved_contacts, db_user.portals) # endregion # region Telegram connection management + def _init_client(self): + device = f"{platform.system()} {platform.release()}" + sysversion = MautrixTelegramClient.__version__ + self.client = MautrixTelegramClient(self.mxid, + config["telegram.api_id"], + config["telegram.api_hash"], + loop=self.loop, + app_version=__version__, + system_version=sysversion, + device_model=device) + self.client.add_update_handler(self.update_catch) + async def start(self): self.connected = await self.client.connect() if self.logged_in: @@ -148,7 +168,14 @@ class User: self.save() async def log_out(self): - self.connected = False + for _, portal in self.portals.items(): + try: + await portal.main_intent.kick(portal.mxid, self.mxid, "Logged out of Telegram.") + except MatrixRequestError: + pass + self.portals = {} + self.contacts = [] + self.save() if self.tgid: try: del self.by_tgid[self.tgid] @@ -156,8 +183,12 @@ class User: pass self.tgid = None self.save() - await self.client.log_out() - # TODO kick user from portals + ok = await self.client.log_out() + if not ok: + return False + self._init_client() + await self.start() + return True def _search_local(self, query, max_results=5, min_similarity=45): results = [] @@ -200,9 +231,27 @@ class User: if invalid: continue portal = po.Portal.get_by_entity(entity) + self.portals[portal.tgid_full] = portal creators.append(portal.create_matrix_room(self, entity, invites=[self.mxid])) + self.save() await asyncio.gather(*creators, loop=self.loop) + def register_portal(self, portal): + try: + if self.portals[portal.tgid_full] == portal: + return + except KeyError: + pass + self.portals[portal.tgid_full] = portal + self.save() + + def unregister_portal(self, portal): + try: + del self.portals[portal.tgid_full] + self.save() + except KeyError: + pass + def _hash_contacts(self): acc = 0 for id in sorted([self.saved_contacts] + [contact.id for contact in self.contacts]): From e4cb2d458d30845213347d9ba37f71d7d44415f2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 13 Feb 2018 01:02:40 +0200 Subject: [PATCH 04/10] Add clean-rooms to help text --- mautrix_telegram/commands/meta.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mautrix_telegram/commands/meta.py b/mautrix_telegram/commands/meta.py index 6f38dc3d..2fb310f7 100644 --- a/mautrix_telegram/commands/meta.py +++ b/mautrix_telegram/commands/meta.py @@ -69,6 +69,7 @@ def help(evt): **delete-portal** - Forget the current portal room. Only works for group chats; to delete a private chat portal, simply leave the room. **group-name** <_name_|`-`> - Change the username of a supergroup/channel. To disable, use a dash - (`-`) as the name. + (`-`) as the name. +**clean-rooms** - Clean up unused portal/management rooms. """ return evt.reply(management_status + help) From 496ebd2c8ded7ac54970f3fde1df52df0be1ac91 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 13 Feb 2018 01:10:03 +0200 Subject: [PATCH 05/10] Handle some weird cases with get_joined_rooms() --- mautrix_telegram/portal.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 9903b9f5..15312eb5 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -372,7 +372,11 @@ class Portal: @staticmethod async def cleanup_room(intent, room_id, type="Portal"): - for user in await intent.get_room_members(room_id): + try: + members = await intent.get_room_members(room_id) + except MatrixRequestError: + members = [] + for user in members: if user != intent.mxid: try: await intent.kick(room_id, user, f"{type} deleted.") From 90466ba941e13f1665540ca17b7191569f5f2305 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 13 Feb 2018 01:13:27 +0200 Subject: [PATCH 06/10] Handle IntentErrors when cleaning up rooms --- mautrix_telegram/commands/clean_rooms.py | 7 ++++++- mautrix_telegram/portal.py | 9 ++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/mautrix_telegram/commands/clean_rooms.py b/mautrix_telegram/commands/clean_rooms.py index 05971af4..612c096c 100644 --- a/mautrix_telegram/commands/clean_rooms.py +++ b/mautrix_telegram/commands/clean_rooms.py @@ -13,6 +13,8 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from mautrix_appservice import MatrixRequestError + from . import command_handler from .. import puppet as pu, portal as po @@ -27,7 +29,10 @@ async def _find_rooms(intent): for room in rooms: portal = po.Portal.get_by_mxid(room) if not portal: - members = await intent.get_room_members(room) + try: + members = await intent.get_room_members(room) + except MatrixRequestError: + members = [] if len(members) == 2: other_member = members[0] if members[0] != intent.mxid else members[1] if pu.Puppet.get_id_from_mxid(other_member): diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 15312eb5..2138d848 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -29,7 +29,7 @@ from telethon.tl.functions.messages import * from telethon.tl.functions.channels import * from telethon.errors.rpc_error_list import * from telethon.tl.types import * -from mautrix_appservice import MatrixRequestError +from mautrix_appservice import MatrixRequestError, IntentError from .db import Portal as DBPortal, Message as DBMessage from . import puppet as p, user as u, formatter @@ -360,7 +360,10 @@ class Portal: return link.link async def get_authenticated_matrix_users(self): - members = await self.main_intent.get_room_members(self.mxid) + try: + members = await self.main_intent.get_room_members(self.mxid) + except MatrixRequestError: + return [] authenticated = [] for member in members: if p.Puppet.get_id_from_mxid(member) or member == self.main_intent.mxid: @@ -380,7 +383,7 @@ class Portal: if user != intent.mxid: try: await intent.kick(room_id, user, f"{type} deleted.") - except MatrixRequestError: + except (MatrixRequestError, IntentError): pass await intent.leave_room(room_id) From 4207e30a17210b0577704c8ba85675dbd46a01ec Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 13 Feb 2018 13:28:12 +0200 Subject: [PATCH 07/10] Add more checks to clean-rooms handling --- mautrix_telegram/commands/clean_rooms.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mautrix_telegram/commands/clean_rooms.py b/mautrix_telegram/commands/clean_rooms.py index 612c096c..23ee4bf9 100644 --- a/mautrix_telegram/commands/clean_rooms.py +++ b/mautrix_telegram/commands/clean_rooms.py @@ -155,12 +155,15 @@ async def execute_room_cleanup(evt, rooms_to_clean): if len(evt.args) > 0 and evt.args[0] == "confirm-clean": await evt.reply(f"Cleaning {len(rooms_to_clean)} rooms. " + "This might take a while.") + cleaned = 0 for room in rooms_to_clean: if isinstance(room, po.Portal): await room.cleanup_and_delete() - else: + cleaned += 1 + elif isinstance(room, str): await po.Portal.cleanup_room(evt.az.intent, room, type="Room") + cleaned += 1 evt.sender.command_status = None - await evt.reply(f"{len(rooms_to_clean)} rooms cleaned up successfully.") + await evt.reply(f"{cleaned} rooms cleaned up successfully.") else: await evt.reply("Room cleaning cancelled.") From 0147475aecd7c354358cdfc5cdaab566131a5214 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 13 Feb 2018 13:28:48 +0200 Subject: [PATCH 08/10] Add optional edit handling with replies. Fixes #47 --- example-config.yaml | 7 +++++-- mautrix_telegram/formatter.py | 7 ++++--- mautrix_telegram/portal.py | 20 +++++++++++++++++--- mautrix_telegram/user.py | 23 ++++++++++++++--------- 4 files changed, 40 insertions(+), 17 deletions(-) diff --git a/example-config.yaml b/example-config.yaml index 6a117c89..fce1ac91 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -67,6 +67,9 @@ bridge: # Warning: Using this on a client with native replies will not look good: the message will have # a native quote AND a non-native quote. link_in_reply: False + # Show message editing as a reply to the original message. + # If this is false, message edits are not shown at all, as Matrix does not support editing yet. + edits_as_replies: False # The prefix for commands. Only required in non-management rooms. command_prefix: "!tg" @@ -74,13 +77,13 @@ bridge: # 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-hs.example.com" + - "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-hs.example.com" + - "@admin:internal.example.com" # Telegram config telegram: diff --git a/mautrix_telegram/formatter.py b/mautrix_telegram/formatter.py index ce4a4c7b..91e59bf0 100644 --- a/mautrix_telegram/formatter.py +++ b/mautrix_telegram/formatter.py @@ -43,6 +43,7 @@ log = logging.getLogger("mau.formatter") # everything up. TEMP_ENC = "utf-16-le" + # region Matrix to Telegram class MessageEntityReply(MessageEntityUnknown): @@ -213,7 +214,7 @@ def matrix_to_telegram(html, tg_space=None): # region Telegram to Matrix async def telegram_event_to_matrix(evt, source, native_replies=False, message_link_in_reply=False, - main_intent=None): + main_intent=None, reply_text="Reply"): text = evt.message html = telegram_to_matrix(evt.message, evt.entities) if evt.entities else None @@ -258,11 +259,11 @@ async def telegram_event_to_matrix(evt, source, native_replies=False, message_li displayname = puppet.displayname if puppet else sender reply_to_user = (f"{displayname}") reply_to_msg = (("Reply") + + f"{msg.mx_room}/{msg.mxid}'>{reply_text}") if message_link_in_reply else "Reply") quote = f"{reply_to_msg} to {reply_to_user}
{body}
" except (ValueError, KeyError, MatrixRequestError): - quote = "Reply to unknown user (Failed to fetch message):
" + quote = "{reply_text} to unknown user (Failed to fetch message):
" if html: html = quote + html else: diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 2138d848..b034b688 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -733,9 +733,23 @@ class Portal: async def handle_telegram_text(self, source, sender, evt): self.log.debug(f"Sending {evt.message} to {self.mxid} by {sender.id}") text, html = await formatter.telegram_event_to_matrix(evt, source, - config["bridge.native_replies"], - config["bridge.link_in_reply"], - self.main_intent) + config["bridge.native_replies"], + config["bridge.link_in_reply"], + self.main_intent) + await sender.intent.set_typing(self.mxid, is_typing=False) + return await sender.intent.send_text(self.mxid, text, html=html) + + async def handle_telegram_edit(self, source, sender, evt): + if not self.mxid: + return + elif not config["bridge.edits_as_replies"]: + self.log.debug("Edits as replies disabled, ignoring edit event...") + return + evt.reply_to_msg_id = evt.id + text, html = await formatter.telegram_event_to_matrix(evt, source, + config["bridge.native_replies"], + config["bridge.link_in_reply"], + self.main_intent, reply_text="Edit") await sender.intent.set_typing(self.mxid, is_typing=False) return await sender.intent.send_text(self.mxid, text, html=html) diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index d64be891..1c19dd96 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -281,8 +281,8 @@ class User: self.log.exception("Failed to handle Telegram update") async def update(self, update): - if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewMessage, - UpdateNewChannelMessage)): + if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage, + UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage)): await self.update_message(update) elif isinstance(update, (UpdateChatUserTyping, UpdateUserTyping)): await self.update_typing(update) @@ -365,7 +365,8 @@ class User: elif isinstance(update, UpdateShortMessage): portal = po.Portal.get_by_tgid(update.user_id, self.tgid, "user") sender = pu.Puppet.get(self.tgid if update.out else update.user_id) - elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)): + elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage, + UpdateEditMessage, UpdateEditChannelMessage)): update = update.message if isinstance(update.to_id, PeerUser) and not update.out: portal = po.Portal.get_by_tgid(update.from_id, peer_type="user", @@ -379,8 +380,8 @@ class User: return update, None, None return update, sender, portal - async def update_message(self, update): - update, sender, portal = self.get_message_details(update) + def update_message(self, original_update): + update, sender, portal = self.get_message_details(original_update) if isinstance(update, MessageService): if isinstance(update.action, MessageActionChannelMigrateFrom): @@ -389,10 +390,14 @@ class User: return self.log.debug("Handling action %s to %s by %d", update.action, portal.tgid_log, sender.id) - await portal.handle_telegram_action(self, sender, update.action) - else: - self.log.debug("Handling message %s to %s by %d", update, portal.tgid_log, sender.tgid) - await portal.handle_telegram_message(self, sender, update) + return portal.handle_telegram_action(self, sender, update.action) + + if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)): + self.log.debug("Handling edit %s to %s by %d", update, portal.tgid_log, sender.tgid) + return portal.handle_telegram_edit(self, sender, update) + + self.log.debug("Handling message %s to %s by %d", update, portal.tgid_log, sender.tgid) + return portal.handle_telegram_message(self, sender, update) # endregion # region Class instance lookup From 317b7d3dc74201e11be9cc85e23cfd98920368d3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 13 Feb 2018 13:45:09 +0200 Subject: [PATCH 09/10] Show that message is edit when using native replies --- mautrix_telegram/formatter.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mautrix_telegram/formatter.py b/mautrix_telegram/formatter.py index 91e59bf0..70700f39 100644 --- a/mautrix_telegram/formatter.py +++ b/mautrix_telegram/formatter.py @@ -247,6 +247,8 @@ async def telegram_event_to_matrix(evt, source, native_replies=False, message_li if msg: if native_replies: quote = f"Quote
" + if reply_text == "Edit": + html = "Edit: " + (html or escape(text)) else: try: event = await main_intent.get_event(msg.mx_room, msg.mxid) From 27083a23ed2209db21a5bc91f360035a16ef3fae Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 13 Feb 2018 13:51:27 +0200 Subject: [PATCH 10/10] Update database when editing message so replies point to edit --- mautrix_telegram/portal.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index b034b688..41a78e82 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -751,7 +751,15 @@ class Portal: config["bridge.link_in_reply"], self.main_intent, reply_text="Edit") await sender.intent.set_typing(self.mxid, is_typing=False) - return await sender.intent.send_text(self.mxid, text, html=html) + response = await sender.intent.send_text(self.mxid, text, html=html) + + mxid = response["event_id"] + tg_space = self.tgid if self.peer_type == "channel" else source.tgid + + msg = DBMessage.query.get((evt.id, tg_space)) + msg.mxid = mxid + msg.mx_room = self.mxid + self.db.commit() async def handle_telegram_message(self, source, sender, evt): if not self.mxid: