Jelajahi Sumber

Update relaybot stuff

Tulir Asokan 4 tahun lalu
induk
melakukan
c988a15b38

+ 28 - 2
mautrix_signal/commands/conn.py

@@ -1,5 +1,5 @@
 # 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
 # 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
 # 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 .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,
-                 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:
     evt.sender.notice_room = evt.room_id
     await evt.sender.update()
     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,
 #                  help_text="Check if you're logged into Twitter")
 # async def ping(evt: CommandEvent) -> None:

+ 6 - 21
mautrix_signal/config.py

@@ -1,5 +1,5 @@
 # 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
 # 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.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):
@@ -99,15 +99,15 @@ class Config(BaseBridgeConfig):
 
         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:
         level = self["bridge.permissions"].get(key, "")
         admin = level == "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:
         permissions = self["bridge.permissions"]
@@ -119,18 +119,3 @@ class Config(BaseBridgeConfig):
             return self._get_permissions(homeserver)
 
         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
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2021 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
@@ -14,13 +14,12 @@
 # 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 typing import Optional, ClassVar, List, Union, TYPE_CHECKING
-from uuid import UUID
 
 from attr import dataclass
 import asyncpg
 
 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 ..util import id_to_str
@@ -42,6 +41,7 @@ class Portal:
     avatar_set: bool
     revision: int
     encrypted: bool
+    relay_user_id: Optional[UserID]
 
     @property
     def chat_id_str(self) -> str:
@@ -49,19 +49,19 @@ class Portal:
 
     async def insert(self) -> None:
         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,
                               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:
-        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")
         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.revision, self.encrypted)
+                              self.revision, self.encrypted, self.relay_user_id)
 
     @classmethod
     def _from_row(cls, row: asyncpg.Record) -> 'Portal':
@@ -74,7 +74,7 @@ class Portal:
     @classmethod
     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,"
-             "       revision, encrypted "
+             "       revision, encrypted, relay_user_id "
              "FROM portal WHERE mxid=$1")
         row = await cls.db.fetchrow(q, mxid)
         if not row:
@@ -85,7 +85,7 @@ class Portal:
     async def get_by_chat_id(cls, chat_id: Union[GroupID, Address], receiver: str = ""
                              ) -> Optional['Portal']:
         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")
         row = await cls.db.fetchrow(q, id_to_str(chat_id), receiver)
         if not row:
@@ -95,7 +95,7 @@ class Portal:
     @classmethod
     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,"
-             "       revision, encrypted "
+             "       revision, encrypted, relay_user_id "
              "FROM portal WHERE receiver=$1")
         rows = await cls.db.fetch(q, receiver)
         return [cls._from_row(row) for row in rows]
@@ -103,7 +103,7 @@ class Portal:
     @classmethod
     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,"
-             "       revision, encrypted "
+             "       revision, encrypted, relay_user_id "
              "FROM portal WHERE chat_id=$1 AND receiver<>''")
         rows = await cls.db.fetch(q, other_user.best_identifier)
         return [cls._from_row(row) for row in rows]
@@ -111,7 +111,7 @@ class Portal:
     @classmethod
     async def all_with_room(cls) -> List['Portal']:
         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")
         rows = await cls.db.fetch(q)
         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
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2021 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
@@ -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("UPDATE portal SET name_set=true WHERE name<>''")
     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.
     # Permitted values:
+    #      relay - Allowed to be relayed through the bridge, no access to commands.
     #       user - Use the bridge with puppeting.
     #      admin - Use and administrate the bridge.
     # Permitted keys:
@@ -194,26 +195,30 @@ bridge:
     #   domain - All users on that homeserver
     #     mxid - Specific user
     permissions:
+        "*": "relay"
         "example.com": "user"
         "@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:
-            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.

+ 6 - 3
mautrix_signal/matrix.py

@@ -1,5 +1,5 @@
 # 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
 # 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:
             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
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2021 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
@@ -15,8 +15,10 @@
 # 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,
                     Callable, TYPE_CHECKING, cast)
+from html import escape as escape_html
 from collections import deque
 from uuid import UUID, uuid4
+from string import Template
 import mimetypes
 import pathlib
 import hashlib
@@ -25,20 +27,16 @@ import os.path
 import time
 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,
                               Attachment, GroupID, GroupV2ID, GroupV2, Mention, Sticker,
                               GroupAccessControl, AccessControlMode, GroupMemberRole)
 from mausignald.errors import RPCError
 from mautrix.appservice import AppService, IntentAPI
 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,
