diff --git a/README.md b/README.md index c8468a7f..02ba39f5 100644 --- a/README.md +++ b/README.md @@ -70,19 +70,20 @@ The bridge does not do this automatically. * [x] Images * [x] Files * [x] Message redactions - * [ ] Presence (may not be possible) - * [ ] Typing notifications (may not be possible) + * [ ] † Presence + * [ ] † Typing notifications * [ ] Pinning messages * [ ] Power level * [x] Normal chats - * [ ] Supergroups/channels + * [ ] Supergroups/channels (currently only creator level bridged) * [ ] Membership actions * [x] Inviting puppets * [ ] Inviting Matrix users who have logged in to Telegram * [x] Kicking * [ ] Joining (once room aliases have been implemented) * [x] Leaving - * [ ] Room metadata changes + * [x] Room metadata changes (name, topic, avatar) + * [x] Initial room metadata * Telegram → Matrix * [x] Plaintext messages * [x] Formatted messages @@ -108,9 +109,12 @@ The bridge does not do this automatically. * [x] Inviting * [x] Kicking * [x] Joining/leaving - * [x] Chat metadata changes - * [ ] Public channel username changes - * [x] Initial chat metadata + * [ ] Chat metadata changes + * [x] Title + * [x] Avatar + * [ ] † About text + * [ ] † Public channel username + * [x] Initial chat metadata (about text missing) * [x] Supergroup upgrade * Misc * [x] Automatic portal creation @@ -131,5 +135,9 @@ The bridge does not do this automatically. * [x] Joining chats with invite links (`join`) * [x] Creating a Telegram chat for an existing Matrix room (`create`) * [ ] Upgrading the chat of a portal room into a supergroup (`upgrade`) + * [ ] Change public/private status of supergroup/channel (`setpublic`) + * [ ] Change username of supergroup/channel (`groupname`) * [x] Getting the Telegram invite link to a Matrix room (`invitelink`) * [x] Clean up and forget a portal room (`deleteportal`) + +† Information not automatically sent from source, i.e. implementation may not be possible diff --git a/mautrix_telegram/commands.py b/mautrix_telegram/commands.py index 7bc6bed2..3a1d3b4b 100644 --- a/mautrix_telegram/commands.py +++ b/mautrix_telegram/commands.py @@ -237,7 +237,7 @@ class CommandHandler: self.reply(f"Created private chat room with {pu.Puppet.get_displayname(user, False)}") @command_handler - def invite_link(self, sender, args): + def invitelink(self, sender, args): if not sender.logged_in: return self.reply("This command requires you to be logged in.") @@ -257,7 +257,7 @@ class CommandHandler: return self.reply("You don't have the permission to create an invite link.") @command_handler - def delete_portal(self, sender, args): + def deleteportal(self, sender, args): if not sender.logged_in: return self.reply("This command requires you to be logged in.") elif not sender.is_admin: @@ -331,10 +331,13 @@ class CommandHandler: state = self.az.intent.get_room_state(self._room_id) title = None + about = None levels = None for event in state: if event["type"] == "m.room.name": title = event["content"]["name"] + elif event["type"] == "m.room.topic": + about = event["content"]["topic"] elif event["type"] == "m.room.power_levels": levels = event["content"] if not title: @@ -359,7 +362,7 @@ class CommandHandler: "group": "chat", }[type] - portal = po.Portal(tgid=None, mxid=self._room_id, title=title, peer_type=type) + portal = po.Portal(tgid=None, mxid=self._room_id, title=title, about=about, peer_type=type) try: portal.create_telegram_chat(sender, supergroup=supergroup) except ValueError as e: @@ -370,6 +373,14 @@ class CommandHandler: def upgrade(self, sender, args): self.reply("Not yet implemented.") + @command_handler + def setpublic(self, sender, args): + self.reply("Not yet implemented.") + + @command_handler + def groupname(self, sender, args): + self.reply("Not yet implemented.") + # endregion # region Command-related commands @command_handler @@ -416,9 +427,12 @@ _**Telegram actions**: commands for using the bridge to interact with Telegram._ **create** [_type_] - Create a Telegram chat of the given type for the current Matrix room. The type is either `group`, `supergroup` or `channel` (defaults to `group`). **upgrade** - Upgrade a normal Telegram group to a supergroup. -**invite_link** - Get a Telegram invite link to the current chat. -**delete_portal** - Forget the current portal room. Only works for group chats; to delete a private - chat portal, simply leave the room. +**invitelink** - Get a Telegram invite link to the current chat. +**deleteportal** - Forget the current portal room. Only works for group chats; to delete a private + chat portal, simply leave the room. +**setpublic** <_yes/no_> - Change whether or not a supergroup/channel is public. +**groupname** <_name_> - Change the username of a supergroup/channel. + To disable, use `setpublic no`. """ return self.reply(management_status + help) diff --git a/mautrix_telegram/db.py b/mautrix_telegram/db.py index 944eaa4b..4aa218b4 100644 --- a/mautrix_telegram/db.py +++ b/mautrix_telegram/db.py @@ -33,6 +33,7 @@ class Portal(Base): # Telegram chat metadata username = Column(String, nullable=True) title = Column(String, nullable=True) + about = Column(String, nullable=True) photo_id = Column(String, nullable=True) diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index 60289c99..7e219d2a 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -184,6 +184,20 @@ class MatrixHandler: sender = User.get_by_mxid(sender) portal.handle_matrix_power_levels(sender, new["users"], old["users"]) + def handle_room_meta(self, type, room, sender, content): + portal = Portal.get_by_mxid(room) + sender = User.get_by_mxid(sender) + if sender.has_full_access and portal: + handler, content_key = { + "m.room.name": (portal.handle_matrix_title, "name"), + "m.room.topic": (portal.handle_matrix_about, "topic"), + "m.room.avatar": (portal.handle_matrix_avatar, "url"), + }[type] + if content_key not in content: + # FIXME handle + pass + handler(sender, content[content_key]) + def filter_matrix_event(self, event): return (event["sender"] == self.az.bot_mxid or Puppet.get_id_from_mxid(event["sender"]) is not None) @@ -209,3 +223,5 @@ class MatrixHandler: elif type == "m.room.power_levels": self.handle_power_levels(evt["room_id"], evt["sender"], evt["content"], evt["prev_content"]) + elif type == "m.room.name" or type == "m.room.avatar" or type == "m.room.topic": + self.handle_room_meta(type, evt["room_id"], evt["sender"], evt["content"]) diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 47573f01..b0ce0e07 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -14,13 +14,9 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from telethon.tl.functions.messages import (GetFullChatRequest, EditChatAdminRequest, - CreateChatRequest, AddChatUserRequest, - ExportChatInviteRequest, DeleteChatUserRequest) -from telethon.tl.functions.channels import (GetParticipantsRequest, CreateChannelRequest, - InviteToChannelRequest, ExportInviteRequest, - LeaveChannelRequest, EditBannedRequest) -from telethon.errors.rpc_error_list import ChatAdminRequiredError, LocationInvalidError +from telethon.tl.functions.messages import * +from telethon.tl.functions.channels import * +from telethon.errors.rpc_error_list import * from telethon.tl.types import * from PIL import Image from io import BytesIO @@ -43,13 +39,14 @@ class Portal: by_tgid = {} def __init__(self, tgid, peer_type, tg_receiver=None, mxid=None, username=None, title=None, - photo_id=None): + about=None, photo_id=None): self.mxid = mxid self.tgid = tgid self.tg_receiver = tg_receiver or tgid self.peer_type = peer_type self.username = username self.title = title + self.about = about self.photo_id = photo_id self._main_intent = None @@ -77,6 +74,9 @@ class Portal: elif self.peer_type == "channel": return PeerChannel(channel_id=self.tgid) + def get_input_entity(self, user): + return user.client.get_input_entity(self.peer) + # region Matrix room info updating @property @@ -202,35 +202,49 @@ class Portal: changed = False if self.peer_type == "channel": - if self.username != entity.username: - # TODO fix aliases and enable - # if self.username: - # self.main_intent.remove_room_alias(self._get_room_alias()) - self.username = entity.username - # if self.username: - # self.main_intent.add_room_alias(self.mxid, self._get_room_alias()) - changed = True + changed = self.update_username(entity.username) or changed + # TODO update about text + # changed = self.update_about(entity.about) or changed - changed = self.update_title(entity.title, self.main_intent) or changed + changed = self.update_title(entity.title) or changed if isinstance(entity.photo, ChatPhoto): - changed = self.update_avatar(user, entity.photo.photo_big, self.main_intent) or changed + changed = self.update_avatar(user, entity.photo.photo_big) or changed if changed: self.save() - def update_title(self, title, intent=None): + def update_username(self, username): + if self.username != username: + # TODO fix aliases and enable + # if self.username: + # self.main_intent.remove_room_alias(self._get_room_alias()) + self.username = username + # if self.username: + # self.main_intent.add_room_alias(self.mxid, self._get_room_alias()) + return True + return False + + def update_about(self, about): + if self.about != about: + self.about = about + self.main_intent.set_room_topic(self.mxid, self.about) + return True + return False + + def update_title(self, title): if self.title != title: self.title = title self.main_intent.set_room_name(self.mxid, self.title) return True return False - def get_largest_photo_size(self, photo): + @staticmethod + def _get_largest_photo_size(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): + def update_avatar(self, user, photo): photo_id = f"{photo.volume_id}-{photo.local_id}" if self.photo_id != photo_id: try: @@ -265,7 +279,7 @@ class Portal: link = user.client(ExportChatInviteRequest(chat_id=self.tgid)) elif self.peer_type == "channel": link = user.client( - ExportInviteRequest(channel=user.client.get_input_entity(self.peer))) + ExportInviteRequest(channel=self.get_input_entity(user))) else: raise ValueError(f"Invalid peer type '{self.peer_type}' for invite link.") @@ -296,11 +310,11 @@ class Portal: del self.by_tgid[self.tgid_full] del self.by_mxid[self.mxid] elif source: - target = source.client.get_input_entity(PeerUser(user_id=user.tgid)) + target = user.get_input_entity(source) if self.peer_type == "chat": source.client(DeleteChatUserRequest(chat_id=self.tgid, user_id=target)) else: - channel = source.client.get_input_entity(self.peer) + channel = self.get_input_entity(source) rights = ChannelBannedRights(datetime.fromtimestamp(0), True) source.client(EditBannedRequest(channel=channel, user_id=target, @@ -308,7 +322,7 @@ class Portal: elif self.peer_type == "chat": user.client(DeleteChatUserRequest(chat_id=self.tgid, user_id=InputUserSelf())) elif self.peer_type == "channel": - channel = user.client.get_input_entity(self.peer) + channel = self.get_input_entity(user) user.client(LeaveChannelRequest(channel=channel)) def handle_matrix_message(self, sender, message, event_id): @@ -358,7 +372,7 @@ class Portal: def handle_matrix_power_levels(self, sender, new_users, old_users): for user, level in new_users.items(): - user_id = p.Puppet.get_id_by_mxid(user) + user_id = p.Puppet.get_id_from_mxid(user) if not user_id: mx_user = u.User.get_by_mxid(user, create=False) if not mx_user or not mx_user.tgid: @@ -368,6 +382,52 @@ class Portal: sender.client( EditChatAdminRequest(chat_id=self.tgid, user_id=user_id, is_admin=level >= 50)) + def handle_matrix_about(self, sender, about): + if self.peer_type not in {"channel"}: + return + channel = self.get_input_entity(sender) + sender.client(EditAboutRequest(channel=channel, about=about)) + self.about = about + self.save() + + def handle_matrix_title(self, sender, title): + if self.peer_type not in {"chat", "channel"}: + return + + if self.peer_type == "chat": + sender.client(EditChatTitleRequest(chat_id=self.tgid, title=title)) + else: + channel = self.get_input_entity(sender) + sender.client(EditTitleRequest(channel=channel, title=title)) + self.title = title + self.save() + + def handle_matrix_avatar(self, sender, url): + if self.peer_type not in {"chat", "channel"}: + # Invalid peer type + return + + file = self.main_intent.download_file(url) + mime = magic.from_buffer(file, mime=True) + ext = mimetypes.guess_extension(mime) + uploaded = sender.client.upload_file(file, file_name=f"avatar{ext}") + photo = InputChatUploadedPhoto(file=uploaded) + + if self.peer_type == "chat": + updates = sender.client(EditChatPhotoRequest(chat_id=self.tgid, photo=photo)) + else: + channel = self.get_input_entity(sender) + updates = sender.client(EditPhotoRequest(channel=channel, photo=photo)) + for update in updates.updates: + is_photo_update = (isinstance(update, UpdateNewMessage) + and isinstance(update.message, MessageService) + and isinstance(update.message.action, MessageActionChatEditPhoto)) + if is_photo_update: + loc = self._get_largest_photo_size(update.message.action.photo).location + self.photo_id = f"{loc.volume_id}-{loc.local_id}" + self.save() + break + # endregion # region Telegram chat info updating @@ -402,7 +462,7 @@ class Portal: updates = source.client(CreateChatRequest(title=self.title, users=invites)) entity = updates.chats[0] elif self.peer_type == "channel": - updates = source.client(CreateChannelRequest(title=self.title, about="", + updates = source.client(CreateChannelRequest(title=self.title, about=self.about or "", megagroup=supergroup)) entity = updates.chats[0] source.client(InviteToChannelRequest(channel=source.client.get_input_entity(entity), @@ -419,7 +479,7 @@ class Portal: if self.peer_type == "chat": source.client(AddChatUserRequest(chat_id=self.tgid, user_id=puppet.tgid, fwd_limit=0)) elif self.peer_type == "channel": - target = source.client.get_input_entity(PeerUser(user_id=puppet.tgid)) + target = puppet.get_input_entity(source) source.client(InviteToChannelRequest(channel=self.peer, users=[target])) else: raise ValueError("Invalid peer type for Telegram user invite") @@ -432,7 +492,7 @@ class Portal: 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) + 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) @@ -554,12 +614,13 @@ class Portal: if not isinstance(action, create_and_continue): return + # TODO figure out how to see changes to about text / channel username if isinstance(action, MessageActionChatEditTitle): - if self.update_title(action.title, self.main_intent): + if self.update_title(action.title): self.save() elif isinstance(action, MessageActionChatEditPhoto): - largest_size = self.get_largest_photo_size(action.photo) - if self.update_avatar(source, largest_size.location, self.main_intent): + largest_size = self._get_largest_photo_size(action.photo) + if self.update_avatar(source, largest_size.location): self.save() elif isinstance(action, MessageActionChatAddUser): for user_id in action.users: @@ -619,7 +680,7 @@ class Portal: return self.db.merge( DBPortal(tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type, mxid=self.mxid, username=self.username, title=self.title, - photo_id=self.photo_id)) + about=self.about, photo_id=self.photo_id)) def migrate_and_save(self, new_id): existing = DBPortal.query.get(self.tgid_full) @@ -643,7 +704,7 @@ class Portal: return Portal(tgid=db_portal.tgid, tg_receiver=db_portal.tg_receiver, peer_type=db_portal.peer_type, mxid=db_portal.mxid, username=db_portal.username, title=db_portal.title, - photo_id=db_portal.photo_id) + about=db_portal.about, photo_id=db_portal.photo_id) # endregion # region Class instance lookup diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index 21622f57..9dab2891 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -15,7 +15,7 @@ # 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 telethon.tl.types import UserProfilePhoto, PeerUser from telethon.errors.rpc_error_list import LocationInvalidError from .db import Puppet as DBPuppet @@ -47,6 +47,9 @@ class Puppet: def tgid(self): return self.id + def get_input_entity(self, user): + return user.client.get_input_entity(PeerUser(user_id=self.tgid)) + def to_db(self): return self.db.merge( DBPuppet(id=self.id, username=self.username, displayname=self.displayname, diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index 5388584c..d5958cd0 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -61,6 +61,9 @@ class User: def has_full_access(self): return self.logged_in and self.whitelisted + def get_input_entity(self, user): + return user.client.get_input_entity(InputUser(user_id=self.tgid)) + # region Database conversion def to_db(self):