From 6d648d51da194a5a5f32f3a7f608240500359f2e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 4 Mar 2018 17:10:03 +0200 Subject: [PATCH] Add basic support for bridging existing rooms to existing chats. Fixes #52 --- mautrix_telegram/bot.py | 11 +- mautrix_telegram/commands/__init__.py | 2 +- mautrix_telegram/commands/portal.py | 164 +++++++++++++++++++++++--- 3 files changed, 154 insertions(+), 23 deletions(-) diff --git a/mautrix_telegram/bot.py b/mautrix_telegram/bot.py index 9c5c5ece..4947da1b 100644 --- a/mautrix_telegram/bot.py +++ b/mautrix_telegram/bot.py @@ -121,12 +121,11 @@ class Bot(AbstractUser): return await reply(f"Invited `{user.mxid}` to the portal.") def handle_command_id(self, message, reply): - id = message.to_id - if isinstance(id, PeerChannel): - id = id.channel_id - else: - id = id.chat_id - return reply(str(id)) + # Provide the prefixed ID to the user so that the user wouldn't need to specify whether the + # chat is a normal group or a supergroup/channel when using the ID. + if isinstance(message.to_id, PeerChannel): + return reply(f"-100{message.to_id.channel_id}") + return reply(str(-message.to_id.chat_id)) def match_command(self, text, command): text = text.lower() diff --git a/mautrix_telegram/commands/__init__.py b/mautrix_telegram/commands/__init__.py index 9f6f7c42..fc0894eb 100644 --- a/mautrix_telegram/commands/__init__.py +++ b/mautrix_telegram/commands/__init__.py @@ -1,2 +1,2 @@ -from .handler import command_handler, CommandHandler +from .handler import command_handler, CommandHandler, CommandEvent from . import clean_rooms, auth, meta, telegram, portal diff --git a/mautrix_telegram/commands/portal.py b/mautrix_telegram/commands/portal.py index ffb4bcc2..666baa21 100644 --- a/mautrix_telegram/commands/portal.py +++ b/mautrix_telegram/commands/portal.py @@ -14,15 +14,17 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import asyncio + from telethon_aio.errors import * from mautrix_appservice import MatrixRequestError from .. import portal as po -from . import command_handler +from . import command_handler, CommandEvent @command_handler() -async def invite_link(evt): +async def invite_link(evt: CommandEvent): portal = po.Portal.get_by_mxid(evt.room_id) if not portal: return await evt.reply("This is not a portal room.") @@ -52,17 +54,17 @@ async def _has_access_to(room, intent, sender, event, default=50): default=default) -async def _get_portal_and_check_permission(evt, permission, action=None, allow_that=False): - room_id = evt.args[0] if len(evt.args) > 0 and allow_that else evt.room_id +async def _get_portal_and_check_permission(evt, permission, action=None): + 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."), False - if not _has_access_to(portal.mxid, portal.main_intent, evt.sender, permission): + if not await _has_access_to(portal.mxid, evt.az.intent, evt.sender, permission): action = action or f"{permission.replace('_', ' ')}s" - return await evt.reply(f"You do not have the permissions to {action}."), False + return await evt.reply(f"You do not have the permissions to {action} that portal."), False return portal, True @@ -83,7 +85,7 @@ def _get_portal_murder_function(action, room_id, function, command, completed_me @command_handler() -async def delete_portal(evt): +async def delete_portal(evt: CommandEvent): portal, ok = await _get_portal_and_check_permission(evt, "delete_portal") if not ok: return @@ -98,8 +100,8 @@ async def delete_portal(evt): @command_handler() -async def unbridge(evt): - portal, ok = await _get_portal_and_check_permission(evt, "unbridge_room", allow_that=False) +async def unbridge(evt: CommandEvent): + portal, ok = await _get_portal_and_check_permission(evt, "unbridge_room") if not ok: return @@ -111,7 +113,137 @@ async def unbridge(evt): "by typing `$cmdprefix+sp confirm-unbridge`") -async def _get_initial_state(evt): +@command_handler() +async def bridge(evt: CommandEvent): + if len(evt.args) == 0: + return await evt.reply("**Usage:** " + "`$cmdprefix+sp bridge [Matrix room ID]`") + room_id = evt.args[1] if len(evt.args) > 1 else evt.room_id + that_this = "This" if room_id == evt.room_id else "That" + + portal = po.Portal.get_by_mxid(room_id) + if portal: + return await evt.reply(f"{that_this} room is already a portal room.") + + if not await _has_access_to(room_id, evt.az.intent, evt.sender, "bridge"): + return await evt.reply("You do not have the permissions to bridge that room.") + + levels = await evt.az.intent.get_power_levels(room_id) + power_level_error = _check_power_levels(levels, evt.az.bot_mxid) + if power_level_error: + return await evt.reply(power_level_error) + + # The /id bot command provides the prefixed ID, so we assume + tgid = evt.args[0] + if tgid.startswith("-100"): + tgid = int(tgid[4:]) + peer_type = "channel" + elif tgid.startswith("-"): + tgid = -int(tgid) + peer_type = "chat" + else: + return await evt.reply("That doesn't seem like a prefixed Telegram chat ID.\n\n" + "If you did not get the ID using the `/id` bot command, please " + "prefix channel IDs with `-100` and normal group IDs with `-`.\n\n" + "Bridging private chats to existing rooms is not allowed.") + + portal = po.Portal.get_by_tgid(tgid, peer_type=peer_type) + if portal.mxid: + has_portal_message = ( + "That Telegram chat already has a portal at " + f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}). ") + if not await _has_access_to(portal.mxid, evt.az.intent, evt.sender, "unbridge"): + return await evt.reply(f"{has_portal_message}" + "Additionally, you do not have the permissions to unbridge " + "that room.") + evt.sender.command_status = { + "next": confirm_bridge, + "action": "Room bridging", + "mxid": portal.mxid, + "bridge_to_mxid": room_id, + "tgid": portal.tgid, + "peer_type": portal.peer_type, + } + return await evt.reply(f"{has_portal_message}" + "However, you have the permissions to unbridge that room.\n\n" + "To delete that portal completely and continue bridging, use " + "`$cmdprefix+sp delete-and-continue`. To unbridge the portal " + "without kicking Matrix users, use `$cmdprefix+sp unbridge-and-" + "continue`. To cancel, use `$cmdprefix+sp cancel`") + evt.sender.command_status = { + "next": confirm_bridge, + "action": "Room bridging", + "bridge_to_mxid": room_id, + "tgid": portal.tgid, + "peer_type": portal.peer_type, + } + return await evt.reply("That Telegram chat has no existing portal. To confirm bridging the " + "chat to this room, use `$cmdprefix+sp continue`") + + +async def cleanup_old_portal_while_bridging(evt, portal): + if not portal.mxid: + await evt.reply("The portal seems to have lost its Matrix room between you" + "calling `$cmdprefix+sp bridge` and this command.\n\n" + "Continuing without touching previous Matrix room...") + return True, None + elif evt.args[0] == "delete-and-continue": + return True, portal.cleanup_room(portal.main_intent, portal.mxid, + message="Portal deleted (moving to another room)") + elif evt.args[0] == "unbridge-and-continue": + return True, portal.cleanup_room(portal.main_intent, portal.mxid, + message="Room unbridged (portal moving to another room)", + puppets_only=True) + else: + await evt.reply( + "The chat you were trying to bridge already has a Matrix portal room.\n\n" + "Please use `$cmdprefix+sp delete-and-continue` or `$cmdprefix+sp unbridge-and-" + "continue` to either delete or unbridge the existing room (respectively) and " + "continue with the bridging.\n\n" + "If you changed your mind, use `$cmdprefix+sp cancel` to cancel.") + return False, None + + +async def confirm_bridge(evt: CommandEvent): + status = evt.sender.command_status + try: + portal = po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"]) + bridge_to_mxid = status["bridge_to_mxid"] + except KeyError: + evt.sender.command_status = None + return await evt.reply("Fatal error: tgid or peer_type missing from command_status. " + "This shouldn't happen unless you're messing with the command " + "handler code.") + if "mxid" in status: + ok, coro = await cleanup_old_portal_while_bridging(evt, portal) + if not ok: + return + elif coro: + asyncio.ensure_future(coro, loop=evt.loop) + await evt.reply("Cleaning up previous portal room...") + elif portal.mxid: + evt.sender.command_status = None + return await evt.reply("The portal seems to have created a Matrix room between you " + "calling `$cmdprefix+sp bridge` and this command.\n\n" + "Please start over by calling the bridge command again.") + elif evt.args[0] != "continue": + return await evt.reply("Please use `$cmdprefix+sp continue` to confirm the bridging or " + "`$cmdprefix+sp cancel` to cancel.") + + portal.mxid = bridge_to_mxid + portal.title, portal.about, levels = await _get_initial_state(evt) + portal.photo_id = "" + portal.save() + + entity = await evt.sender.client.get_entity(portal.peer) + direct = False + asyncio.ensure_future(portal.update_matrix_room(evt.sender, entity, direct, levels=levels), + loop=evt.loop) + + return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.") + + +async def _get_initial_state(evt: CommandEvent): state = await evt.az.intent.get_room_state(evt.room_id) title = None about = None @@ -126,23 +258,23 @@ async def _get_initial_state(evt): return title, about, levels -def _check_power_levels(levels, bot_mxid): +def _check_power_levels(levels: dict, bot_mxid: str): try: if levels["users"][bot_mxid] < 100: raise ValueError() except (TypeError, KeyError, ValueError): return (f"Please give [the bridge bot](https://matrix.to/#/{bot_mxid}) a power level of " - "100 before creating a Telegram chat.") + "100 before creating or bridging a Telegram chat.") for user, level in levels["users"].items(): if level >= 100 and user != bot_mxid: return (f"Please make sure only the bridge bot has power level above 99 before " - f"creating a Telegram chat.\n\n" + f"creating or bridging a Telegram chat.\n\n" f"Use power level 95 instead of 100 for admins.") @command_handler() -async def create(evt): +async def create(evt: CommandEvent): type = evt.args[0] if len(evt.args) > 0 else "group" if type not in {"chat", "group", "supergroup", "channel"}: return await evt.reply( @@ -177,7 +309,7 @@ async def create(evt): @command_handler() -async def upgrade(evt): +async def upgrade(evt: CommandEvent): portal = po.Portal.get_by_mxid(evt.room_id) if not portal: return await evt.reply("This is not a portal room.") @@ -196,7 +328,7 @@ async def upgrade(evt): @command_handler() -async def group_name(evt): +async def group_name(evt: CommandEvent): if len(evt.args) == 0: return await evt.reply("**Usage:** `$cmdprefix+sp group-name `")