Bläddra i källkod

Update relaybot stuff

Tulir Asokan 4 år sedan
förälder
incheckning
c988a15b38

+ 28 - 2
mautrix_signal/commands/conn.py

@@ -1,5 +1,5 @@
 # mautrix-signal - A Matrix-Signal puppeting bridge
 # mautrix-signal - A Matrix-Signal puppeting bridge
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2021 Tulir Asokan
 #
 #
 # This program is free software: you can redistribute it and/or modify
 # 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
 # it under the terms of the GNU Affero General Public License as published by
@@ -13,6 +13,7 @@
 #
 #
 # You should have received a copy of the GNU Affero General Public License
 # 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/>.
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
+from mautrix.types import EventID
 from mautrix.bridge.commands import HelpSection, command_handler
 from mautrix.bridge.commands import HelpSection, command_handler
 from .typehint import CommandEvent
 from .typehint import CommandEvent
 
 
@@ -20,12 +21,37 @@ SECTION_CONNECTION = HelpSection("Connection management", 15, "")
 
 
 
 
 @command_handler(needs_auth=False, management_only=True, help_section=SECTION_CONNECTION,
 @command_handler(needs_auth=False, management_only=True, help_section=SECTION_CONNECTION,
-                 help_text="Mark this room as your bridge notice room")
+                 help_text="Mark this room as your bridge notice room.")
 async def set_notice_room(evt: CommandEvent) -> None:
 async def set_notice_room(evt: CommandEvent) -> None:
     evt.sender.notice_room = evt.room_id
     evt.sender.notice_room = evt.room_id
     await evt.sender.update()
     await evt.sender.update()
     await evt.reply("This room has been marked as your bridge notice room")
     await evt.reply("This room has been marked as your bridge notice room")
 
 
+
+@command_handler(needs_auth=True, management_only=False, help_section=SECTION_CONNECTION,
+                 help_text="Relay messages in this room through your Signal account.")
+async def set_relay(evt: CommandEvent) -> EventID:
+    if not evt.config["bridge.relay.enabled"]:
+        return await evt.reply("Relay mode is not enabled in this instance of the bridge.")
+    elif not evt.is_portal:
+        return await evt.reply("This is not a portal room.")
+    await evt.portal.set_relay_user(evt.sender)
+    return await evt.reply("Messages from non-logged-in users in this room will now be bridged "
+                           "through your Signal account.")
+
+
+@command_handler(needs_auth=True, management_only=False, help_section=SECTION_CONNECTION,
+                 help_text="Stop relaying messages in this room.")
+async def unset_relay(evt: CommandEvent) -> EventID:
+    if not evt.config["bridge.relay.enabled"]:
+        return await evt.reply("Relay mode is not enabled in this instance of the bridge.")
+    elif not evt.is_portal:
+        return await evt.reply("This is not a portal room.")
+    elif not evt.portal.has_relay:
+        return await evt.reply("This room does not have a relay user set.")
+    await evt.portal.set_relay_user(None)
+    return await evt.reply("Messages from non-logged-in users will no longer be bridged.")
+
 # @command_handler(needs_auth=False, management_only=True, help_section=SECTION_CONNECTION,
 # @command_handler(needs_auth=False, management_only=True, help_section=SECTION_CONNECTION,
 #                  help_text="Check if you're logged into Twitter")
 #                  help_text="Check if you're logged into Twitter")
 # async def ping(evt: CommandEvent) -> None:
 # async def ping(evt: CommandEvent) -> None:

+ 6 - 21
mautrix_signal/config.py

@@ -1,5 +1,5 @@
 # mautrix-signal - A Matrix-Signal puppeting bridge
 # mautrix-signal - A Matrix-Signal puppeting bridge
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2021 Tulir Asokan
 #
 #
 # This program is free software: you can redistribute it and/or modify
 # 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
 # it under the terms of the GNU Affero General Public License as published by
@@ -21,7 +21,7 @@ from mautrix.client import Client
 from mautrix.util.config import ConfigUpdateHelper, ForbiddenKey, ForbiddenDefault
 from mautrix.util.config import ConfigUpdateHelper, ForbiddenKey, ForbiddenDefault
 from mautrix.bridge.config import BaseBridgeConfig
 from mautrix.bridge.config import BaseBridgeConfig
 
 
-Permissions = NamedTuple("Permissions", user=bool, admin=bool, level=str)
+Permissions = NamedTuple("Permissions", relay=bool, user=bool, admin=bool, level=str)
 
 
 
 
 class Config(BaseBridgeConfig):
 class Config(BaseBridgeConfig):
@@ -99,15 +99,15 @@ class Config(BaseBridgeConfig):
 
 
         copy_dict("bridge.permissions")
         copy_dict("bridge.permissions")
 
 
