From a0bbf0338d7a1667848f8020ca8e74de68030e86 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 27 Jan 2018 16:31:40 +0200 Subject: [PATCH] Add support for all kinds of files in both directions --- README.md | 16 ++-- mautrix_appservice/intent_api.py | 25 +++++- mautrix_telegram/portal.py | 129 +++++++++++++++++++++++++++---- mautrix_telegram/puppet.py | 2 +- mautrix_telegram/user.py | 63 ++++++++++----- 5 files changed, 189 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index fffbcdbc..06ce81b9 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,8 @@ does not do this automatically. * [x] Mentions * [x] Rich quotes * [ ] Locations (not implemented in Riot) - * [ ] Images - * [ ] Files + * [x] Images + * [x] Files * [ ] Message redactions * [ ] Presence (currently always shown as online on Telegram) * [ ] Typing notifications (may not be possible) @@ -82,12 +82,12 @@ does not do this automatically. * [x] Mentions * [x] Replies * [x] Forwards - * [ ] Images - * [ ] Locations - * [ ] Stickers - * [ ] Audio messages - * [ ] Video messages - * [ ] Documents + * [x] Images + * [x] Locations + * [ ] Stickers (only works if client supports webp, need converter) + * [x] Audio messages + * [x] Video messages + * [x] Documents * [ ] Message deletions * [ ] Message edits * [x] Avatars diff --git a/mautrix_appservice/intent_api.py b/mautrix_appservice/intent_api.py index e8971b24..a3df215d 100644 --- a/mautrix_appservice/intent_api.py +++ b/mautrix_appservice/intent_api.py @@ -16,6 +16,7 @@ import re import json import magic +import urllib.request from matrix_client.api import MatrixHttpApi from matrix_client.errors import MatrixRequestError @@ -166,10 +167,16 @@ class IntentAPI: self._ensure_registered() return self.client.set_avatar_url(self.mxid, url) - def media_upload(self, photo_data, mime_type=None): + def upload_file(self, data, mime_type=None): self._ensure_registered() - mime_type = mime_type or magic.from_buffer(photo_data, mime=True) - return self.client.media_upload(photo_data, mime_type) + mime_type = mime_type or magic.from_buffer(data, mime=True) + return self.client.media_upload(data, mime_type) + + def download_file(self, url): + self._ensure_registered() + url = self.client.get_download_url(url) + response = urllib.request.urlopen(url) + return response.read() # endregion # region Room actions @@ -187,7 +194,6 @@ class IntentAPI: if matrix_error_code(e) != "M_FORBIDDEN": raise IntentError(f"Failed to invite {user_id} to {room_id}", e) - def set_room_avatar(self, room_id, avatar_url, info=None): content = { "url": avatar_url, @@ -211,6 +217,17 @@ class IntentAPI: def send_emote(self, room_id, text, html=None): self.send_text(room_id, text, html, "m.emote") + def send_image(self, room_id, url, info={}, text=None): + return self.send_file(room_id, url, info, text, "m.image") + + def send_file(self, room_id, url, info={}, text=None, type="m.file"): + return self.send_message(room_id, { + "msgtype": type, + "url": url, + "body": text or "Uploaded file", + "info": info, + }) + def send_text(self, room_id, text, html=None, type="m.text"): if html: return self.send_message(room_id, { diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 776a480c..7c68bf98 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -17,6 +17,8 @@ from telethon.tl.functions.messages import GetFullChatRequest from telethon.tl.functions.channels import GetParticipantsRequest from telethon.errors.rpc_error_list import ChatAdminRequiredError from telethon.tl.types import * +import mimetypes +import magic from .db import Portal as DBPortal, Message as DBMessage from . import puppet as p, formatter @@ -158,11 +160,15 @@ class Portal: return True return False + def get_largest_photo_size(self, photo): + return max(photo.sizes, key=(lambda photo: ( + len(photo.bytes) if isinstance(photo, PhotoCachedSize) else photo.size))) + def update_avatar(self, user, photo, intent=None): photo_id = f"{photo.volume_id}-{photo.local_id}" if self.photo_id != photo_id: file = user.download_file(photo) - uploaded = self.main_intent.media_upload(file) + uploaded = self.main_intent.upload_file(file) self.main_intent.set_room_avatar(self.mxid, uploaded["content_uri"]) self.photo_id = photo_id return True @@ -200,9 +206,28 @@ class Portal: reply_to=reply_to) else: response = sender.send_message(self.peer, message["body"]) - self.db.add( - DBMessage(tgid=response.id, mx_room=self.mxid, mxid=event_id, user=sender.tgid)) - self.db.commit() + elif type == "m.image" or type == "m.file": + file = self.main_intent.download_file(message["url"]) + + info = message["info"] + body = message["body"] + mime = info["mimetype"] + + extension = mimetypes.guess_extension(mime) + file_name = body if body.endswith(extension) else f"matrix_upload{extension}" + caption = None if file_name == body else body + + attributes = [DocumentAttributeFilename(file_name=file_name)] + if "w" in info and "h" in info: + attributes.append(DocumentAttributeImageSize(w=info["w"], h=info["h"])) + + response = sender.send_file(self.peer, file, mime, caption, attributes, file_name) + else: + self.log.debug("Unhandled Matrix event: %s", message) + return + self.db.add( + DBMessage(tgid=response.id, mx_room=self.mxid, mxid=event_id, user=sender.tgid)) + self.db.commit() # endregion # region Telegram event handling @@ -210,6 +235,72 @@ class Portal: def handle_telegram_typing(self, user, event): user.intent.set_typing(self.mxid, is_typing=True) + def handle_telegram_photo(self, source, sender, media): + largest_size = self.get_largest_photo_size(media.photo) + file = source.download_file(largest_size.location) + mime_type = magic.from_buffer(file, mime=True) + uploaded = sender.intent.upload_file(file, mime_type) + info = { + "h": largest_size.h, + "w": largest_size.w, + "size": len(largest_size.bytes) if ( + isinstance(largest_size, PhotoCachedSize)) else largest_size.size, + "orientation": 0, + "mimetype": mime_type, + } + name = media.caption + sender.intent.send_image(self.mxid, uploaded["content_uri"], info=info, text=name) + + def handle_telegram_document(self, source, sender, media): + file = source.download_file(media.document) + mime_type = magic.from_buffer(file, mime=True) + uploaded = sender.intent.upload_file(file, mime_type) + name = media.caption + if not name: + for attr in media.document.attributes: + if isinstance(attr, DocumentAttributeFilename): + name = attr.file_name + (mime_from_name, _) = mimetypes.guess_type(name) + mime_type = mime_from_name or mime_type + break + mime_type = media.document.mime_type or mime_type + info = { + "size": media.document.size, + "mimetype": mime_type, + } + type = "m.file" + if mime_type.startswith("video/"): + type = "m.video" + elif mime_type.startswith("audio/"): + type = "m.audio" + sender.intent.send_file(self.mxid, uploaded["content_uri"], info=info, text=name, + type=type) + + def handle_telegram_location(self, source, sender, location): + long = location.long + lat = location.lat + long_char = "E" if long > 0 else "W" + lat_char = "N" if lat > 0 else "S" + rounded_long = abs(round(long * 100000) / 100000) + rounded_lat = abs(round(lat * 100000) / 100000) + + body = f"{rounded_lat}° {lat_char}, {rounded_long}° {long_char}" + + url = f"https://maps.google.com/?q={lat},{long}" + + formatted_body = f"Location: {body}" + # At least Riot ignores formatting in m.location messages, so we'll add a plaintext link. + body = f"Location: {body}\n{url}" + + sender.intent.send_message(self.mxid, { + "msgtype": "m.location", + "geo_uri": f"geo:{lat},{long}", + "body": body, + "format": "org.matrix.custom.html", + "formatted_body": formatted_body, + }) + + def handle_telegram_message(self, source, sender, evt): if not self.mxid: self.create_room(source, invites=[source.mxid]) @@ -221,32 +312,40 @@ class Portal: self.db.add(DBMessage(tgid=evt.id, mx_room=self.mxid, mxid=response["event_id"], user=source.tgid)) self.db.commit() + elif evt.media: + if isinstance(evt.media, MessageMediaPhoto): + self.handle_telegram_photo(source, sender, evt.media) + elif isinstance(evt.media, MessageMediaDocument): + self.handle_telegram_document(source, sender, evt.media) + elif isinstance(evt.media, MessageMediaGeo): + self.handle_telegram_location(source, sender, evt.media.geo) + else: + self.log.debug("Unhandled Telegram media: %s", evt.media) else: self.log.debug("Unhandled Telegram message: %s", evt) def handle_telegram_action(self, source, sender, action): - action_type = type(action) if not self.mxid: - if action_type in {MessageActionChatCreate, MessageActionChannelCreate}: + if isinstance(action, (MessageActionChatCreate, MessageActionChannelCreate)): self.create_room(source, invites=[source.mxid]) return - if action_type == MessageActionChatEditTitle: + if isinstance(action, MessageActionChatEditTitle): if self.update_title(action.title, self.main_intent): self.save() - elif action_type == MessageActionChatEditPhoto: - largest_size = max(action.photo.sizes, key=lambda photo: photo.size) + elif isinstance(action, MessageActionChatEditPhoto): + largest_size = self.get_largest_photo_size(action.photo) if self.update_avatar(source, largest_size.location, self.main_intent): self.save() - elif action_type == MessageActionChatAddUser: - for id in action.users: - self.add_telegram_user(id, source) - elif action_type == MessageActionChatJoinedByLink: + elif isinstance(action, MessageActionChatAddUser): + for user_id in action.users: + self.add_telegram_user(user_id, source) + elif isinstance(action, MessageActionChatJoinedByLink): self.add_telegram_user(sender.id, source) - elif action_type == MessageActionChatDeleteUser: + elif isinstance(action, MessageActionChatDeleteUser): # TODO show kick message if user was kicked self.delete_telegram_user(action.user_id) - elif action_type == MessageActionChatMigrateTo: + elif isinstance(action, MessageActionChatMigrateTo): self.peer_type = "channel" self.migrate_and_save(action.channel_id) sender.intent.send_emote(self.mxid, "upgraded this group to a supergroup.") diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index 06545fa6..eb3bf370 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -92,7 +92,7 @@ class Puppet: photo_id = f"{photo.volume_id}-{photo.local_id}" if self.photo_id != photo_id: file = source.download_file(photo) - uploaded = self.intent.media_upload(file) + uploaded = self.intent.upload_file(file) self.intent.set_avatar(uploaded["content_uri"]) self.photo_id = photo_id return True diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index 48130f5f..b5f3e667 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -16,7 +16,7 @@ from io import BytesIO from telethon import TelegramClient from telethon.tl.types import * -from telethon.tl.functions.messages import SendMessageRequest +from telethon.tl.functions.messages import SendMessageRequest, SendMediaRequest from .db import User as DBUser from . import portal as po, puppet as pu @@ -126,8 +126,39 @@ class User: return self.client._get_response_message(request, result) + def send_file(self, entity, file, mime_type=None, caption=None, + attributes=None, file_name=None, reply_to=None): + entity = self.client.get_input_entity(entity) + reply_to = self.client._get_reply_to(reply_to) + + file_handle = self.client.upload_file(file, file_name=file_name, use_cache=False) + print(entity, mime_type, caption) + for a in attributes: + print(a) + print(file_handle) + + if mime_type.startswith("image/"): + media = InputMediaUploadedPhoto(file_handle, caption or "") + else: + attr_dict = {} + if attributes: + for a in attributes: + attr_dict[type(a)] = a + + media = InputMediaUploadedDocument( + file=file_handle, + mime_type=mime_type or "application/octet-stream", + attributes=list(attr_dict.values()), + caption=caption or "") + + request = SendMediaRequest(entity, media, reply_to_msg_id=reply_to) + return self.client._get_response_message(request, self.client(request)) + def download_file(self, location): - if not isinstance(location, InputFileLocation): + if isinstance(location, Document): + location = InputDocumentFileLocation(location.id, location.access_hash, + location.version) + elif not isinstance(location, (InputFileLocation, InputDocumentFileLocation)): location = InputFileLocation(location.volume_id, location.local_id, location.secret) file = BytesIO() @@ -161,36 +192,32 @@ class User: self.log.exception("Failed to handle Telegram update") def update(self, update): - update_type = type(update) - - if update_type in {UpdateShortChatMessage, UpdateShortMessage, UpdateNewMessage, - UpdateNewChannelMessage}: + if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewMessage, + UpdateNewChannelMessage)): return self.update_message(update) - elif update_type in {UpdateChatUserTyping, UpdateUserTyping}: + elif isinstance(update, (UpdateChatUserTyping, UpdateUserTyping)): return self.update_typing(update) - elif update_type == UpdateUserStatus: + elif isinstance(update, UpdateUserStatus): return self.update_status(update) else: self.log.debug("Unhandled update: %s", update) return def get_message_details(self, update): - update_type = type(update) - if update_type == UpdateShortChatMessage: + if isinstance(update, UpdateShortChatMessage): portal = po.Portal.get_by_tgid(update.chat_id, "chat") sender = pu.Puppet.get(update.from_id) - elif update_type == UpdateShortMessage: + elif isinstance(update, UpdateShortMessage): portal = po.Portal.get_by_tgid(update.user_id, "user") sender = pu.Puppet.get(self.tgid if update.out else update.user_id) - elif update_type in {UpdateNewMessage, UpdateNewChannelMessage}: + elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)): update = update.message sender = pu.Puppet.get(update.from_id) portal = po.Portal.get_by_entity(update.to_id) return update, sender, portal def update_typing(self, update): - update_type = type(update) - if update_type == UpdateUserTyping: + if isinstance(update, UpdateUserTyping): portal = po.Portal.get_by_tgid(update.user_id, "user") else: portal = po.Portal.get_by_tgid(update.chat_id, "chat") @@ -199,10 +226,9 @@ class User: def update_status(self, update): puppet = pu.Puppet.get(update.user_id) - status = type(update.status) - if status == UserStatusOnline: + if isinstance(update.status, UserStatusOnline): puppet.intent.set_presence("online") - elif status == UserStatusOffline: + elif isinstance(update.status, UserStatusOffline): puppet.intent.set_presence("offline") return @@ -211,7 +237,8 @@ class User: if isinstance(update, MessageService): if isinstance(update.action, MessageActionChannelMigrateFrom): - self.log.debug("Ignoring action %s to %d by %d", update.action, portal.tgid, sender.id) + self.log.debug("Ignoring action %s to %d by %d", update.action, portal.tgid, + sender.id) return self.log.debug("Handling action %s to %d by %d", update.action, portal.tgid, sender.id) portal.handle_telegram_action(self, sender, update.action)