Add support for all kinds of files in both directions

This commit is contained in:
Tulir Asokan
2018-01-27 16:31:40 +02:00
parent 9c6fbb8bc5
commit a0bbf0338d
5 changed files with 189 additions and 46 deletions

View File

@@ -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

View File

@@ -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, {

View File

@@ -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: <a href='{url}'>{body}</a>"
# 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.")

View File

@@ -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

View File

@@ -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)