-        copy("bridge.relaybot.enable")
-        copy("bridge.relaybot.users")
-        copy_dict("bridge.relaybot.message_formats")
+        copy("bridge.relay.enabled")
+        copy_dict("bridge.relay.message_formats")
 
 
     def _get_permissions(self, key: str) -> Permissions:
     def _get_permissions(self, key: str) -> Permissions:
         level = self["bridge.permissions"].get(key, "")
         level = self["bridge.permissions"].get(key, "")
         admin = level == "admin"
         admin = level == "admin"
         user = level == "user" or admin
         user = level == "user" or admin
-        return Permissions(user, admin, level)
+        relay = level == "relay" or user
+        return Permissions(relay, user, admin, level)
 
 
     def get_permissions(self, mxid: UserID) -> Permissions:
     def get_permissions(self, mxid: UserID) -> Permissions:
         permissions = self["bridge.permissions"]
         permissions = self["bridge.permissions"]
@@ -119,18 +119,3 @@ class Config(BaseBridgeConfig):
             return self._get_permissions(homeserver)
             return self._get_permissions(homeserver)
 
 
         return self._get_permissions("*")
         return self._get_permissions("*")
-
-    def get_relay_users(self, mxid: UserID) -> Permissions:
-        relay_users = self["bridge.relaybot.users"]
-        if not isinstance(relay_users, list):
-            return True
-        if len(relay_users) == 0:
-            return True
-        if mxid in relay_users:
-            return True
-
-        _, homeserver = Client.parse_user_id(mxid)
-        if homeserver in relay_users: 
-            return True
-
-        return False

+ 14 - 14
mautrix_signal/db/portal.py

@@ -1,5 +1,5 @@
 # mautrix-signal - A Matrix-Signal puppeting bridge
 # mautrix-signal - A Matrix-Signal puppeting bridge
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2021 Tulir Asokan
 #
 #
 # This program is free software: you can redistribute it and/or modify
 # 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
 # it under the terms of the GNU Affero General Public License as published by
@@ -14,13 +14,12 @@
 # You should have received a copy of the GNU Affero General Public License
 # 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/>.
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 from typing import Optional, ClassVar, List, Union, TYPE_CHECKING
 from typing import Optional, ClassVar, List, Union, TYPE_CHECKING
-from uuid import UUID
 
 
 from attr import dataclass
 from attr import dataclass
 import asyncpg
 import asyncpg
 
 
 from mausignald.types import Address, GroupID
 from mausignald.types import Address, GroupID
-from mautrix.types import RoomID, ContentURI
+from mautrix.types import RoomID, ContentURI, UserID
 from mautrix.util.async_db import Database
 from mautrix.util.async_db import Database
 
 
 from ..util import id_to_str
 from ..util import id_to_str
@@ -42,6 +41,7 @@ class Portal:
     avatar_set: bool
     avatar_set: bool
     revision: int
     revision: int
     encrypted: bool
     encrypted: bool
+    relay_user_id: Optional[UserID]
 
 
     @property
     @property
     def chat_id_str(self) -> str:
     def chat_id_str(self) -> str:
@@ -49,19 +49,19 @@ class Portal:
 
 
     async def insert(self) -> None:
     async def insert(self) -> None:
         q = ("INSERT INTO portal (chat_id, receiver, mxid, name, avatar_hash, avatar_url, "
         q = ("INSERT INTO portal (chat_id, receiver, mxid, name, avatar_hash, avatar_url, "
-             "                    name_set, avatar_set, revision, encrypted) "
-             "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)")
+             "                    name_set, avatar_set, revision, encrypted, relay_user_id) "
+             "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)")
         await self.db.execute(q, self.chat_id_str, self.receiver, self.mxid, self.name,
         await self.db.execute(q, self.chat_id_str, self.receiver, self.mxid, self.name,
                               self.avatar_hash, self.avatar_url, self.name_set, self.avatar_set,
                               self.avatar_hash, self.avatar_url, self.name_set, self.avatar_set,
-                              self.revision, self.encrypted)
+                              self.revision, self.encrypted, self.relay_user_id)
 
 
     async def update(self) -> None:
     async def update(self) -> None:
-        q = ("UPDATE portal SET mxid=$3, name=$4, avatar_hash=$5, avatar_url=$6, "
-             "                  name_set=$7, avatar_set=$8, revision=$9, encrypted=$10 "
+        q = ("UPDATE portal SET mxid=$3, name=$4, avatar_hash=$5, avatar_url=$6, name_set=$7, "
+             "                  avatar_set=$8, revision=$9, encrypted=$10, relay_user_id=$11 "
              "WHERE chat_id=$1 AND receiver=$2")
              "WHERE chat_id=$1 AND receiver=$2")
         await self.db.execute(q, self.chat_id_str, self.receiver, self.mxid, self.name,
         await self.db.execute(q, self.chat_id_str, self.receiver, self.mxid, self.name,
                               self.avatar_hash, self.avatar_url, self.name_set, self.avatar_set,
                               self.avatar_hash, self.avatar_url, self.name_set, self.avatar_set,
-                              self.revision, self.encrypted)
+                              self.revision, self.encrypted, self.relay_user_id)
 
 
     @classmethod
     @classmethod
     def _from_row(cls, row: asyncpg.Record) -> 'Portal':
     def _from_row(cls, row: asyncpg.Record) -> 'Portal':