-                           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 .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_lock: asyncio.Lock
     _pending_members: Optional[Set[UUID]]
+    _relay_user: Optional['u.User']
 
     def __init__(self, chat_id: Union[GroupID, Address], receiver: str,
                  mxid: Optional[RoomID] = None, name: Optional[str] = None,
                  avatar_hash: Optional[str] = None, avatar_url: Optional[ContentURI] = None,
                  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,
-                         name_set, avatar_set, revision, encrypted)
+                         name_set, avatar_set, revision, encrypted, relay_user_id)
         self._create_room_lock = asyncio.Lock()
         self.log = self.log.getChild(self.chat_id_str)
         self._main_intent = None
@@ -102,6 +101,23 @@ class Portal(DBPortal, BasePortal):
         self._last_participant_update = set()
         self._reaction_lock = asyncio.Lock()
         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
     def main_intent(self) -> IntentAPI:
@@ -195,22 +211,41 @@ class Portal(DBPortal, BasePortal):
     async def get_displayname(self, user: 'u.User') -> str:
         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:
             content.format = Format.HTML
             content.formatted_body = escape_html(content.body).replace("\n", "<br/>")
- 
+
         tpl = (self.config[f"relaybot.message_formats.[{content.msgtype.value}]"]
-               or "*$sender_displayname*: $message")
+               or "$sender_displayname: $message")
         displayname = await self.get_displayname(sender)
+        username, _ = self.az.intent.parse_user_id(sender.mxid)
         tpl_args = dict(sender_mxid=sender.mxid,
-                        sender_username=sender.mxid, #TODO
+                        sender_username=username,
                         sender_displayname=escape_html(displayname),
                         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.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,
                                     event_id: EventID) -> None:
@@ -218,18 +253,14 @@ class Portal(DBPortal, BasePortal):
              and await p.Puppet.get_by_custom_mxid(sender.mxid))):
             self.log.debug(f"Ignoring puppet-sent message by confirmed puppet user {sender.mxid}")
             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)
         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 = self._make_attachment(message, attachment_path)
             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)
         else:
             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,
                                      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
         emoji = emoji.rstrip("\ufe0f")
 
@@ -283,19 +315,6 @@ class Portal(DBPortal, BasePortal):
             self.log.debug(f"Ignoring reaction to unknown event {reacting_to}")
             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,
                                                      message.timestamp, sender.address)
         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,
                                       redaction_event_id: EventID) -> None:
-        if not self.mxid:
+        if not self.mxid or not await sender.is_logged_in():
             return
 
         # 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")
 
     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
         if self._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)
 
     async def handle_matrix_leave(self, user: 'u.User') -> None:
+        if not await user.is_logged_in():
+            return
         if self.is_direct:
             self.log.info(f"{user.mxid} left private chat portal with {self.chat_id}")
             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:
         if self.name == name or self.is_direct or not name:
             return
+        sender, is_relay = await self._get_relay_sender(user, "name change")
+        if not sender:
+            return
         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:
-            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:
             self.log.exception("Failed to update Signal group name")
             self.name = None
@@ -381,6 +406,9 @@ class Portal(DBPortal, BasePortal):
     async def handle_matrix_avatar(self, user: 'u.User', url: ContentURI) -> None:
         if self.is_direct or not url:
             return
+        sender, is_relay = await self._get_relay_sender(user, "avatar change")
+        if not sender:
+            return
 
         data = await self.main_intent.download_media(url)
         new_hash = hashlib.sha256(data).hexdigest()
@@ -390,9 +418,10 @@ class Portal(DBPortal, BasePortal):
         self.avatar_url = url
         self.avatar_hash = new_hash
         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:
-            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
         except Exception:
             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
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2021 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
@@ -48,6 +48,7 @@ class User(DBUser, BaseUser):
     loop: asyncio.AbstractEventLoop
     bridge: 'SignalBridge'
 
+    relay_whitelisted: bool
     is_admin: bool
     permission_level: str
 
@@ -63,8 +64,7 @@ class User(DBUser, BaseUser):
         self._sync_lock = asyncio.Lock()
         self._connected = False
         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
     def init_cls(cls, bridge: 'SignalBridge') -> None: