Pārlūkot izejas kodu

Merge remote-tracking branch 'MaximilianGaedig/master'

Tulir Asokan 2 gadi atpakaļ
vecāks
revīzija
34ad1bc81e

+ 197 - 12
mautrix_signal/commands/signal.py

@@ -15,6 +15,8 @@
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 from __future__ import annotations
 from __future__ import annotations
 
 
+from typing import Awaitable
+import asyncio
 import base64
 import base64
 import json
 import json
 
 
@@ -25,7 +27,7 @@ from mautrix.bridge.commands import SECTION_ADMIN, HelpSection, command_handler
 from mautrix.types import ContentURI, EventID, EventType, PowerLevelStateEventContent, RoomID
 from mautrix.types import ContentURI, EventID, EventType, PowerLevelStateEventContent, RoomID
 
 
 from .. import portal as po, puppet as pu
 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 .auth import make_qr
 from .typehint import CommandEvent
 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)
         await portal.main_intent.invite_user(portal.mxid, evt.sender.mxid)
         return
         return
+
     await portal.create_matrix_room(evt.sender, puppet.address)
     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")
     await evt.reply(f"Created a portal room with {_pill(puppet)} and invited you to it")
 
 
@@ -337,22 +340,190 @@ async def create(evt: CommandEvent) -> EventID:
         receiver="",
         receiver="",
         avatar_url=avatar_url,
         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 portal.create_signal_group(evt.sender, levels)
     await evt.reply(f"Signal chat created. ID: {portal.chat_id}")
     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="<signal chat ID> [matrix room ID]",
+)
+async def bridge(evt: CommandEvent) -> EventID:
+    if len(evt.args) == 0:
+        return await evt.reply(
+            "**Usage:** `$cmdprefix+sp bridge <signal chat ID> [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.")
+
+    portal = await po.Portal.get_by_chat_id(GroupID(evt.args[0]), create=True)
+    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)")
+    elif evt.args[0] == "unbridge-and-continue":
+        return True, portal.cleanup_portal(
+            "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) -> 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:
+            await evt.reply("Cleaning up previous portal room...")
+            await coro
+    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(
 async def get_initial_state(
     intent: IntentAPI, room_id: RoomID
     intent: IntentAPI, room_id: RoomID
 ) -> tuple[str | None, str | None, PowerLevelStateEventContent | None, bool, ContentURI | None]:
 ) -> tuple[str | None, str | None, PowerLevelStateEventContent | None, bool, ContentURI | None]:
@@ -380,3 +551,17 @@ async def get_initial_state(
             # Some state event probably has empty content
             # Some state event probably has empty content
             pass
             pass
     return title, about, levels, encrypted, avatar_url
     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)

+ 9 - 0
mautrix_signal/portal.py

@@ -1752,6 +1752,15 @@ class Portal(DBPortal, BasePortal):
         await self.update()
         await self.update()
         await self.update_bridge_info()
         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
     # endregion
     # region Updating portal info
     # region Updating portal info
 
 

+ 1 - 0
mautrix_signal/util/__init__.py

@@ -1,3 +1,4 @@
 from .color_log import ColorFormatter
 from .color_log import ColorFormatter
 from .id_to_str import id_to_str
 from .id_to_str import id_to_str
 from .normalize_number import normalize_number
 from .normalize_number import normalize_number
+from .user_has_power_level import user_has_power_level

+ 36 - 0
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 <https://www.gnu.org/licenses/>.
+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)