Selaa lähdekoodia

Merge pull request #214 from mautrix/sumner/bri-1875

link previews: support Signal <-> Beeper
Sumner Evans 3 vuotta sitten
vanhempi
sitoutus
9b43207506
3 muutettua tiedostoa jossa 95 lisäystä ja 0 poistoa
  1. 4 0
      mausignald/signald.py
  2. 10 0
      mausignald/types.py
  3. 81 0
      mautrix_signal/portal.py

+ 4 - 0
mausignald/signald.py

@@ -24,6 +24,7 @@ from .types import (
     GroupID,
     GroupV2,
     IncomingMessage,
+    LinkPreview,
     LinkSession,
     Mention,
     Profile,
@@ -212,12 +213,14 @@ class SignaldClient(SignaldRPCClient):
         quote: Quote | None = None,
         attachments: list[Attachment] | None = None,
         mentions: list[Mention] | None = None,
+        previews: list[LinkPreview] | None = None,
         timestamp: int | None = None,
         req_id: UUID | None = None,
     ) -> None:
         serialized_quote = quote.serialize() if quote else None
         serialized_attachments = [attachment.serialize() for attachment in (attachments or [])]
         serialized_mentions = [mention.serialize() for mention in (mentions or [])]
+        serialized_previews = [preview.serialize() for preview in (previews or [])]
         resp = await self.request_v1(
             "send",
             username=username,
@@ -225,6 +228,7 @@ class SignaldClient(SignaldRPCClient):
             attachments=serialized_attachments,
             quote=serialized_quote,
             mentions=serialized_mentions,
+            previews=serialized_previews,
             timestamp=timestamp,
             req_id=req_id,
             **self._recipient_to_args(recipient),

+ 10 - 0
mausignald/types.py

@@ -346,6 +346,14 @@ class SharedContact(SerializableAttrs):
     address: Optional[SharedContactAddress] = None
 
 
+@dataclass
+class LinkPreview(SerializableAttrs):
+    url: str
+    title: str
+    description: str
+    attachment: Optional[Attachment] = None
+
+
 @dataclass
 class MessageData(SerializableAttrs):
     timestamp: int
@@ -368,6 +376,8 @@ class MessageData(SerializableAttrs):
 
     remote_delete: Optional[RemoteDelete] = field(default=None, json="remoteDelete")
 
+    previews: List[LinkPreview] = field(factory=lambda: [])
+
     @property
     def is_message(self) -> bool:
         return bool(self.body or self.attachments or self.sticker or self.contacts)

+ 81 - 0
mautrix_signal/portal.py

@@ -38,6 +38,7 @@ from mausignald.types import (
     GroupMemberRole,
     GroupV2,
     GroupV2ID,
+    LinkPreview,
     Mention,
     MessageData,
     Profile,
@@ -54,6 +55,7 @@ from mautrix.types import (
     AudioInfo,
     ContentURI,
     EncryptedEvent,
+    EncryptedFile,
     EventID,
     EventType,
     FileInfo,
@@ -106,6 +108,8 @@ StateBridge = EventType.find("m.bridge", EventType.Class.STATE)
 StateHalfShotBridge = EventType.find("uk.half-shot.bridge", EventType.Class.STATE)
 ChatInfo = Union[Group, GroupV2, GroupV2ID, Contact, Profile, Address]
 MAX_MATRIX_MESSAGE_SIZE = 60000
+BEEPER_LINK_PREVIEWS_KEY = "com.beeper.linkpreviews"
+BEEPER_IMAGE_ENCRYPTION_KEY = "beeper:image:encryption"
 
 
 class UnknownReactionTarget(Exception):
@@ -315,6 +319,32 @@ class Portal(DBPortal, BasePortal):
                 ),
             )
 
+    async def _beeper_link_preview_to_signal(
+        self, beeper_link_preview: dict[str, Any]
+    ) -> LinkPreview | None:
+        link_preview = LinkPreview(
+            url=beeper_link_preview["matched_url"],
+            title=beeper_link_preview.get("og:title", ""),
+            description=beeper_link_preview.get("og:description", ""),
+        )
+        if BEEPER_IMAGE_ENCRYPTION_KEY in beeper_link_preview or "og:image" in beeper_link_preview:
+            if BEEPER_IMAGE_ENCRYPTION_KEY in beeper_link_preview:
+                file = EncryptedFile.deserialize(beeper_link_preview[BEEPER_IMAGE_ENCRYPTION_KEY])
+                data = await self.main_intent.download_media(file.url)
+                data = decrypt_attachment(data, file.key.key, file.hashes.get("sha256"), file.iv)
+            else:
+                data = await self.main_intent.download_media(beeper_link_preview["og:image"])
+
+            attachment_path = self._write_outgoing_file(data)
+            link_preview.attachment = Attachment(
+                content_type=beeper_link_preview.get("og:image:type"),
+                outgoing_filename=attachment_path,
+                width=beeper_link_preview.get("og:image:width", 0),
+                height=beeper_link_preview.get("og:image:height", 0),
+                size=beeper_link_preview.get("matrix:image:size", 0),
+            )
+        return link_preview
+
     async def _handle_matrix_message(
         self, sender: u.User, message: MessageEventContent, event_id: EventID
     ) -> None:
@@ -349,8 +379,14 @@ class Portal(DBPortal, BasePortal):
         attachments: list[Attachment] | None = None
         attachment_path: str | None = None
         mentions: list[Mention] | None = None
+        link_previews: list[LinkPreview] | None = None
         if message.msgtype.is_text:
             text, mentions = await matrix_to_signal(message)
+            message_previews = message.get(BEEPER_LINK_PREVIEWS_KEY, [])
+            potential_link_previews = await asyncio.gather(
+                *(self._beeper_link_preview_to_signal(m) for m in message_previews)
+            )
+            link_previews = [p for p in potential_link_previews if p is not None]
         elif message.msgtype.is_media:
             attachment_path = await self._download_matrix_media(message)
             attachment = await self._make_attachment(message, attachment_path)
@@ -373,6 +409,7 @@ class Portal(DBPortal, BasePortal):
                 recipient=self.chat_id,
                 body=text,
                 mentions=mentions,
+                previews=link_previews,
                 quote=quote,
                 attachments=attachments,
                 timestamp=request_id,
@@ -744,6 +781,45 @@ class Portal(DBPortal, BasePortal):
         except MatrixError:
             return reply_msg.mxid
 
+    async def _signal_link_preview_to_beeper(
+        self, link_preview: LinkPreview, intent: IntentAPI
+    ) -> dict[str, Any]:
+        beeper_link_preview: dict[str, Any] = {
+            "matched_url": link_preview.url,
+            "og:title": link_preview.title,
+            "og:url": link_preview.url,
+            "og:description": link_preview.description,
+        }
+
+        # Upload an image corresponding to the link preview if it exists.
+        if link_preview.attachment and link_preview.attachment.incoming_filename:
+            beeper_link_preview["og:image:type"] = link_preview.attachment.content_type
+            beeper_link_preview["og:image:height"] = link_preview.attachment.height
+            beeper_link_preview["og:image:width"] = link_preview.attachment.width
+            beeper_link_preview["matrix:image:size"] = link_preview.attachment.size
+
+            with open(link_preview.attachment.incoming_filename, "rb") as file:
+                data = file.read()
+            if self.config["signal.remove_file_after_handling"]:
+                os.remove(link_preview.attachment.incoming_filename)
+
+            upload_mime_type = link_preview.attachment.content_type
+            if self.encrypted and encrypt_attachment:
+                data, beeper_link_preview[BEEPER_IMAGE_ENCRYPTION_KEY] = encrypt_attachment(data)
+                upload_mime_type = "application/octet-stream"
+
+            upload_uri = await intent.upload_media(
+                data, mime_type=upload_mime_type, filename=link_preview.attachment.id
+            )
+            if BEEPER_IMAGE_ENCRYPTION_KEY in beeper_link_preview:
+                beeper_link_preview[BEEPER_IMAGE_ENCRYPTION_KEY].url = upload_uri
+                beeper_link_preview[BEEPER_IMAGE_ENCRYPTION_KEY] = beeper_link_preview[
+                    BEEPER_IMAGE_ENCRYPTION_KEY
+                ].serialize()
+            else:
+                beeper_link_preview["og:image"] = upload_uri
+        return beeper_link_preview
+
     async def handle_signal_message(
         self, source: u.User, sender: p.Puppet, message: MessageData
     ) -> None:
@@ -833,6 +909,11 @@ class Portal(DBPortal, BasePortal):
 
         if message.body:
             content = await signal_to_matrix(message)
+            if message.previews:
+                content[BEEPER_LINK_PREVIEWS_KEY] = await asyncio.gather(
+                    *(self._signal_link_preview_to_beeper(p, intent) for p in message.previews)
+                )
+
             if reply_to:
                 content.set_reply(reply_to)
             event_id = await self._send_message(intent, content, timestamp=message.timestamp)