瀏覽代碼

Add group create command (#250)

Malte E 3 年之前
父節點
當前提交
1be53a2f0f
共有 4 個文件被更改,包括 185 次插入5 次删除
  1. 25 0
      mausignald/signald.py
  2. 3 3
      mausignald/types.py
  3. 90 2
      mautrix_signal/commands/signal.py
  4. 67 0
      mautrix_signal/portal.py

+ 25 - 0
mausignald/signald.py

@@ -21,6 +21,7 @@ from .types import (
     ErrorMessage,
     GetIdentitiesResponse,
     Group,
+    GroupAccessControl,
     GroupID,
     GroupV2,
     IncomingMessage,
@@ -353,6 +354,7 @@ class SignaldClient(SignaldRPCClient):
         avatar_path: str | None = None,
         add_members: list[Address] | None = None,
         remove_members: list[Address] | None = None,
+        update_access_control: GroupAccessControl | None = None,
     ) -> Group | GroupV2 | None:
         update_params = {
             key: value
@@ -365,6 +367,9 @@ class SignaldClient(SignaldRPCClient):
                 "removeMembers": (
                     [addr.serialize() for addr in remove_members] if remove_members else None
                 ),
+                "updateAccessControl": (
+                    update_access_control.serialize() if update_access_control else None
+                ),
             }.items()
             if value is not None
         }
@@ -390,6 +395,26 @@ class SignaldClient(SignaldRPCClient):
             return None
         return GroupV2.deserialize(resp)
 
+    async def create_group(
+        self,
+        username: str,
+        avatar_path: str | None = None,
+        member_role_administrator: bool = False,
+        members: list[Address] | None = None,
+        title: str | None = None,
+    ) -> GroupV2 | None:
+        create_params = {
+            "avatar": avatar_path,
+            "member_role": "ADMINISTRATOR" if member_role_administrator else "DEFAULT",
+            "title": title,
+            "members": [addr.serialize() for addr in members],
+        }
+        create_params = {k: v for k, v in create_params.items() if v is not None}
+        resp = await self.request_v1("create_group", account=username, **create_params)
+        if "id" not in resp:
+            return None
+        return GroupV2.deserialize(resp)
+
     async def get_profile(
         self, username: str, address: Address, use_cache: bool = False
     ) -> Profile | None:

+ 3 - 3
mausignald/types.py

@@ -198,9 +198,9 @@ class AnnouncementsMode(SerializableEnum):
 
 @dataclass
 class GroupAccessControl(SerializableAttrs):
-    attributes: AccessControlMode = AccessControlMode.UNKNOWN
-    link: AccessControlMode = AccessControlMode.UNKNOWN
-    members: AccessControlMode = AccessControlMode.UNKNOWN
+    attributes: Optional[AccessControlMode] = AccessControlMode.UNKNOWN
+    link: Optional[AccessControlMode] = AccessControlMode.UNKNOWN
+    members: Optional[AccessControlMode] = AccessControlMode.UNKNOWN
 
 
 class GroupMemberRole(SerializableEnum):

+ 90 - 2
mautrix_signal/commands/signal.py

@@ -19,9 +19,10 @@ import base64
 import json
 
 from mausignald.errors import UnknownIdentityKey
-from mausignald.types import Address, TrustLevel
+from mausignald.types import Address, GroupID, TrustLevel
+from mautrix.appservice import IntentAPI
 from mautrix.bridge.commands import SECTION_ADMIN, HelpSection, command_handler
-from mautrix.types import EventID
+from mautrix.types import ContentURI, EventID, EventType, PowerLevelStateEventContent, RoomID
 
 from .. import portal as po, puppet as pu
 from ..util import normalize_number
@@ -287,3 +288,90 @@ async def raw(evt: CommandEvent) -> None:
             await evt.reply(
                 f"Got reply `{resp_type}`:\n\n```json\n{json.dumps(resp_data, indent=2)}\n```"
             )
