diff --git a/mautrix_signal/commands/signal.py b/mautrix_signal/commands/signal.py index e25b653c..d4058a19 100644 --- a/mautrix_signal/commands/signal.py +++ b/mautrix_signal/commands/signal.py @@ -15,6 +15,8 @@ # along with this program. If not, see . from __future__ import annotations +from typing import Awaitable +import asyncio import base64 import json @@ -25,7 +27,7 @@ from mautrix.types import ContentURI, EventID, EventType, PowerLevelStateEventContent, RoomID from .. import portal as po, puppet as pu -from ..util import normalize_number +from ..util import normalize_number, user_has_power_level from .auth import make_qr from .typehint import CommandEvent @@ -100,6 +102,7 @@ async def pm(evt: CommandEvent) -> None: ) await portal.main_intent.invite_user(portal.mxid, evt.sender.mxid) return + await portal.create_matrix_room(evt.sender, puppet.address) await evt.reply(f"Created a portal room with {_pill(puppet)} and invited you to it") @@ -337,22 +340,200 @@ async def create(evt: CommandEvent) -> EventID: receiver="", avatar_url=avatar_url, ) - bot_pl = levels.get_user_level(evt.az.bot_mxid) - if bot_pl < levels.get_event_level(EventType.ROOM_POWER_LEVELS): - await evt.reply(missing_power_warning.format(bot_mxid=evt.az.bot_mxid)) - elif bot_pl <= 50: - await evt.reply(low_power_warning.format(bot_mxid=evt.az.bot_mxid)) - if levels.state_default < 50 and ( - levels.events[EventType.ROOM_NAME] >= 50 - or levels.events[EventType.ROOM_AVATAR] >= 50 - or levels.events[EventType.ROOM_TOPIC] >= 50 - ): - await evt.reply(meta_power_warning) + await warn_missing_power(levels, evt) await portal.create_signal_group(evt.sender, levels) await evt.reply(f"Signal chat created. ID: {portal.chat_id}") +@command_handler( + name="id", + needs_auth=True, + management_only=False, + help_section=SECTION_SIGNAL, + help_text="Get the ID of the Signal chat where this room is bridged.", +) +async def get_id(evt: CommandEvent) -> EventID: + if evt.portal: + return await evt.reply(f"This room is bridged to Signal chat ID `{evt.portal.chat_id}`.") + await evt.reply("This is not a portal room.") + + +@command_handler( + needs_auth=True, + management_only=False, + help_section=SECTION_SIGNAL, + help_text="Bridge the current Matrix room to the Signal chat with the given ID.", + help_args=" [Matrix room ID]", +) +async def bridge(evt: CommandEvent) -> EventID: + if len(evt.args) == 0: + return await evt.reply( + "**Usage:** `$cmdprefix+sp bridge [Matrix room ID]`" + ) + room_id = RoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id + that_this = "This" if room_id == evt.room_id else "That" + + portal = await 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 user_has_power_level(room_id, evt.az.intent, evt.sender, "bridge"): + return await evt.reply(f"You do not have the permissions to bridge {that_this} room.") + chat_id = None + try: + chat_id = GroupID(evt.args[0]) + except ValueError: + pass + if not chat_id: + return await evt.reply( + "That doesn't seem like a Signal chat ID.\n\n" + "Bridging private chats to existing rooms is not allowed." + ) + + portal = await po.Portal.get_by_chat_id(chat_id) + if portal.mxid: + has_portal_message = ( + "That Signal chat already has a portal at " + f"[{portal.mxid}](https://matrix.to/#/{portal.mxid}). " + ) + if not await user_has_power_level(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, + "chat_id": portal.chat_id, + } + 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, + "chat_id": portal.chat_id, + } + return await evt.reply( + "That Signal 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: CommandEvent, portal: po.Portal +) -> tuple[bool, Awaitable[None] | None]: + 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_portal("Portal deleted (moving to another room)", delete=False) + elif evt.args[0] == "unbridge-and-continue": + return True, portal.cleanup_portal( + "Room unbridged (portal moving to another room)", puppets_only=True, delete=False + ) + 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) -> EventID | None: + status = evt.sender.command_status + try: + portal = await po.Portal.get_by_chat_id(status["chat_id"]) + bridge_to_mxid = status["bridge_to_mxid"] + except KeyError: + evt.sender.command_status = None + return await evt.reply( + "Fatal error: chat_id missing from command_status. " + "This shouldn't happen unless you're messing with the command handler code." + ) + + is_logged_in = await evt.sender.is_logged_in() + + if "mxid" in status: + ok, coro = await cleanup_old_portal_while_bridging(evt, portal) + if not ok: + return None + elif coro: + asyncio.create_task(coro) + 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." + ) + evt.sender.command_status = None + async with portal._create_room_lock: + await _locked_confirm_bridge( + evt, portal=portal, room_id=bridge_to_mxid, is_logged_in=is_logged_in + ) + + +async def _locked_confirm_bridge( + evt: CommandEvent, portal: po.Portal, room_id: RoomID, is_logged_in: bool +) -> EventID | None: + try: + group = await evt.bridge.signal.get_group( + evt.sender.username, portal.chat_id, portal.revision + ) + except Exception: + evt.log.exception("Failed to get_group(%s) for manual bridging.", portal.chat_id) + if is_logged_in: + return await evt.reply( + "Failed to get info of signal chat. You are logged in, are you in that chat?" + ) + else: + return await evt.reply( + "Failed to get info of signal chat. " + "You're not logged in, this should not happen." + ) + + portal.mxid = room_id + portal.by_mxid[portal.mxid] = portal + ( + portal.title, + portal.about, + levels, + portal.encrypted, + portal.photo_id, + ) = await get_initial_state(evt.az.intent, evt.room_id) + await portal.save() + await portal.update_bridge_info() + + asyncio.create_task(portal.update_matrix_room(evt.sender, group)) + + await warn_missing_power(levels, evt) + + return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.") + + async def get_initial_state( intent: IntentAPI, room_id: RoomID ) -> tuple[str | None, str | None, PowerLevelStateEventContent | None, bool, ContentURI | None]: @@ -380,3 +561,17 @@ async def get_initial_state( # Some state event probably has empty content pass return title, about, levels, encrypted, avatar_url + + +async def warn_missing_power(levels: PowerLevelStateEventContent, evt: CommandEvent) -> None: + bot_pl = levels.get_user_level(evt.az.bot_mxid) + if bot_pl < levels.get_event_level(EventType.ROOM_POWER_LEVELS): + await evt.reply(missing_power_warning.format(bot_mxid=evt.az.bot_mxid)) + elif bot_pl <= 50: + await evt.reply(low_power_warning.format(bot_mxid=evt.az.bot_mxid)) + if levels.state_default < 50 and ( + levels.events[EventType.ROOM_NAME] >= 50 + or levels.events[EventType.ROOM_AVATAR] >= 50 + or levels.events[EventType.ROOM_TOPIC] >= 50 + ): + await evt.reply(meta_power_warning) diff --git a/mautrix_signal/portal.py b/mautrix_signal/portal.py index 569dc6cb..1fbef78f 100644 --- a/mautrix_signal/portal.py +++ b/mautrix_signal/portal.py @@ -1509,6 +1509,15 @@ async def create_signal_group( await self.update() await self.update_bridge_info() + async def bridge_signal_group( + self, source: u.User, levels: PowerLevelStateEventContent + ) -> None: + await self._postinit() + await self.insert() + await self.handle_matrix_power_level(source, levels) + await self.update() + await self.update_bridge_info() + # endregion # region Updating portal info diff --git a/mautrix_signal/util/__init__.py b/mautrix_signal/util/__init__.py index 04ecab18..99251778 100644 --- a/mautrix_signal/util/__init__.py +++ b/mautrix_signal/util/__init__.py @@ -1,3 +1,4 @@ from .color_log import ColorFormatter from .id_to_str import id_to_str from .normalize_number import normalize_number +from .user_has_power_level import user_has_power_level diff --git a/mautrix_signal/util/user_has_power_level.py b/mautrix_signal/util/user_has_power_level.py new file mode 100644 index 00000000..56fba5d8 --- /dev/null +++ b/mautrix_signal/util/user_has_power_level.py @@ -0,0 +1,36 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +from mautrix.appservice import IntentAPI +from mautrix.errors import MatrixRequestError +from mautrix.types import EventType, RoomID + +from .. import user as u + + +async def user_has_power_level( + room_id: RoomID, intent: IntentAPI, sender: u.User, event: str +) -> bool: + if sender.is_admin: + return True + # Make sure the state store contains the power levels. + try: + await intent.get_power_levels(room_id) + except MatrixRequestError: + return False + event_type = EventType.find(f"net.maunium.signal.{event}", t_class=EventType.Class.STATE) + return await intent.state_store.has_power_level(room_id, sender.mxid, event_type)