@@ -74,7 +74,7 @@ class Portal:
     @classmethod
     @classmethod
     async def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']:
     async def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']:
         q = ("SELECT chat_id, receiver, mxid, name, avatar_hash, avatar_url, name_set, avatar_set,"
         q = ("SELECT chat_id, receiver, mxid, name, avatar_hash, avatar_url, name_set, avatar_set,"
-             "       revision, encrypted "
+             "       revision, encrypted, relay_user_id "
              "FROM portal WHERE mxid=$1")
              "FROM portal WHERE mxid=$1")
         row = await cls.db.fetchrow(q, mxid)
         row = await cls.db.fetchrow(q, mxid)
         if not row:
         if not row:
@@ -85,7 +85,7 @@ class Portal:
     async def get_by_chat_id(cls, chat_id: Union[GroupID, Address], receiver: str = ""
     async def get_by_chat_id(cls, chat_id: Union[GroupID, Address], receiver: str = ""
                              ) -> Optional['Portal']:
                              ) -> Optional['Portal']:
         q = ("SELECT chat_id, receiver, mxid, name, avatar_hash, avatar_url, name_set, avatar_set,"
         q = ("SELECT chat_id, receiver, mxid, name, avatar_hash, avatar_url, name_set, avatar_set,"
-             "       revision, encrypted "
+             "       revision, encrypted, relay_user_id "
              "FROM portal WHERE chat_id=$1 AND receiver=$2")
              "FROM portal WHERE chat_id=$1 AND receiver=$2")
         row = await cls.db.fetchrow(q, id_to_str(chat_id), receiver)
         row = await cls.db.fetchrow(q, id_to_str(chat_id), receiver)
         if not row:
         if not row:
@@ -95,7 +95,7 @@ class Portal:
     @classmethod
     @classmethod
     async def find_private_chats_of(cls, receiver: str) -> List['Portal']:
     async def find_private_chats_of(cls, receiver: str) -> List['Portal']:
         q = ("SELECT chat_id, receiver, mxid, name, avatar_hash, avatar_url, name_set, avatar_set,"
         q = ("SELECT chat_id, receiver, mxid, name, avatar_hash, avatar_url, name_set, avatar_set,"
-             "       revision, encrypted "
+             "       revision, encrypted, relay_user_id "
              "FROM portal WHERE receiver=$1")
              "FROM portal WHERE receiver=$1")
         rows = await cls.db.fetch(q, receiver)
         rows = await cls.db.fetch(q, receiver)
         return [cls._from_row(row) for row in rows]
         return [cls._from_row(row) for row in rows]
@@ -103,7 +103,7 @@ class Portal:
     @classmethod
     @classmethod
     async def find_private_chats_with(cls, other_user: Address) -> List['Portal']:
     async def find_private_chats_with(cls, other_user: Address) -> List['Portal']:
         q = ("SELECT chat_id, receiver, mxid, name, avatar_hash, avatar_url, name_set, avatar_set,"
         q = ("SELECT chat_id, receiver, mxid, name, avatar_hash, avatar_url, name_set, avatar_set,"
-             "       revision, encrypted "
+             "       revision, encrypted, relay_user_id "
              "FROM portal WHERE chat_id=$1 AND receiver<>''")
              "FROM portal WHERE chat_id=$1 AND receiver<>''")
         rows = await cls.db.fetch(q, other_user.best_identifier)
         rows = await cls.db.fetch(q, other_user.best_identifier)
         return [cls._from_row(row) for row in rows]
         return [cls._from_row(row) for row in rows]
@@ -111,7 +111,7 @@ class Portal:
     @classmethod
     @classmethod
     async def all_with_room(cls) -> List['Portal']:
     async def all_with_room(cls) -> List['Portal']:
         q = ("SELECT chat_id, receiver, mxid, name, avatar_hash, avatar_url, name_set, avatar_set,"
         q = ("SELECT chat_id, receiver, mxid, name, avatar_hash, avatar_url, name_set, avatar_set,"
-             "       revision, encrypted "
+             "       revision, encrypted, relay_user_id "
              "FROM portal WHERE mxid IS NOT NULL")
              "FROM portal WHERE mxid IS NOT NULL")
         rows = await cls.db.fetch(q)
         rows = await cls.db.fetch(q)
         return [cls._from_row(row) for row in rows]
         return [cls._from_row(row) for row in rows]

+ 6 - 1
mautrix_signal/db/upgrade.py

@@ -1,5 +1,5 @@
 # mautrix-signal - A Matrix-Signal puppeting bridge
 # mautrix-signal - A Matrix-Signal puppeting bridge
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2021 Tulir Asokan
 #
 #
 # This program is free software: you can redistribute it and/or modify
 # 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
 # it under the terms of the GNU Affero General Public License as published by