+
+
+missing_power_warning = (
+    "Warning: The bridge bot ([{bot_mxid}](https://matrix.to/#/{bot_mxid})) does not have "
+    "sufficient privileges to change power levels on Matrix. Power level changes will not be "
+    "bridged."
+)
+
+low_power_warning = (
+    "Warning: The bridge bot ([{bot_mxid}](https://matrix.to/#/{bot_mxid})) has a power level "
+    "below or equal to 50. Bridged moderator rights are currently hardcoded to PL 50, so the "
+    "bridge bot must have a higher level to properly bridge them."
+)
+
+meta_power_warning = (
+    "Warning: Permissions for changing name, topic and avatar cannot be set separately on Signal. "
+    "Changes to those may not be bridged properly, unless the permissions are set to the same "
+    "level or lower than state_default."
+)
+
+
+@command_handler(
+    needs_auth=True,
+    management_only=False,
+    help_section=SECTION_SIGNAL,
+    help_text="Create a Signal group for the current Matrix room.",
+)
+async def create(evt: CommandEvent) -> EventID:
+    if evt.portal:
+        return await evt.reply("This is already a portal room.")
+
+    title, about, levels, encrypted, avatar_url = await get_initial_state(
+        evt.az.intent, evt.room_id
+    )
+
+    portal = po.Portal(
+        chat_id=GroupID(""),
+        mxid=evt.room_id,
+        name=title,
+        topic=about or "",
+        encrypted=encrypted,
+        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 portal.create_signal_group(evt.sender, levels)
+    await evt.reply(f"Signal chat created. ID: {portal.chat_id}")
+
+
+async def get_initial_state(
+    intent: IntentAPI, room_id: RoomID
+) -> tuple[str | None, str | None, PowerLevelStateEventContent | None, bool, ContentURI | None]:
+    state = await intent.get_state(room_id)
+    title: str | None = None
+    about: str | None = None
+    levels: PowerLevelStateEventContent | None = None
+    encrypted: bool = False
+    avatar_url: ContentURI | None = None
+    for event in state:
+        try:
+            if event.type == EventType.ROOM_NAME:
+                title = event.content.name
+            elif event.type == EventType.ROOM_TOPIC:
+                about = event.content.topic
+            elif event.type == EventType.ROOM_POWER_LEVELS:
+                levels = event.content
+            elif event.type == EventType.ROOM_CANONICAL_ALIAS:
+                title = title or event.content.canonical_alias
+            elif event.type == EventType.ROOM_ENCRYPTION:
+                encrypted = True
+            elif event.type == EventType.ROOM_AVATAR:
+                avatar_url = event.content.url
+        except KeyError:
+            # Some state event probably has empty content
+            pass
+    return title, about, levels, encrypted, avatar_url

+ 67 - 0
mautrix_signal/portal.py

@@ -1259,6 +1259,73 @@ class Portal(DBPortal, BasePortal):
         except MForbidden:
             await self.main_intent.redact(message.mx_room, message.mxid)
 
+    # endregion
+    # region Matrix -> Signal metadata
+
+    async def create_signal_group(
+        self, source: u.User, levels: PowerLevelStateEventContent
+    ) -> None:
+        user_mxids = await self.az.intent.get_room_members(
+            self.mxid, (Membership.JOIN, Membership.INVITE)
+        )
+        invitee_addresses = []
+        for mxid in user_mxids:
+            mx_user = await u.User.get_by_mxid(mxid, create=False)
+            if mx_user and mx_user.address and mx_user.username != source.username:
+                invitee_addresses.append(mx_user.address)
+            puppet = await p.Puppet.get_by_mxid(mxid, create=False)
+            if puppet:
+                invitee_addresses.append(puppet.address)
+        avatar_path: str | None = None
+        if self.avatar_url:
+            avatar_data = await self.az.intent.download_media(self.avatar_url)
+            self.avatar_hash = hashlib.sha256(avatar_data).hexdigest()
+            avatar_path = self._write_outgoing_file(avatar_data)
+        signal_chat = await self.signal.create_group(
+            source.username, title=self.name, members=invitee_addresses, avatar_path=avatar_path
+        )
+        self.name_set = bool(self.name and signal_chat.title)
+        self.avatar_set = bool(self.avatar_url and self.avatar_hash and signal_chat.avatar)
+        self.chat_id = signal_chat.id
+        await self._postinit()
+        await self.insert()
+        if avatar_path and self.config["signal.remove_file_after_handling"]:
+            try:
+                os.remove(avatar_path)
+            except FileNotFoundError:
+                pass
+        if self.topic:
+            await self.signal.update_group(source.username, self.chat_id, description=self.topic)
+        await self.signal.update_group(
+            username=source.username,
+            group_id=self.chat_id,
+            update_access_control=GroupAccessControl(
+                members=(
+                    AccessControlMode.MEMBER
+                    if levels.invite == 0
+                    else AccessControlMode.ADMINISTRATOR
+                ),
+                attributes=None,
+                link=None,
+            ),
+        )
+        update_meta = await self.signal.update_group(
+            username=source.username,
+            group_id=self.chat_id,
+            update_access_control=GroupAccessControl(
+                attributes=(
+                    AccessControlMode.MEMBER
+                    if levels.state_default == 0
+                    else AccessControlMode.ADMINISTRATOR
+                ),
+                members=None,
+                link=None,
+            ),
+        )
+        self.revision = update_meta.revision
+        await self.update()
+        await self.update_bridge_info()
+
     # endregion
     # region Updating portal info