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)