@@ -132,3 +132,8 @@ async def upgrade_v6(conn: Connection) -> None:
     await conn.execute("ALTER TABLE portal ADD COLUMN revision INTEGER NOT NULL DEFAULT 0")
     await conn.execute("ALTER TABLE portal ADD COLUMN revision INTEGER NOT NULL DEFAULT 0")
     await conn.execute("UPDATE portal SET name_set=true WHERE name<>''")
     await conn.execute("UPDATE portal SET name_set=true WHERE name<>''")
     await conn.execute("UPDATE portal SET avatar_set=true WHERE avatar_hash<>''")
     await conn.execute("UPDATE portal SET avatar_set=true WHERE avatar_hash<>''")
+
+
+@upgrade_table.register(description="Add relay user field to portal table")
+async def upgrade_v7(conn: Connection) -> None:
+    await conn.execute("ALTER TABLE portal ADD COLUMN relay_user_id TEXT")

+ 21 - 16
mautrix_signal/example-config.yaml

@@ -187,6 +187,7 @@ bridge:
 
 
     # Permissions for using the bridge.
     # Permissions for using the bridge.
     # Permitted values:
     # Permitted values:
+    #      relay - Allowed to be relayed through the bridge, no access to commands.
     #       user - Use the bridge with puppeting.
     #       user - Use the bridge with puppeting.
     #      admin - Use and administrate the bridge.
     #      admin - Use and administrate the bridge.
     # Permitted keys:
     # Permitted keys:
@@ -194,26 +195,30 @@ bridge:
     #   domain - All users on that homeserver
     #   domain - All users on that homeserver
     #     mxid - Specific user
     #     mxid - Specific user
     permissions:
     permissions:
+        "*": "relay"
         "example.com": "user"
         "example.com": "user"
         "@admin:example.com": "admin"
         "@admin:example.com": "admin"
 
 
-    relaybot:
-        enable: false
-        # The formats to use when sending messages to Signal via the relaybot.
-        # (markdown/html is not supported by signal yet.)
+    relay:
+        # Whether or not relay mode should be allowed. If allowed, `!signal set-relay` can be used to turn any
+        # authenticated user into a relaybot for that chat.
+        enabled: false
+        # The formats to use when sending messages to Signal via a relay user.
+        #
+        # Available variables:
+        #   $sender_displayname - The display name of the sender (e.g. Example User)
+        #   $sender_username    - The username (Matrix ID localpart) of the sender (e.g. exampleuser)
+        #   $sender_mxid        - The Matrix ID of the sender (e.g. @exampleuser:example.com)
+        #   $message            - The message content
         message_formats:
         message_formats:
-            m.text: '*{{ .Sender.Displayname }}*: {{ .Message }}'
-            m.notice: '*{{ .Sender.Displayname }}*: {{ .Message }}'
-            m.emote: '*{{ .Sender.Displayname }}* {{ .Message }}'
-            m.file: '*{{ .Sender.Displayname }}* sent a file'
-            m.image: '*{{ .Sender.Displayname }}* sent an image'
-            m.audio: '*{{ .Sender.Displayname }}* sent an audio file'
-            m.video: '*{{ .Sender.Displayname }}* sent a video'
-            m.location: '*{{ .Sender.Displayname }}* sent a location'
-        # Users that may function as relaybot, means messages in signal are sent by these users.
-        # If empty all users may function as relaybot. Only the last reaction can be handled by signal.
-        users:
-            - '@signal_relaybot:example.com'
+            m.text: '$sender_displayname: $message'
+            m.notice: '$sender_displayname: $message'
+            m.emote: '* $sender_displayname $message'
+            m.file: '$sender_displayname sent a file'
+            m.image: '$sender_displayname  sent an image'
+            m.audio: '$sender_displayname  sent an audio file'
+            m.video: '$sender_displayname  sent a video'
+            m.location: '$sender_displayname  sent a location'
 
 
 
 
 # Python logging configuration.
 # Python logging configuration.

+ 6 - 3
mautrix_signal/matrix.py

@@ -1,5 +1,5 @@
 # mautrix-signal - A Matrix-Signal puppeting bridge
 # mautrix-signal - A Matrix-Signal puppeting bridge
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2021 Tulir Asokan
 #
 #
 # This program is free software: you can redistribute it and/or modify
 # 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
 # it under the terms of the GNU Affero General Public License as published by
@@ -163,5 +163,8 @@ class MatrixHandler(BaseMatrixHandler):
         elif evt.type == EventType.ROOM_AVATAR:
         elif evt.type == EventType.ROOM_AVATAR:
             await portal.handle_matrix_avatar(user, evt.content.url)
             await portal.handle_matrix_avatar(user, evt.content.url)
 
 
-    async def allow_bridging_message(self, user: 'BaseUser', portal: 'BasePortal') -> bool:
-        return self.config['bridge.relaybot.enable'] or await user.is_logged_in()
+    async def allow_message(self, user: 'u.User') -> bool:
+        return user.relay_whitelisted
+
+    async def allow_bridging_message(self, user: 'u.User', portal: 'po.Portal') -> bool:
+        return portal.has_relay or await user.is_logged_in()

