瀏覽代碼

Add relay mode

Closes #27

Co-authored-by: Alejandro Herrera <bherrera@ikono.com.co>
Tulir Asokan 3 年之前
父節點
當前提交
98856dba3a

+ 3 - 2
mautrix_instagram/config.py

@@ -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):
@@ -102,7 +102,8 @@ class Config(BaseBridgeConfig):
         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"]

+ 12 - 11
mautrix_instagram/db/portal.py

@@ -18,7 +18,7 @@ from typing import Optional, ClassVar, List, TYPE_CHECKING
 from attr import dataclass
 import asyncpg
 
-from mautrix.types import RoomID, ContentURI
+from mautrix.types import RoomID, ContentURI, UserID
 from mautrix.util.async_db import Database
 
 fake_db = Database("") if TYPE_CHECKING else None
@@ -37,22 +37,23 @@ class Portal:
     encrypted: bool
     name_set: bool
     avatar_set: bool
+    relay_user_id: Optional[UserID]
 
     async def insert(self) -> None:
         q = ("INSERT INTO portal (thread_id, receiver, other_user_pk, mxid, name, avatar_url, "
-             "                    encrypted, name_set, avatar_set) "
-             "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)")
+             "                    encrypted, name_set, avatar_set, relay_user_id) "
+             "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)")
         await self.db.execute(q, self.thread_id, self.receiver, self.other_user_pk,
                               self.mxid, self.name, self.avatar_url, self.encrypted,
-                              self.name_set, self.avatar_set)
+                              self.name_set, self.avatar_set, self.relay_user_id)
 
     async def update(self) -> None:
         q = ("UPDATE portal SET other_user_pk=$3, mxid=$4, name=$5, avatar_url=$6, encrypted=$7,"
-             "                  name_set=$8, avatar_set=$9 "
+             "                  name_set=$8, avatar_set=$9, relay_user_id=$10 "
              "WHERE thread_id=$1 AND receiver=$2")
         await self.db.execute(q, self.thread_id, self.receiver, self.other_user_pk,
                               self.mxid, self.name, self.avatar_url, self.encrypted,
-                              self.name_set, self.avatar_set)
+                              self.name_set, self.avatar_set, self.relay_user_id)
 
     @classmethod
     def _from_row(cls, row: asyncpg.Record) -> 'Portal':
@@ -61,7 +62,7 @@ class Portal:
     @classmethod
     async def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']:
         q = ("SELECT thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted, "
-             "       name_set, avatar_set "
+             "       name_set, avatar_set, relay_user_id "
              "FROM portal WHERE mxid=$1")
         row = await cls.db.fetchrow(q, mxid)
         if not row:
@@ -72,7 +73,7 @@ class Portal:
     async def get_by_thread_id(cls, thread_id: str, receiver: int,
                                rec_must_match: bool = True) -> Optional['Portal']:
         q = ("SELECT thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted, "
-             "       name_set, avatar_set "
+             "       name_set, avatar_set, relay_user_id "
              "FROM portal WHERE thread_id=$1 AND receiver=$2")
         if not rec_must_match:
             q = ("SELECT thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted, "
@@ -86,7 +87,7 @@ class Portal:
     @classmethod
     async def find_private_chats_of(cls, receiver: int) -> List['Portal']:
         q = ("SELECT thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted, "
-             "       name_set, avatar_set "
+             "       name_set, avatar_set, relay_user_id "
              "FROM portal WHERE receiver=$1 AND other_user_pk IS NOT NULL")
         rows = await cls.db.fetch(q, receiver)
         return [cls._from_row(row) for row in rows]
@@ -94,7 +95,7 @@ class Portal:
     @classmethod
     async def find_private_chats_with(cls, other_user: int) -> List['Portal']:
         q = ("SELECT thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted, "
-             "       name_set, avatar_set "
+             "       name_set, avatar_set, relay_user_id "
              "FROM portal WHERE other_user_pk=$1")
         rows = await cls.db.fetch(q, other_user)
         return [cls._from_row(row) for row in rows]
@@ -102,7 +103,7 @@ class Portal:
     @classmethod
     async def all_with_room(cls) -> List['Portal']:
         q = ("SELECT thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted, "
-             "       name_set, avatar_set "
+             "       name_set, avatar_set, 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]

+ 5 - 0
mautrix_instagram/db/upgrade.py

@@ -88,3 +88,8 @@ async def upgrade_v2(conn: Connection) -> None:
     await conn.execute("ALTER TABLE portal ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false")
     await conn.execute("ALTER TABLE portal ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false")
     await conn.execute("UPDATE portal SET name_set=true WHERE name<>''")
+
+
+@upgrade_table.register(description="Add relay user field to portal table")
+async def upgrade_v3(conn: Connection) -> None:
+    await conn.execute("ALTER TABLE portal ADD COLUMN relay_user_id TEXT")

+ 19 - 0
mautrix_instagram/example-config.yaml

@@ -211,6 +211,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:
@@ -218,9 +219,27 @@ bridge:
     #   domain - All users on that homeserver
     #     mxid - Specific user
     permissions:
+        "*": "relay"
         "example.com": "user"
         "@admin:example.com": "admin"
 
+    relay:
+        # Whether relay mode should be allowed. If allowed, `!ig 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 Instagram 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
+        #
+        # Note that Instagram doesn't support captions for images, so images won't include any indication of being relayed.
+        message_formats:
+            m.text: '$sender_displayname: $message'
+            m.notice: '$sender_displayname: $message'
+            m.emote: '* $sender_displayname $message'
 
 # Python logging configuration.
 #

+ 38 - 6
mautrix_instagram/portal.py

@@ -87,9 +87,10 @@ class Portal(DBPortal, BasePortal):
     def __init__(self, thread_id: str, receiver: int, other_user_pk: Optional[int],
                  mxid: Optional[RoomID] = None, name: Optional[str] = None,
                  avatar_url: Optional[ContentURI] = None, encrypted: bool = False,
-                 name_set: bool = False, avatar_set: bool = False) -> None:
+                 name_set: bool = False, avatar_set: bool = False,
+                 relay_user_id: Optional[UserID] = None) -> None:
         super().__init__(thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted,
-                         name_set, avatar_set)
+                         name_set, avatar_set, relay_user_id)
         self._create_room_lock = asyncio.Lock()
         self.log = self.log.getChild(thread_id)
         self._msgid_dedup = deque(maxlen=100)
