diff --git a/ROADMAP.md b/ROADMAP.md index 0203f9ec6e6a588afbf7fd13de292b0a53ad4ce6..f794a6ae35ec86966520c21e287156493512ee7b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,9 +5,9 @@ * [x] Text * [ ] Media * Android Messages → Matrix - * [ ] Message content + * [x] Message content * [x] Text - * [ ] Media + * [x] Media * [x] Message history * [x] When creating portal * [x] Missed messages @@ -15,6 +15,6 @@ * [ ] Automatic portal creation * [x] At startup * [ ] When receiving invite or message - * [ ] Provisioning API for logging in + * [x] Provisioning API for logging in * [x] Use own Matrix account for messages sent from Android Messages app * [x] E2EE in Matrix rooms diff --git a/mautrix_amp/portal.py b/mautrix_amp/portal.py index b08f87ba4a14193ca96c30d500c44ec2bc1f844f..ad8a2bedc8c1d8f559cffbf87eee3256501d070a 100644 --- a/mautrix_amp/portal.py +++ b/mautrix_amp/portal.py @@ -25,11 +25,10 @@ import random import magic from mautrix.appservice import AppService, IntentAPI -from mautrix.bridge import BasePortal, NotificationDisabler +from mautrix.bridge import BasePortal from mautrix.types import (EventID, MessageEventContent, RoomID, EventType, MessageType, - TextMessageEventContent, MediaMessageEventContent, - ContentURI, EncryptedFile) -from mautrix.util.simple_lock import SimpleLock + TextMessageEventContent, MediaMessageEventContent, ContentURI, + EncryptedFile, ImageInfo, VideoInfo, FileInfo) from .db import Portal as DBPortal, Message as DBMessage from .config import Config @@ -165,7 +164,7 @@ class Portal(DBPortal, BasePortal): return event_id = None - if evt.image: + if evt.media: content = await self._handle_remote_photo(source, intent, evt) if content: event_id = await self._send_message(intent, content, timestamp=evt.timestamp) @@ -180,8 +179,22 @@ class Portal(DBPortal, BasePortal): async def _handle_remote_photo(self, source: 'u.User', intent: IntentAPI, message: Message ) -> Optional[MediaMessageEventContent]: - # TODO - pass + data = await source.client.download(message.media.guid, message.media.key) + reuploaded = await self._reupload_remote_media(data, intent) + if reuploaded.mime_type.startswith("image/"): + info = ImageInfo(width=message.media.width, height=message.media.height) + msgtype = MessageType.IMAGE + elif reuploaded.mime_type.startswith("video/"): + info = VideoInfo(width=message.media.width, height=message.media.height) + msgtype = MessageType.VIDEO + else: + info = FileInfo() + msgtype = MessageType.FILE + info.mimetype = reuploaded.mime_type + info.size = reuploaded.size + return MediaMessageEventContent(url=reuploaded.mxc, file=reuploaded.decryption_info, + body=message.media.filename or reuploaded.file_name, + info=info, msgtype=msgtype) async def _reupload_remote_media(self, data: bytes, intent: IntentAPI) -> ReuploadedMediaInfo: upload_mime_type = mime_type = magic.from_buffer(data, mime=True) diff --git a/mautrix_amp/rpc/client.py b/mautrix_amp/rpc/client.py index a2261f155992788944d1d3ea80bf6c2ca637723a..d56c2516bfcbff266f78036a4d768b1df461bcea 100644 --- a/mautrix_amp/rpc/client.py +++ b/mautrix_amp/rpc/client.py @@ -13,9 +13,10 @@ # # 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 AsyncGenerator, TypedDict, List, Dict, Callable, Awaitable, Any +from typing import AsyncGenerator, TypedDict, Dict, Callable, Awaitable, Any from collections import deque import asyncio +import json from .rpc import RPCClient from .types import ChatInfo, Message, StartStatus @@ -49,6 +50,15 @@ class Client(RPCClient): await self.request("send", chat_id=chat_id, sender_id=sender_id, temp_id=temp_id, text=text) + async def download(self, guid: str, key: str) -> bytes: + r, w = await self._open_connection() + w.write(json.dumps({"command": "download", "id": 1, "user_id": self.user_id, + "guid": guid, "key": key}).encode("utf-8")) + w.write(b"\n") + data = await r.read() + w.close() + return data + def on_message(self, func: Callable[[Message], Awaitable[None]]) -> None: async def wrapper(data: Dict[str, Any]) -> None: await func(Message.deserialize(data["message"])) diff --git a/mautrix_amp/rpc/rpc.py b/mautrix_amp/rpc/rpc.py index 96636f733bf2636899405955220255cd91cadaa1..40b08f3a080b72b311d8a36995de5ed09d0ca176 100644 --- a/mautrix_amp/rpc/rpc.py +++ b/mautrix_amp/rpc/rpc.py @@ -13,7 +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 typing import Dict, Any, Callable, Awaitable, List, Optional +from typing import Dict, Any, Callable, Awaitable, List, Optional, Tuple import logging import asyncio import json @@ -51,19 +51,20 @@ class RPCClient: self._writer = None self._reader = None - async def connect(self) -> None: - if self._writer is not None: - return - + async def _open_connection(self) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]: if self.config["puppeteer.connection.type"] == "unix": - r, w = await asyncio.open_unix_connection(self.config["puppeteer.connection.path"]) + return await asyncio.open_unix_connection(self.config["puppeteer.connection.path"]) elif self.config["puppeteer.connection.type"] == "tcp": - r, w = await asyncio.open_connection(self.config["puppeteer.connection.host"], + return await asyncio.open_connection(self.config["puppeteer.connection.host"], self.config["puppeteer.connection.port"]) else: raise RuntimeError("invalid puppeteer connection type") - self._reader = r - self._writer = w + + async def connect(self) -> None: + if self._writer is not None: + return + + self._reader, self._writer = await self._open_connection() self.loop.create_task(self._try_read_loop()) await self.request("register", user_id=self.user_id) diff --git a/mautrix_amp/rpc/types.py b/mautrix_amp/rpc/types.py index bd56e13f6c5f1dc4396ef1abf8d61af07dd6d8f6..2b2aba6873bf860948aeb727a8af4b195f39d259 100644 --- a/mautrix_amp/rpc/types.py +++ b/mautrix_amp/rpc/types.py @@ -42,6 +42,16 @@ class ChatInfo(SerializableAttrs['ChatInfo']): readonly: bool +@dataclass +class MediaInfo(SerializableAttrs['MediaInfo']): + guid: str + key: str + filename: Optional[str] = None + size: Optional[int] = None + width: Optional[int] = None + height: Optional[int] = None + + @dataclass class Message(SerializableAttrs['Message']): id: int @@ -53,7 +63,7 @@ class Message(SerializableAttrs['Message']): temp_id: Optional[str] = None subject: Optional[str] = None text: Optional[str] = None - image: Optional[str] = None + media: Optional[MediaInfo] = None @dataclass diff --git a/puppet/dh-messages-api b/puppet/dh-messages-api index f83d735f684718e50e1e9fbdce0abe4ef2345365..082f349c400c03b524b76866ed485ef6afcce9ca 160000 --- a/puppet/dh-messages-api +++ b/puppet/dh-messages-api @@ -1 +1 @@ -Subproject commit f83d735f684718e50e1e9fbdce0abe4ef2345365 +Subproject commit 082f349c400c03b524b76866ed485ef6afcce9ca diff --git a/puppet/src/client.js b/puppet/src/client.js index 085ca2d8555dbb62fba9ae0e84e648eaee258aca..569f48b9baa4d7c79658354fa73e20b90776029a 100644 --- a/puppet/src/client.js +++ b/puppet/src/client.js @@ -120,6 +120,14 @@ export default class Client { text: item.MsgText, is_outgoing: item.MsgSentByUser, is_echo: item.SentByClient, + media: item.ImageData ? { + guid: item.ImageData[1], + filename: item.ImageData[3], + size: item.ImageData[4], + width: item.ImageData[5]?.[0], + height: item.ImageData[5]?.[1], + key: item.ImageData[10], + } : undefined, })).sort((a, b) => a.id - b.id) this.log("Sending message list with IDs", messages.map(msg => msg.id), "to client") await this._write({ @@ -256,6 +264,22 @@ export default class Client { await this.client.Connect() } + handleDownload = async req => { + this.userID = req.user_id + this.stopped = true + const client = this.manager.msgClients.get(this.userID) + if (!client) { + this.log("Couldn't find msgClient for", this.userID) + } else { + this.log("Downloading file", req.guid) + const data = await client.DownloadFile(req.guid, req.key) + this.log("Sending file", req.guid, "to client") + await promisify(cb => this.socket.write(Buffer.from(data, "base64"), cb)) + } + this.log("Disconnecting file download connection") + await promisify(cb => this.socket.end(cb)) + } + async handleLine(line) { if (this.stopped) { this.log("Ignoring line, client is stopped") @@ -278,6 +302,10 @@ export default class Client { } this.log("Received request", req.id, "with command", req.command) this.maxCommandID = req.id + if (!this.userID && req.command === "download") { + await this.handleDownload(req) + return + } let handler if (!this.userID) { if (req.command !== "register") {