+ 79 - 50
mautrix_signal/portal.py

@@ -1,5 +1,5 @@
 # mautrix-signal - A Matrix-Signal puppeting bridge
 # mautrix-signal - A Matrix-Signal puppeting bridge
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2021 Tulir Asokan
 #
 #
 # This program is free software: you can redistribute it and/or modify
 # 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
 # it under the terms of the GNU Affero General Public License as published by
@@ -15,8 +15,10 @@
 # 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 typing import (Dict, Tuple, Optional, List, Deque, Any, Union, AsyncGenerator, Awaitable, Set,
 from typing import (Dict, Tuple, Optional, List, Deque, Any, Union, AsyncGenerator, Awaitable, Set,
                     Callable, TYPE_CHECKING, cast)
                     Callable, TYPE_CHECKING, cast)
+from html import escape as escape_html
 from collections import deque
 from collections import deque
 from uuid import UUID, uuid4
 from uuid import UUID, uuid4
+from string import Template
 import mimetypes
 import mimetypes
 import pathlib
 import pathlib
 import hashlib
 import hashlib
@@ -25,20 +27,16 @@ import os.path
 import time
 import time
 import os
 import os
 
 
-# for relaybot
-from html import escape as escape_html
-from string import Template
-
 from mausignald.types import (Address, MessageData, Reaction, Quote, Group, Contact, Profile,
 from mausignald.types import (Address, MessageData, Reaction, Quote, Group, Contact, Profile,
                               Attachment, GroupID, GroupV2ID, GroupV2, Mention, Sticker,
                               Attachment, GroupID, GroupV2ID, GroupV2, Mention, Sticker,
                               GroupAccessControl, AccessControlMode, GroupMemberRole)
                               GroupAccessControl, AccessControlMode, GroupMemberRole)
 from mausignald.errors import RPCError
 from mausignald.errors import RPCError
 from mautrix.appservice import AppService, IntentAPI
 from mautrix.appservice import AppService, IntentAPI
 from mautrix.bridge import BasePortal, async_getter_lock
 from mautrix.bridge import BasePortal, async_getter_lock
-from mautrix.types import (EventID, MessageEventContent, RoomID, EventType, MessageType,
+from mautrix.types import (EventID, MessageEventContent, RoomID, EventType, MessageType, Format,
                            MessageEvent, EncryptedEvent, ContentURI, MediaMessageEventContent,
                            MessageEvent, EncryptedEvent, ContentURI, MediaMessageEventContent,
-                           UserID, TextMessageEventContent, Format, #for relaybot
-                           ImageInfo, VideoInfo, FileInfo, AudioInfo, PowerLevelStateEventContent)
+                           TextMessageEventContent, ImageInfo, VideoInfo, FileInfo, AudioInfo,
+                           PowerLevelStateEventContent, UserID)
 from mautrix.errors import MatrixError, MForbidden, IntentError
 from mautrix.errors import MatrixError, MForbidden, IntentError
 
 
 from .db import Portal as DBPortal, Message as DBMessage, Reaction as DBReaction
 from .db import Portal as DBPortal, Message as DBMessage, Reaction as DBReaction
@@ -86,14 +84,15 @@ class Portal(DBPortal, BasePortal):
     _reaction_dedup: Deque[Tuple[Address, int, str]]
     _reaction_dedup: Deque[Tuple[Address, int, str]]
     _reaction_lock: asyncio.Lock
     _reaction_lock: asyncio.Lock
     _pending_members: Optional[Set[UUID]]
     _pending_members: Optional[Set[UUID]]
+    _relay_user: Optional['u.User']
 
 
     def __init__(self, chat_id: Union[GroupID, Address], receiver: str,
     def __init__(self, chat_id: Union[GroupID, Address], receiver: str,
                  mxid: Optional[RoomID] = None, name: Optional[str] = None,
                  mxid: Optional[RoomID] = None, name: Optional[str] = None,
                  avatar_hash: Optional[str] = None, avatar_url: Optional[ContentURI] = None,
                  avatar_hash: Optional[str] = None, avatar_url: Optional[ContentURI] = None,
                  name_set: bool = False, avatar_set: bool = False, revision: int = 0,
                  name_set: bool = False, avatar_set: bool = False, revision: int = 0,
-                 encrypted: bool = False) -> None:
+                 encrypted: bool = False, relay_user_id: Optional[UserID] = None) -> None:
         super().__init__(chat_id, receiver, mxid, name, avatar_hash, avatar_url,
         super().__init__(chat_id, receiver, mxid, name, avatar_hash, avatar_url,
-                         name_set, avatar_set, revision, encrypted)
+                         name_set, avatar_set, revision, encrypted, relay_user_id)
         self._create_room_lock = asyncio.Lock()
         self._create_room_lock = asyncio.Lock()
         self.log = self.log.getChild(self.chat_id_str)
         self.log = self.log.getChild(self.chat_id_str)
         self._main_intent = None
         self._main_intent = None
