From 0eace205ad77683b1b55ba5cf91d80674b0371d2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 23 Jan 2018 22:14:26 +0200 Subject: [PATCH] Add avatar syncing and join/leave bridging --- README.md | 7 +- mautrix_appservice/intent_api.py | 17 +++++ mautrix_telegram/db.py | 2 + mautrix_telegram/portal.py | 127 ++++++++++++++++++------------- mautrix_telegram/puppet.py | 28 ++++++- mautrix_telegram/user.py | 27 +++++-- 6 files changed, 142 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 3693b944..589f9f5f 100644 --- a/README.md +++ b/README.md @@ -89,19 +89,20 @@ does not do this automatically. * [ ] Video messages * [ ] Documents * [ ] Message deletions + * [ ] Message edits + * [x] Avatars * [x] Presence * [x] Typing notifications * [ ] Pinning messages * [ ] Admin status * [ ] Membership actions * [ ] Inviting - * [ ] Kicking - * [ ] Joining/leaving + * [ ] Kicking (currently shown as leaving) + * [x] Joining/leaving * [x] Chat metadata changes * [ ] Public channel username changes * [x] Initial chat metadata * [ ] Supergroup upgrade - * [ ] Message edits * Initiating chats * [x] Automatic portal creation for groups/channels at startup * [ ] Automatic portal creation for groups/channels when receiving invite/message diff --git a/mautrix_appservice/intent_api.py b/mautrix_appservice/intent_api.py index aaf0dba5..9b4a8435 100644 --- a/mautrix_appservice/intent_api.py +++ b/mautrix_appservice/intent_api.py @@ -162,6 +162,10 @@ class IntentAPI: self._ensure_registered() return self.client.set_presence(status) + def set_avatar(self, url): + self._ensure_registered() + return self.client.set_avatar_url(self.mxid, url) + def media_upload(self, photo_data, mime_type=None): self._ensure_registered() mime_type = mime_type or magic.from_buffer(photo_data, mime=True) @@ -175,6 +179,15 @@ class IntentAPI: self._ensure_registered() return self.client.create_room(alias, is_public, name, topic, is_direct, invitees) + def invite(self, room_id, user_id): + self._ensure_joined(room_id) + try: + return self.client.invite_user(room_id, user_id) + except MatrixRequestError as e: + 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, @@ -222,6 +235,10 @@ class IntentAPI: def join_room(self, room_id): return self._ensure_joined(room_id, ignore_cache=True) + def leave_room(self, room_id): + self.memberships[room_id] = "left" + return self.client.leave_room(room_id) + def get_room_members(self, room_id): return self.client.get_room_members(room_id) diff --git a/mautrix_telegram/db.py b/mautrix_telegram/db.py index 7510b85f..a4135c24 100644 --- a/mautrix_telegram/db.py +++ b/mautrix_telegram/db.py @@ -36,6 +36,7 @@ class Portal(Base): class Message(Base): __tablename__ = "message" + mxid = Column(String) mx_room = Column(String) tgid = Column(Integer, primary_key=True) @@ -58,6 +59,7 @@ class Puppet(Base): id = Column(Integer, primary_key=True) displayname = Column(String, nullable=True) username = Column(String, nullable=True) + photo_id = Column(String, nullable=True) def init(db_factory): diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index a484db00..1c8766a4 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -13,8 +13,6 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from io import BytesIO - from telethon.tl.functions.messages import GetFullChatRequest from telethon.tl.functions.channels import GetParticipantsRequest from telethon.tl.types import * @@ -25,6 +23,9 @@ config = None class Portal: + log = None + db = None + az = None by_mxid = {} by_tgid = {} @@ -35,6 +36,7 @@ class Portal: self.username = username self.title = title self.photo_id = photo_id + self._main_intent = None self.by_tgid[tgid] = self if mxid: @@ -51,17 +53,22 @@ class Portal: # region Matrix room info updating - def get_main_intent(self): - direct = self.peer_type == "user" - puppet = p.Puppet.get(self.tgid) if direct else None - return puppet.intent if direct else self.az.intent + @property + def main_intent(self): + if not self._main_intent: + direct = self.peer_type == "user" + puppet = p.Puppet.get(self.tgid) if direct else None + self._main_intent = puppet.intent if direct else self.az.intent + return self._main_intent def invite_matrix(self, users=[]): - # TODO implement - pass + if isinstance(users, str): + self.main_intent.invite(self.mxid, users) + else: + for user in users: + self.main_intent.invite(self.mxid, user) def create_room(self, user, entity=None, invites=[], update_if_exists=True): - self.log.debug("Creating room for %d", self.tgid) if not entity: entity = user.client.get_entity(self.peer) self.log.debug("Fetched data: %s", entity) @@ -70,10 +77,12 @@ class Portal: if update_if_exists: self.update_info(user, entity) users = self.get_users(user, entity) - self.sync_telegram_users(users) + self.sync_telegram_users(user, users) self.invite_matrix(invites) return self.mxid + self.log.debug("Creating room for %d", self.tgid) + try: title = entity.title except AttributeError: @@ -94,16 +103,27 @@ class Portal: if not direct: self.update_info(user, entity) users = self.get_users(user, entity) - self.sync_telegram_users(users) + self.sync_telegram_users(user, users) else: - puppet.update_info(entity) + puppet.update_info(user, entity) puppet.intent.join_room(self.mxid) - def sync_telegram_users(self, users=[]): + def sync_telegram_users(self, source, users=[]): for entity in users: - user = p.Puppet.get(entity.id) - user.update_info(entity) - user.intent.join_room(self.mxid) + puppet = p.Puppet.get(entity.id) + puppet.update_info(source, entity) + puppet.intent.join_room(self.mxid) + + def add_telegram_user(self, user_id, source=None): + puppet = p.Puppet.get(user_id) + if source: + entity = source.client.get_entity(user_id) + puppet.update_info(source, entity) + puppet.intent.join_room(self.mxid) + + def delete_telegram_user(self, user_id): + puppet = p.Puppet.get(user_id) + puppet.intent.leave_room(self.mxid) def update_info(self, user, entity=None): if self.peer_type == "user": @@ -116,22 +136,37 @@ class Portal: self.log.debug("Fetched data: %s", entity) changed = False - intent = self.get_main_intent() - if self.peer_type == "channel": if self.username != entity.username: # TODO update room alias self.username = entity.username changed = True - changed = self.update_title(entity.title, intent) or changed + changed = self.update_title(entity.title, self.main_intent) or changed if isinstance(entity.photo, ChatPhoto): - changed = self.update_avatar(user, entity.photo.photo_big, intent) or changed + changed = self.update_avatar(user, entity.photo.photo_big, self.main_intent) or changed if changed: self.save() + def update_title(self, title, intent=None): + if self.title != title: + self.title = title + self.main_intent.set_room_name(self.mxid, self.title) + return True + return False + + 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) + self.main_intent.set_room_avatar(self.mxid, uploaded["content_uri"]) + self.photo_id = photo_id + return True + return False + def get_users(self, user, entity): if self.peer_type == "chat": return user.client(GetFullChatRequest(chat_id=self.tgid)).users @@ -173,56 +208,42 @@ class Portal: def handle_telegram_message(self, source, sender, evt): if not self.mxid: - self.create_room(self, invites=[source.mxid]) + self.create_room(source, invites=[source.mxid]) - self.log.debug("Sending %s to %s by %d", evt.message, self.mxid, sender.id) if evt.message: + self.log.debug("Sending %s to %s by %d", evt.message, self.mxid, sender.id) text, html = formatter.telegram_event_to_matrix(evt, source) response = sender.intent.send_text(self.mxid, text, html=html) self.db.add(DBMessage(tgid=evt.id, mx_room=self.mxid, mxid=response["event_id"], user=source.tgid)) self.db.commit() + 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}: + self.create_room(source, invites=[source.mxid]) return - intent = self.get_main_intent() - action_type = type(action) if action_type == MessageActionChatEditTitle: - if self.update_title(action.title, intent): + 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) - if self.update_avatar(source, largest_size.location, intent): + if self.update_avatar(source, largest_size.location, self.main_intent): self.save() - - def update_title(self, title, intent=None): - if self.title != title: - self.title = title - intent = intent or self.get_main_intent() - intent.set_room_name(self.mxid, self.title) - return True - return False - - def update_avatar(self, user, photo, intent=None): - photo_id = f"{photo.volume_id}-{photo.local_id}" - if self.photo_id != photo_id: - intent = intent or self.get_main_intent() - - file = BytesIO() - - user.client.download_file( - InputFileLocation(photo.volume_id, photo.local_id, photo.secret), file) - - uploaded = intent.media_upload(file.getvalue()) - intent.set_room_avatar(self.mxid, uploaded["content_uri"]) - - file.close() - - self.photo_id = photo_id - return True - return False + elif action_type == MessageActionChatAddUser: + for id in action.users: + self.add_telegram_user(id, source) + elif action_type == MessageActionChatJoinedByLink: + self.add_telegram_user(sender.id, source) + elif action_type == MessageActionChatDeleteUser: + # TODO show kick message if user was kicked + self.delete_telegram_user(action.user_id) + else: + self.log.debug("Unhandled Telegram action in %s: %s", self.title, action) # endregion # region Database conversion diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index 6cf276ec..06545fa6 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -14,15 +14,19 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . import re +from telethon.tl.types import UserProfilePhoto from .db import Puppet as DBPuppet config = None class Puppet: + log = None + db = None + az = None cache = {} - def __init__(self, id=None, username=None, displayname=None): + def __init__(self, id=None, username=None, displayname=None, photo_id=None): self.id = id self.localpart = config.get("bridge.alias_template", "telegram_{}").format(self.id) @@ -30,6 +34,7 @@ class Puppet: self.mxid = f"@{self.localpart}:{hs}" self.username = username self.displayname = displayname + self.photo_id = photo_id self.intent = self.az.intent.user(self.mxid) self.cache[id] = self @@ -40,11 +45,12 @@ class Puppet: def to_db(self): return self.db.merge( - DBPuppet(id=self.id, username=self.username, displayname=self.displayname)) + DBPuppet(id=self.id, username=self.username, displayname=self.displayname, + photo_id=self.photo_id)) @classmethod def from_db(cls, db_puppet): - return Puppet(db_puppet.id, db_puppet.username, db_puppet.displayname) + return Puppet(db_puppet.id, db_puppet.username, db_puppet.displayname, db_puppet.photo_id) def save(self): self.to_db() @@ -64,20 +70,34 @@ class Puppet: return name return config.get("bridge.displayname_template", "{} (Telegram)").format(name) - def update_info(self, info): + def update_info(self, source, info): changed = False if self.username != info.username: self.username = info.username changed = True + displayname = self.get_displayname(info) if displayname != self.displayname: self.intent.set_display_name(displayname) self.displayname = displayname changed = True + if isinstance(info.photo, UserProfilePhoto): + changed = self.update_avatar(source, info.photo.photo_big) + if changed: self.save() + def update_avatar(self, source, photo): + 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) + self.intent.set_avatar(uploaded["content_uri"]) + self.photo_id = photo_id + return True + return False + @classmethod def get(cls, id, create=True): try: diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index ce04d276..14e796b6 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -13,7 +13,7 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import traceback +from io import BytesIO from telethon import TelegramClient from telethon.tl.types import * from telethon.tl.functions.messages import SendMessageRequest @@ -24,6 +24,9 @@ config = None class User: + log = None + db = None + az = None by_mxid = {} by_tgid = {} @@ -82,11 +85,13 @@ class User: def update_info(self, info=None): info = info or self.client.get_me() + changed = False self.username = info.username if self.tgid != info.id: self.tgid = info.id self.by_tgid[self.tgid] = self - self.save() + if changed: + self.save() def log_out(self): self.connected = False @@ -121,6 +126,18 @@ class User: return self.client._get_response_message(request, result) + def download_file(self, location): + if not isinstance(location, InputFileLocation): + location = InputFileLocation(location.volume_id, location.local_id, location.secret) + + file = BytesIO() + + self.client.download_file(location, file) + + data = file.getvalue() + file.close() + return data + def sync_dialogs(self): dialogs = self.client.get_dialogs(limit=30) for dialog in dialogs: @@ -191,12 +208,10 @@ class User: update, sender, portal = self.get_message_details(update) if isinstance(update, MessageService): - self.log.debug("Handling action portal=%s sender=%s action=%s", portal, sender, - update.action) + self.log.debug("Handling action %s to %d by %d", update.action, portal.tgid, sender.id) portal.handle_telegram_action(self, sender, update.action) else: - self.log.debug("Handling message portal=%s sender=%s update=%s", portal, sender, - update) + self.log.debug("Handling message %s to %d by %d", update, portal.tgid, sender.tgid) portal.handle_telegram_message(self, sender, update) # endregion