diff --git a/README.md b/README.md index 023f39e9..3108ff18 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,8 @@ does not do this automatically. * [x] Formatted messages * [ ] Bot commands (!command -> /command) * [x] Mentions - * [ ] Locations + * [ ] Rich quotes + * [ ] Locations (not implemented in Riot) * [ ] Images * [ ] Files * [ ] Message redactions @@ -76,6 +77,8 @@ does not do this automatically. * [x] Formatted messages * [x] Bot commands (/command -> !command) * [x] Mentions + * [ ] Replies + * [ ] Forwards * [ ] Images * [ ] Locations * [ ] Stickers @@ -91,8 +94,10 @@ does not do this automatically. * [ ] Inviting * [ ] Kicking * [ ] Joining/leaving - * [ ] Chat metadata changes - * [ ] Initial chat metadata + * [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 @@ -104,6 +109,4 @@ does not do this automatically. * [ ] Creating Telegram chats for existing Matrix rooms * Misc * [ ] Use optional bot to relay messages for unauthenticated Matrix users - * [ ] Properly handle upgrading groups to supergroups - * [ ] Allow upgrading group to supergroup from Matrix - * [ ] Handle public channel username changes + * [ ] Command to upgrade chat to supergroup from Matrix diff --git a/mautrix_appservice/intent_api.py b/mautrix_appservice/intent_api.py index 035f8261..99f628ff 100644 --- a/mautrix_appservice/intent_api.py +++ b/mautrix_appservice/intent_api.py @@ -15,6 +15,7 @@ # along with this program. If not, see . import re import json +import magic from matrix_client.api import MatrixHttpApi from matrix_client.errors import MatrixRequestError @@ -44,12 +45,14 @@ class HTTPAPI(MatrixHttpApi): def intent(self, user): return IntentAPI(user, self.user(user), self, log=self.log) - def _send(self, method, path, content=None, query_params={}, headers={}): + def _send(self, method, path, content=None, query_params={}, headers={}, + api_path="/_matrix/client/r0"): if not query_params: query_params = {} query_params["user_id"] = self.identity - self.log.debug("%s %s %s", method, path, content) - return super()._send(method, path, content, query_params, headers) + log_content = content if not isinstance(content, bytes) else f"<{len(content)} bytes>" + self.log.debug("%s %s %s", method, path, log_content) + return super()._send(method, path, content, query_params, headers, api_path=api_path) def create_room(self, alias=None, is_public=False, name=None, topic=None, is_direct=False, invitees=()): @@ -157,6 +160,11 @@ class IntentAPI: self._ensure_registered() return self.client.set_presence(status) + def media_upload(self, photo_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) + def set_typing(self, room_id, is_typing=True, timeout=5000): self._ensure_joined(room_id) return self.client.set_typing(room_id, is_typing, timeout) @@ -166,6 +174,21 @@ class IntentAPI: self._ensure_registered() return self.client.create_room(alias, is_public, name, topic, is_direct, invitees) + def set_room_avatar(self, room_id, avatar_url, info=None): + content = { + "url": avatar_url, + } + if info: + content["info"] = info + self._ensure_joined(room_id) + self._ensure_has_power_level_for(room_id, "m.room.avatar") + return self.send_state_event(room_id, "m.room.avatar", content) + + def set_room_name(self, room_id, name): + self._ensure_joined(room_id) + self._ensure_has_power_level_for(room_id, "m.room.name") + return self.client.set_room_name(room_id, name) + def send_text(self, room_id, text, html=None, notice=False): if html: return self.send_message(room_id, { diff --git a/mautrix_telegram/db.py b/mautrix_telegram/db.py index d7cf1a22..a4dda1f2 100644 --- a/mautrix_telegram/db.py +++ b/mautrix_telegram/db.py @@ -23,10 +23,18 @@ from .base import Base class Portal(Base): __tablename__ = "portal" + # Telegram chat information tgid = Column(Integer, primary_key=True) peer_type = Column(String) + + # Matrix portal information mxid = Column(String, unique=True, nullable=True) + # Telegram chat metadata + username = Column(String, nullable=True) + title = Column(String, nullable=True) + photo_id = Column(String, nullable=True) + class User(Base): __tablename__ = "user" diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 8162c81d..50d9160b 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -13,9 +13,11 @@ # # 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 ChannelParticipantsRecent, PeerChat, PeerChannel, PeerUser +from telethon.tl.types import * from .db import Portal as DBPortal from . import puppet as p, formatter @@ -26,25 +28,35 @@ class Portal: by_mxid = {} by_tgid = {} - def __init__(self, tgid, peer_type, mxid=None): + def __init__(self, tgid, peer_type, mxid=None, username=None, title=None, photo_id=None): self.mxid = mxid self.tgid = tgid self.peer_type = peer_type + self.username = username + self.title = title + self.photo_id = photo_id self.by_tgid[tgid] = self if mxid: self.by_mxid[mxid] = self - def create_room(self, user, entity=None, invites=[]): + 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 + + 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) if self.mxid: + if update_if_exists: + self.update_info(user, entity) + users = self.get_users(user, entity) + self.sync_telegram_users(users) self.invite_matrix(invites) - users = self.get_users(user, entity) - self.sync_telegram_users(users) return self.mxid try: @@ -55,8 +67,9 @@ class Portal: direct = self.peer_type == "user" puppet = p.Puppet.get(self.tgid) if direct else None intent = puppet.intent if direct else self.az.intent + # TODO set room alias if public channel. room = intent.create_room(invitees=invites, name=title, - is_direct=direct) + is_direct=direct) if not room: raise Exception(f"Failed to create room for {self.tgid}") @@ -64,6 +77,7 @@ class Portal: self.by_mxid[self.mxid] = self self.save() if not direct: + self.update_info(user, entity) users = self.get_users(user, entity) self.sync_telegram_users(users) else: @@ -76,6 +90,33 @@ class Portal: user.update_info(entity) user.intent.join_room(self.mxid) + def update_info(self, user, entity=None): + if self.peer_type == "user": + self.log.warn("Called update_info() for direct chat portal %d", self.tgid) + return + + self.log.debug("Updating info of %d", self.tgid) + if not entity: + entity = user.client.get_entity(self.peer) + 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 + + if isinstance(entity.photo, ChatPhoto): + changed = self.update_avatar(user, entity.photo.photo_big, intent) or changed + + if changed: + self.save() + def handle_matrix_message(self, sender, message): type = message["msgtype"] if type == "m.text": @@ -97,6 +138,45 @@ class Portal: else: sender.intent.send_text(self.mxid, evt.message) + 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 + + def handle_telegram_action(self, source, sender, action): + intent = self.get_main_intent() + action_type = type(action) + if action_type == MessageActionChatEditTitle: + if self.update_title(action.title, 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): + self.save() + @property def peer(self): if self.peer_type == "user": @@ -121,7 +201,9 @@ class Portal: pass def to_db(self): - return self.db.merge(DBPortal(tgid=self.tgid, peer_type=self.peer_type, mxid=self.mxid)) + return self.db.merge(DBPortal(tgid=self.tgid, peer_type=self.peer_type, mxid=self.mxid, + username=self.username, title=self.title, + photo_id=self.photo_id)) def save(self): self.to_db() @@ -129,7 +211,8 @@ class Portal: @classmethod def from_db(cls, db_portal): - return Portal(db_portal.tgid, db_portal.peer_type, db_portal.mxid) + return Portal(db_portal.tgid, db_portal.peer_type, db_portal.mxid, db_portal.username, + db_portal.title, db_portal.photo_id) @classmethod def get_by_mxid(cls, mxid): @@ -165,7 +248,28 @@ class Portal: @classmethod def get_by_entity(cls, entity): - return cls.get_by_tgid(entity.id, entity.__class__.__name__.lower()) + entity_type = type(entity) + if entity_type in {Chat, ChatFull}: + type_name = "chat" + id = entity.id + elif entity_type in {PeerChat, InputPeerChat}: + type_name = "chat" + id = entity.chat_id + elif entity_type in {Channel, ChannelFull}: + type_name = "channel" + id = entity.id + elif entity_type in {PeerChannel, InputPeerChannel, InputChannel}: + type_name = "channel" + id = entity.channel_id + elif entity_type in {User, UserFull}: + type_name = "user" + id = entity.id + elif entity_type in {PeerUser, InputPeerUser, InputUser}: + type_name = "user" + id = entity.user_id + else: + raise ValueError(f"Unknown entity type {entity_type.__name__}") + return cls.get_by_tgid(id, type_name) def init(context): diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index 734c26f9..3de59c08 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -50,7 +50,8 @@ class Puppet: self.to_db() self.db.commit() - def get_displayname(self, info): + @staticmethod + def get_displayname(info): if info.first_name or info.last_name: name = " ".join([info.first_name or "", info.last_name or ""]).strip() elif info.username: diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index 35e94beb..2903d4fa 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -123,7 +123,6 @@ class User: continue portal = po.Portal.get_by_entity(entity) portal.create_room(self, entity, invites=[self.mxid]) - # portal.update_info(self, entity) def update_catch(self, update): try: @@ -153,15 +152,25 @@ class User: elif isinstance(update.status, UserStatusOffline): puppet.intent.set_presence("offline") return + elif update_type == UpdateNewMessage or update_type == UpdateNewChannelMessage: + update = update.message + sender = pu.Puppet.get(update.from_id) + portal = po.Portal.get_by_entity(update.to_id) else: self.log.debug("Unhandled update: %s", update) return if not portal.mxid: portal.create_room(self, invites=[self.mxid]) - self.log.debug("Handling message portal=%s sender=%s update=%s", portal, sender, + + if isinstance(update, MessageService): + self.log.debug("Handling action portal=%s sender=%s action=%s", portal, sender, + update.action) + portal.handle_telegram_action(self, sender, update.action) + else: + self.log.debug("Handling message portal=%s sender=%s update=%s", portal, sender, update) - portal.handle_telegram_message(sender, update) + portal.handle_telegram_message(sender, update) @classmethod def get_by_mxid(cls, mxid, create=True): diff --git a/requirements.txt b/requirements.txt index bc946534..eb6c7eb1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ aiohttp -e git+git://github.com/Cadair/matrix-python-sdk#egg=matrix_client #matrix-client ruamel.yaml +python-magic SQLAlchemy Telethon Markdown