@@ -102,6 +101,23 @@ class Portal(DBPortal, BasePortal):
         self._last_participant_update = set()
         self._last_participant_update = set()
         self._reaction_lock = asyncio.Lock()
         self._reaction_lock = asyncio.Lock()
         self._pending_members = None
         self._pending_members = None
+        self._relay_user = None
+
+    @property
+    def has_relay(self) -> bool:
+        return self.config["bridge.relay.enabled"] and bool(self.relay_user_id)
+
+    async def get_relay_user(self) -> Optional['u.User']:
+        if not self.has_relay:
+            return None
+        if self._relay_user is None:
+            self._relay_user = await u.User.get_by_mxid(self.relay_user_id)
+        return self._relay_user if await self._relay_user.is_logged_in() else None
+
+    async def set_relay_user(self, user: Optional['u.User']) -> None:
+        self._relay_user = user
+        self.relay_user_id = user.mxid if user else None
+        await self.save()
 
 
     @property
     @property
     def main_intent(self) -> IntentAPI:
     def main_intent(self) -> IntentAPI:
@@ -195,22 +211,41 @@ class Portal(DBPortal, BasePortal):
     async def get_displayname(self, user: 'u.User') -> str:
     async def get_displayname(self, user: 'u.User') -> str:
         return await self.main_intent.get_room_displayname(self.mxid, user.mxid) or user.mxid
         return await self.main_intent.get_room_displayname(self.mxid, user.mxid) or user.mxid
 
 
-    async def _apply_msg_format(self, sender: 'u.User', content: MessageEventContent
-                                ) -> None:
+    async def _apply_msg_format(self, sender: 'u.User', content: MessageEventContent) -> None:
         if not isinstance(content, TextMessageEventContent) or content.format != Format.HTML:
         if not isinstance(content, TextMessageEventContent) or content.format != Format.HTML:
             content.format = Format.HTML
             content.format = Format.HTML
             content.formatted_body = escape_html(content.body).replace("\n", "<br/>")
             content.formatted_body = escape_html(content.body).replace("\n", "<br/>")
- 
+
         tpl = (self.config[f"relaybot.message_formats.[{content.msgtype.value}]"]
         tpl = (self.config[f"relaybot.message_formats.[{content.msgtype.value}]"]
-               or "*$sender_displayname*: $message")
+               or "$sender_displayname: $message")
         displayname = await self.get_displayname(sender)
         displayname = await self.get_displayname(sender)
+        username, _ = self.az.intent.parse_user_id(sender.mxid)
         tpl_args = dict(sender_mxid=sender.mxid,
         tpl_args = dict(sender_mxid=sender.mxid,
-                        sender_username=sender.mxid, #TODO
+                        sender_username=username,
                         sender_displayname=escape_html(displayname),
                         sender_displayname=escape_html(displayname),
                         message=content.formatted_body,
                         message=content.formatted_body,
-                        body=content.body, formatted_body=content.formatted_body)             
+                        body=content.body,
+                        formatted_body=content.formatted_body)
         content.formatted_body = Template(tpl).safe_substitute(tpl_args)
         content.formatted_body = Template(tpl).safe_substitute(tpl_args)
         content.body = Template(tpl).safe_substitute(tpl_args)
         content.body = Template(tpl).safe_substitute(tpl_args)
+        if content.msgtype == MessageType.EMOTE:
+            content.msgtype = MessageType.TEXT
+
+    async def _get_relay_sender(self, sender: 'u.User', evt_identifier: str
+                                ) -> Tuple[Optional['u.User'], bool]:
+        if await sender.is_logged_in():
+            return sender, False
+
+        if not self.has_relay:
+            self.log.debug(f"Ignoring {evt_identifier} from non-logged-in user {sender.mxid}"
+                           " in chat with no relay user")
+            return None, True
+        relay_sender = await self.get_relay_user()
+        if not relay_sender:
+            self.log.debug(f"Ignoring {evt_identifier} from non-logged-in user {sender.mxid}: "
+                           f"relay user {self.relay_user_id} is not set up correctly")
+            return None, True
+        return relay_sender, True
 
 
     async def handle_matrix_message(self, sender: 'u.User', message: MessageEventContent,
     async def handle_matrix_message(self, sender: 'u.User', message: MessageEventContent,
                                     event_id: EventID) -> None:
                                     event_id: EventID) -> None:
@@ -218,18 +253,14 @@ class Portal(DBPortal, BasePortal):
              and await p.Puppet.get_by_custom_mxid(sender.mxid))):
              and await p.Puppet.get_by_custom_mxid(sender.mxid))):
             self.log.debug(f"Ignoring puppet-sent message by confirmed puppet user {sender.mxid}")
             self.log.debug(f"Ignoring puppet-sent message by confirmed puppet user {sender.mxid}")
             return
             return