@@ -103,6 +104,7 @@ class Portal(DBPortal, BasePortal):
         self._main_intent = None
         self._reaction_lock = asyncio.Lock()
         self._typing = set()
+        self._relay_user = None
 
     @property
     def is_direct(self) -> bool:
@@ -189,9 +191,20 @@ class Portal(DBPortal, BasePortal):
                 msg="Fatal error in message handling (see logs for more details)",
             )
 
-    async def _handle_matrix_message(self, sender: 'u.User', message: MessageEventContent,
+    async def _handle_matrix_message(self, orig_sender: 'u.User', message: MessageEventContent,
                                      event_id: EventID) -> None:
-        if not sender.is_connected:
+        sender, is_relay = await self.get_relay_sender(orig_sender, f"message {event_id}")
+        if not sender:
+            orig_sender.send_remote_checkpoint(
+                status=MessageSendCheckpointStatus.PERM_FAILURE,
+                event_id=event_id,
+                room_id=self.mxid,
+                event_type=EventType.ROOM_MESSAGE,
+                message_type=message.msgtype,
+                error="user is not logged in",
+            )
+            return
+        elif not sender.is_connected:
             await self._send_bridge_error(
                 sender,
                 "You're not connected to Instagram",
@@ -201,6 +214,9 @@ class Portal(DBPortal, BasePortal):
                 confirmed=True,
             )
             return
+        elif is_relay:
+            await self.apply_relay_message_format(orig_sender, message)
+
         request_id = sender.state.gen_client_context()
         self._reqid_dedup.add(request_id)
         self.log.debug(f"Handling Matrix message {event_id} from {sender.mxid}/{sender.igpk} "
@@ -289,6 +305,10 @@ class Portal(DBPortal, BasePortal):
             self.log.debug(f"Ignoring reaction to unknown event {reacting_to}")
             return
 
+        if not await sender.is_logged_in():
+            self.log.debug(f"Ignoring reaction by non-logged-in user {sender.mxid}")
+            return
+
         existing = await DBReaction.get_by_item_id(message.item_id, message.receiver, sender.igpk)
         if existing and existing.reaction == emoji:
             return
@@ -322,9 +342,19 @@ class Portal(DBPortal, BasePortal):
                 await self._upsert_reaction(existing, self.main_intent, event_id, message, sender,
                                             emoji)
 
-    async def handle_matrix_redaction(self, sender: 'u.User', event_id: EventID,
+    async def handle_matrix_redaction(self, orig_sender: 'u.User', event_id: EventID,
                                       redaction_event_id: EventID) -> None:
-        if not sender.is_connected:
+        sender, _ = await self.get_relay_sender(orig_sender, f"redaction {event_id}")
+        if not sender:
+            orig_sender.send_remote_checkpoint(
+                status=MessageSendCheckpointStatus.PERM_FAILURE,
+                event_id=redaction_event_id,
+                room_id=self.mxid,
+                event_type=EventType.ROOM_REDACTION,
+                error="user is not logged in",
+            )
+            return
+        elif not sender.is_connected:
             await self._send_bridge_error(
                 sender,
                 "You're not connected to Instagram",
@@ -414,6 +444,8 @@ class Portal(DBPortal, BasePortal):
             await user.mqtt.indicate_activity(self.thread_id, status)
 
     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.other_user_pk}")
             if user.igpk == self.receiver:

+ 1 - 1
mautrix_instagram/user.py

@@ -85,7 +85,7 @@ class User(DBUser, BaseUser):
         self._notice_room_lock = asyncio.Lock()
         self._notice_send_lock = asyncio.Lock()
         perms = self.config.get_permissions(mxid)
-        self.is_whitelisted, self.is_admin, self.permission_level = perms
+        self.relay_whitelisted, self.is_whitelisted, self.is_admin, self.permission_level = perms
         self.client = None
         self.mqtt = None
         self.username = None

+ 1 - 1
requirements.txt

@@ -4,7 +4,7 @@ commonmark>=0.8,<0.10
 aiohttp>=3,<4
 yarl>=1,<2
 attrs>=20.1
-mautrix>=0.13.0,<0.14
+mautrix>=0.13.1,<0.14
 asyncpg>=0.20,<0.26
 pycryptodome>=3,<4
 paho-mqtt>=1.5,<2