mirror of
https://github.com/mautrix/telegram.git
synced 2026-05-17 07:25:46 +03:00
Add support for all kinds of files in both directions
This commit is contained in:
16
README.md
16
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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user