-        if not await sender.is_logged_in() and self.config['bridge.relaybot.enable']:
-            self.log.trace(f"Message sent by non signal-user {sender.mxid}")
-            async for user in u.User.all_logged_in():
-                await self._apply_msg_format(sender, message)
-                if await user.is_in_portal(self) and user.is_relaybot:
-                    await self._handle_matrix_message(user, message, event_id, True)
-                    return
-        else:
-            await self._handle_matrix_message(sender, message, event_id)
 
 
-    async def _handle_matrix_message(self, sender: 'u.User', message: MessageEventContent,
-            event_id: EventID, relay_sender = None) -> None:
+        orig_sender = sender
+        sender, is_relay = await self._get_relay_sender(sender, f"message {event_id}")
+        if not sender:
+            return
+        elif is_relay:
+            await self._apply_msg_format(orig_sender, message)
+
         request_id = int(time.time() * 1000)
         request_id = int(time.time() * 1000)
         self._msgts_dedup.appendleft((sender.address, request_id))
         self._msgts_dedup.appendleft((sender.address, request_id))
 
 
@@ -249,10 +280,7 @@ class Portal(DBPortal, BasePortal):
             attachment_path = await self._download_matrix_media(message)
             attachment_path = await self._download_matrix_media(message)
             attachment = self._make_attachment(message, attachment_path)
             attachment = self._make_attachment(message, attachment_path)
             attachments = [attachment]
             attachments = [attachment]
-            text = None
-            if relay_sender:
-                self.log.trace(f"Format text for relay")
-                text = message.body
+            text = message.body if is_relay else None
             self.log.trace("Formed outgoing attachment %s", attachment)
             self.log.trace("Formed outgoing attachment %s", attachment)
         else:
         else:
             self.log.debug(f"Unknown msgtype {message.msgtype} in Matrix message {event_id}")
             self.log.debug(f"Unknown msgtype {message.msgtype} in Matrix message {event_id}")
@@ -275,6 +303,10 @@ class Portal(DBPortal, BasePortal):
 
 
     async def handle_matrix_reaction(self, sender: 'u.User', event_id: EventID,
     async def handle_matrix_reaction(self, sender: 'u.User', event_id: EventID,
                                      reacting_to: EventID, emoji: str) -> None:
                                      reacting_to: EventID, emoji: str) -> None:
+        if not await sender.is_logged_in():
+            self.log.trace(f"Ignoring reaction by non-logged-in user {sender.mxid}")
+            return
+
         # Signal doesn't seem to use variation selectors at all
         # Signal doesn't seem to use variation selectors at all
         emoji = emoji.rstrip("\ufe0f")
         emoji = emoji.rstrip("\ufe0f")
 
 
@@ -283,19 +315,6 @@ class Portal(DBPortal, BasePortal):
             self.log.debug(f"Ignoring reaction to unknown event {reacting_to}")
             self.log.debug(f"Ignoring reaction to unknown event {reacting_to}")
             return
             return
 
 
-        if not await sender.is_logged_in() and self.config['bridge.relaybot.enable']:
-            self.log.trace(f"Reaction by non signal-user {sender.mxid}")
-            react_permitted = False
-            async for user in u.User.all_logged_in():
-                if await user.is_in_portal(self) and user.is_relaybot:
-                    self.log.trace(f"Set new sender to {user.mxid}")
-                    react_permitted = True
-                    sender=user
-                    break
-            if not react_permitted:
-                self.log.debug(f"Reaction not permitted.")
-                return
-
         existing = await DBReaction.get_by_signal_id(self.chat_id, self.receiver, message.sender,
         existing = await DBReaction.get_by_signal_id(self.chat_id, self.receiver, message.sender,
                                                      message.timestamp, sender.address)
                                                      message.timestamp, sender.address)
         if existing and existing.emoji == emoji:
         if existing and existing.emoji == emoji:
@@ -316,7 +335,7 @@ class Portal(DBPortal, BasePortal):
 
 
     async def handle_matrix_redaction(self, sender: 'u.User', event_id: EventID,
     async def handle_matrix_redaction(self, sender: 'u.User', event_id: EventID,
                                       redaction_event_id: EventID) -> None:
                                       redaction_event_id: EventID) -> None:
-        if not self.mxid:
+        if not self.mxid or not await sender.is_logged_in():
             return
             return
 
 
         # TODO message redactions after https://gitlab.com/signald/signald/-/issues/37
         # TODO message redactions after https://gitlab.com/signald/signald/-/issues/37
@@ -336,7 +355,7 @@ class Portal(DBPortal, BasePortal):
                 self.log.exception("Removing reaction failed")
                 self.log.exception("Removing reaction failed")
 
 
     async def handle_matrix_join(self, user: 'u.User') -> None:
     async def handle_matrix_join(self, user: 'u.User') -> None:
-        if self.is_direct:
+        if self.is_direct or not await user.is_logged_in():
             return
             return
         if self._pending_members is None:
         if self._pending_members is None:
             self.log.debug(f"{user.mxid} ({user.uuid}) joined room, but pending_members is None,"
             self.log.debug(f"{user.mxid} ({user.uuid}) joined room, but pending_members is None,"
@@ -357,6 +376,8 @@ class Portal(DBPortal, BasePortal):
                 await self.update_info(user, resp)
                 await self.update_info(user, resp)
 
 
     async def handle_matrix_leave(self, user: 'u.User') -> None:
     async def handle_matrix_leave(self, user: 'u.User') -> None:
+        if not await user.is_logged_in():
+            return
         if self.is_direct:
         if self.is_direct:
             self.log.info(f"{user.mxid} left private chat portal with {self.chat_id}")
             self.log.info(f"{user.mxid} left private chat portal with {self.chat_id}")
             if user.username == self.receiver:
             if user.username == self.receiver:
@@ -370,10 +391,14 @@ class Portal(DBPortal, BasePortal):
     async def handle_matrix_name(self, user: 'u.User', name: str) -> None:
     async def handle_matrix_name(self, user: 'u.User', name: str) -> None:
         if self.name == name or self.is_direct or not name:
         if self.name == name or self.is_direct or not name:
             return
             return
+        sender, is_relay = await self._get_relay_sender(user, "name change")
+        if not sender:
+            return
         self.name = name
         self.name = name
-        self.log.debug(f"{user.mxid} changed the group name, sending to Signal")
+        self.log.debug(f"{user.mxid} changed the group name, "
+                       f"sending to Signal through {sender.username}")
         try:
         try:
-            await self.signal.update_group(user.username, self.chat_id, title=name)
+            await self.signal.update_group(sender.username, self.chat_id, title=name)
         except Exception:
         except Exception:
             self.log.exception("Failed to update Signal group name")
             self.log.exception("Failed to update Signal group name")
             self.name = None
             self.name = None
@@ -381,6 +406,9 @@ class Portal(DBPortal, BasePortal):
     async def handle_matrix_avatar(self, user: 'u.User', url: ContentURI) -> None:
     async def handle_matrix_avatar(self, user: 'u.User', url: ContentURI) -> None:
         if self.is_direct or not url:
         if self.is_direct or not url:
             return
             return
+        sender, is_relay = await self._get_relay_sender(user, "avatar change")
+        if not sender:
+            return
 
 
         data = await self.main_intent.download_media(url)
         data = await self.main_intent.download_media(url)
         new_hash = hashlib.sha256(data).hexdigest()
         new_hash = hashlib.sha256(data).hexdigest()
@@ -390,9 +418,10 @@ class Portal(DBPortal, BasePortal):
         self.avatar_url = url
         self.avatar_url = url
         self.avatar_hash = new_hash
         self.avatar_hash = new_hash
         path = self._write_outgoing_file(data)
         path = self._write_outgoing_file(data)
-        self.log.debug(f"{user.mxid} changed the group avatar, sending to Signal")
+        self.log.debug(f"{user.mxid} changed the group avatar, "
+                       f"sending to Signal through {sender.username}")
         try:
         try:
-            await self.signal.update_group(user.username, self.chat_id, avatar_path=path)
+            await self.signal.update_group(sender.username, self.chat_id, avatar_path=path)
             self.avatar_set = True
             self.avatar_set = True
         except Exception:
         except Exception:
             self.log.exception("Failed to update Signal group avatar")
             self.log.exception("Failed to update Signal group avatar")

+ 3 - 3
mautrix_signal/user.py

@@ -1,5 +1,5 @@
 # mautrix-signal - A Matrix-Signal puppeting bridge
 # mautrix-signal - A Matrix-Signal puppeting bridge
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2021 Tulir Asokan
 #
 #
 # This program is free software: you can redistribute it and/or modify
 # 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
 # it under the terms of the GNU Affero General Public License as published by
@@ -48,6 +48,7 @@ class User(DBUser, BaseUser):
     loop: asyncio.AbstractEventLoop
     loop: asyncio.AbstractEventLoop
     bridge: 'SignalBridge'
     bridge: 'SignalBridge'
 
 
+    relay_whitelisted: bool
     is_admin: bool
     is_admin: bool
     permission_level: str
     permission_level: str
 
 
@@ -63,8 +64,7 @@ class User(DBUser, BaseUser):
         self._sync_lock = asyncio.Lock()
         self._sync_lock = asyncio.Lock()
         self._connected = False
         self._connected = False
         perms = self.config.get_permissions(mxid)
         perms = self.config.get_permissions(mxid)
-        self.is_whitelisted, self.is_admin, self.permission_level = perms
-        self.is_relaybot = self.config.get_relay_users(mxid)
+        self.relay_whitelisted, self.is_whitelisted, self.is_admin, self.permission_level = perms
 
 
     @classmethod
     @classmethod
     def init_cls(cls, bridge: 'SignalBridge') -> None:
     def init_cls(cls, bridge: 'SignalBridge') -> None: