From 143ca11f05c61d98d8ad1cfb18f634e4d7a65dd9 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 28 Jan 2018 17:02:32 +0200 Subject: [PATCH] Add support for inviting users and initiating chats from Matrix --- README.md | 6 +-- example-config.yaml | 2 +- mautrix_appservice/appservice.py | 2 +- mautrix_appservice/intent_api.py | 23 ++++++--- mautrix_telegram/commands.py | 54 ++++++++++++++++++-- mautrix_telegram/matrix.py | 84 ++++++++++++++++++++++++++------ mautrix_telegram/portal.py | 73 ++++++++++++++++++++++++--- mautrix_telegram/puppet.py | 1 + mautrix_telegram/user.py | 2 +- 9 files changed, 208 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 10a3bd4c..cfc6a693 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ does not do this automatically.~~ * [ ] Pinning messages * [x] Power level * [ ] Membership actions - * [ ] Inviting + * [x] Inviting * [ ] Kicking * [ ] Joining/leaving * [ ] Room metadata changes @@ -108,7 +108,7 @@ does not do this automatically.~~ * Misc * [x] Automatic portal creation for groups/channels at startup * [x] Automatic portal creation for groups/channels when receiving invite/message - * [ ] Private chat creation by inviting Telegram user to new room + * [x] Private chat creation by inviting Telegram user to new room * [ ] Use optional bot to relay messages for unauthenticated Matrix users * [ ] Joining public channels/supergroups using room aliases * Commands @@ -117,5 +117,5 @@ does not do this automatically.~~ * [x] Searching for users (`search`) * [x] Starting private chats (`pm`) * [x] Joining chats with invite links (`join`) - * [ ] Creating a Telegram chat for an existing Matrix room (`create`) + * [x] Creating a Telegram chat for an existing Matrix room (`create`) * [ ] Upgrading the chat of a portal room into a supergroup (`upgrade`) diff --git a/example-config.yaml b/example-config.yaml index f402aa2b..ad3e1e72 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -21,7 +21,7 @@ appservice: id: telegram # Username of the appservice bot. bot_username: telegrambot - bot_displayname: Mautrix-Telegram Bridge + bot_displayname: Telegram bridge bot # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify. as_token: "This value is generated when generating the registration" diff --git a/mautrix_appservice/appservice.py b/mautrix_appservice/appservice.py index 12d144a7..bb2fde5f 100644 --- a/mautrix_appservice/appservice.py +++ b/mautrix_appservice/appservice.py @@ -44,7 +44,7 @@ class StateStore: return self._set_membership(room, user, "join") def invited(self, room, user): - return self._set_membership(room, user, "invited") + return self._set_membership(room, user, "invite") def left(self, room, user): return self._set_membership(room, user, "left") diff --git a/mautrix_appservice/intent_api.py b/mautrix_appservice/intent_api.py index fc072f4b..edd37b85 100644 --- a/mautrix_appservice/intent_api.py +++ b/mautrix_appservice/intent_api.py @@ -196,7 +196,7 @@ class IntentAPI: self._ensure_joined(room_id) try: response = self.client.invite_user(room_id, user_id) - self.state_store.set_invited(room_id, user_id) + self.state_store.invited(room_id, user_id) return response except MatrixRequestError as e: if matrix_error_code(e) != "M_FORBIDDEN": @@ -249,6 +249,8 @@ class IntentAPI: def send_text(self, room_id, text, html=None, type="m.text"): if html: + if not text: + text = html return self.send_message(room_id, { "body": text, "msgtype": type, @@ -264,9 +266,14 @@ class IntentAPI: def send_message(self, room_id, body): return self.send_event(room_id, "m.room.message", body) + def error_and_leave(self, room_id, text, html=None): + self._ensure_joined(room_id) + self.send_notice(room_id, text, html=html) + self.leave_room(room_id) + def kick(self, room_id, user_id, message): self._ensure_joined(room_id) - self.client.kick_user(room_id, user_id, message) + return self.client.kick_user(room_id, user_id, message) def send_event(self, room_id, type, body, txn_id=None): self._ensure_joined(room_id) @@ -285,13 +292,17 @@ class IntentAPI: self.state_store.left(room_id, self.mxid) return self.client.leave_room(room_id) - def get_room_members(self, room_id): + def get_room_memberships(self, room_id): return self.client.get_room_members(room_id) - def get_joined_users(self, room_id): - memberships = self.get_room_members(room_id) + def get_room_members(self, room_id, allowed_memberships=("join",)): + memberships = self.get_room_memberships(room_id) return [membership["state_key"] for membership in memberships["chunk"] if - membership["content"]["membership"] == "join"] + membership["content"]["membership"] in allowed_memberships] + + def get_room_state(self, room_id): + self._ensure_joined(room_id) + return self.client.get_room_state(room_id) # endregion # region Ensure functions diff --git a/mautrix_telegram/commands.py b/mautrix_telegram/commands.py index 0f09a55e..3476246b 100644 --- a/mautrix_telegram/commands.py +++ b/mautrix_telegram/commands.py @@ -267,11 +267,56 @@ class CommandHandler: updates = sender.client(JoinChannelRequest(channel)) for chat in updates.chats: portal = po.Portal.get_by_entity(chat) - portal.create_room(sender, chat, [sender.mxid]) + portal.create_matrix_room(sender, chat, [sender.mxid]) + self.reply(f"Created room for {portal.title}") @command_handler def create(self, sender, args): - self.reply("Not yet implemented.") + type = args[0] if len(args) > 0 else "group" + if type not in {"chat", "group", "supergroup", "channel"}: + return self.reply("**Usage:** `$cmdprefix+sp create [`group`/`supergroup`/`channel`]") + elif not sender.tgid: + return self.reply("This command requires you to be logged in.") + + if po.Portal.get_by_mxid(self._room_id): + return self.reply("This is already a portal room.") + + state = self.az.intent.get_room_state(self._room_id) + title = None + levels = None + for event in state: + if event["type"] == "m.room.name": + title = event["content"]["name"] + elif event["type"] == "m.room.power_levels": + levels = event["content"] + if not title: + return self.reply("Please set a title before creating a Telegram chat.") + elif (not levels or not levels["users"] or self.az.intent.mxid not in levels["users"] or + levels["users"][self.az.intent.mxid] < 100): + return self.reply(f"Please give " + f"[the bridge bot](https://matrix.to/#/{self.az.intent.mxid}) " + f"a power level of 100 before creating a Telegram chat.") + else: + for user, level in levels["users"].items(): + if level >= 100 and user != self.az.intent.mxid: + return self.reply(f"Please make sure only the bridge bot has power level above" + f"99 before creating a Telegram chat.\n\n" + f"Use power level 95 instead of 100 for admins.") + + supergroup = type == "supergroup" + types = { + "supergroup": "channel", + "channel": "channel", + "chat": "chat", + "group": "chat", + } + + portal = po.Portal(tgid=None, mxid=self._room_id, title=title, peer_type=types[type]) + try: + portal.create_telegram_chat(sender, supergroup=supergroup) + except ValueError as e: + return self.reply(e.args[0]) + self.reply(f"Telegram chat created. ID: {portal.tgid}") @command_handler def upgrade(self, sender, args): @@ -320,9 +365,8 @@ _**Telegram actions**: commands for using the bridge to interact with Telegram._ **pm** <_identifier_> - Open a private chat with the given Telegram user. The identifier is either the internal user ID, the username or the phone number. **join** <_link_> - Join a chat with an invite link. -**create** <_group/channel_> [_room ID_] - Create a Telegram chat of the given type for a Matrix - room. If the room ID is not specified, a chat for the - current room is created. +**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. """ return self.reply(management_status + help) diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index 796bed55..ed1e24fa 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -13,10 +13,11 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import re +from matrix_client.errors import MatrixRequestError from .user import User from .portal import Portal +from .puppet import Puppet from .commands import CommandHandler @@ -26,32 +27,83 @@ class MatrixHandler: self.log = log.getChild("mx") self.commands = CommandHandler(context) - alias_format = self.config.get("bridge.alias_template", "telegram_{groupname}").format(groupname="(.+)") - hs = self.config["homeserver"]["domain"] - self.localpart_regex = re.compile(f"@{alias_format}:{hs}") - self.az.matrix_event_handler(self.handle_event) self.az.intent.set_display_name( - self.config.get("appservice.bot_displayname", "Mautrix-Telegram Bridge")) + self.config.get("appservice.bot_displayname", "Telegram bridge bot")) def is_puppet(self, mxid): - match = self.localpart_regex.match(mxid) + match = Puppet.mxid_regex.match(mxid) return True if match else False + def get_puppet(self, mxid): + match = Puppet.mxid_regex.match(mxid) + if not match: + return None + return Puppet.get(int(match.group(1))) + + def handle_puppet_invite(self, room, puppet, inviter): + self.log.debug(f"{inviter} invited puppet for {puppet.tgid} to {room}") + portal = Portal.get_by_mxid(room) + if portal: + if portal.peer_type == "user": + puppet.intent.error_and_leave( + room, text="You can not invite additional users to private chats.") + return + portal.invite_telegram(inviter, puppet) + puppet.intent.join_room(room) + return + try: + members = self.az.intent.get_room_members(room) + except MatrixRequestError: + members = [] + if self.az.intent.mxid not in members: + if len(members) > 1: + puppet.intent.error_and_leave(room, text=None, html=( + f"Please invite " + f"the bridge bot " + f"first if you want to create a Telegram chat.")) + return + + puppet.intent.join_room(room) + existing_portal = Portal.get_by_tgid(puppet.tgid, "user") + if existing_portal: + try: + puppet.intent.invite(existing_portal.mxid, inviter.mxid) + puppet.intent.send_notice(room, text=None, html=( + "You already have a private chat with me: " + f"" + "Link to room" + "")) + puppet.intent.leave_room(room) + return + except MatrixRequestError: + existing_portal.delete() + + portal = Portal(tgid=puppet.tgid, peer_type="user", mxid=room) + portal.save() + puppet.intent.send_notice(room, "Portal to private chat created.") + else: + puppet.intent.join_room(room) + puppet.intent.send_notice(room, "This puppet will remain inactive until a Telegram " + "chat is created for this room.") + def handle_invite(self, room, user, inviter): + inviter = User.get_by_mxid(inviter) if user == self.az.bot_mxid: self.az.intent.join_room(room) return - tgid = self.get_puppet(user) - if tgid: - # TODO handle puppet invite - self.log.debug(f"{inviter} invited puppet for {tgid} to {room}") + elif not inviter.tgid: + return + puppet = self.get_puppet(user) + if puppet: + self.handle_puppet_invite(room, puppet, inviter) return # These can probably be ignored self.log.debug(f"{inviter} invited {user} to {room}") def handle_part(self, room, user): self.log.debug(f"{user} left {room}") + user = User.get_by_mxid(user, create=False) def is_command(self, message): text = message.get("body", "") @@ -68,14 +120,14 @@ class MatrixHandler: sender = User.get_by_mxid(sender) portal = Portal.get_by_mxid(room) - if portal and not is_command: + if sender.tgid and portal and not is_command: portal.handle_matrix_message(sender, message, event_id) return if message["msgtype"] != "m.text": return - is_management = len(self.az.intent.get_joined_users(room)) == 2 + is_management = len(self.az.intent.get_room_members(room)) == 2 if is_command or is_management: try: command, arguments = text.split(" ", 1) @@ -90,12 +142,13 @@ class MatrixHandler: def handle_redaction(self, room, sender, event_id): portal = Portal.get_by_mxid(room) sender = User.get_by_mxid(sender) - if portal: + if sender.tgid and portal: portal.handle_matrix_deletion(sender, event_id) def handle_power_levels(self, room, sender, new, old): portal = Portal.get_by_mxid(room) - if portal: + sender = User.get_by_mxid(sender) + if sender.tgid and portal: sender = User.get_by_mxid(sender) portal.handle_matrix_power_levels(sender, new["users"], old["users"]) @@ -115,6 +168,7 @@ class MatrixHandler: elif membership == "leave": self.handle_part(evt["room_id"], evt["state_key"]) elif membership == "join": + # TODO handle when needed pass elif type == "m.room.message": self.handle_message(evt["room_id"], evt["sender"], content, evt["event_id"]) diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 319a38fd..222f081b 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -13,8 +13,10 @@ # # 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 -from telethon.tl.functions.channels import GetParticipantsRequest +from telethon.tl.functions.messages import (GetFullChatRequest, EditChatAdminRequest, + CreateChatRequest, AddChatUserRequest) +from telethon.tl.functions.channels import (GetParticipantsRequest, CreateChannelRequest, + InviteToChannelRequest) from telethon.errors.rpc_error_list import ChatAdminRequiredError from telethon.tl.types import * from PIL import Image @@ -43,7 +45,8 @@ class Portal: self.photo_id = photo_id self._main_intent = None - self.by_tgid[tgid] = self + if tgid: + self.by_tgid[tgid] = self if mxid: self.by_mxid[mxid] = self @@ -73,7 +76,7 @@ class Portal: for user in users: self.main_intent.invite(self.mxid, user) - def create_room(self, user, entity=None, invites=[], update_if_exists=True): + def create_matrix_room(self, user, entity=None, invites=[], update_if_exists=True): if not entity: entity = user.client.get_entity(self.peer) self.log.debug("Fetched data: %s", entity) @@ -277,6 +280,59 @@ class Portal: sender.client( EditChatAdminRequest(chat_id=self.tgid, user_id=user_id, is_admin=level >= 50)) + # endregion + # region Telegram chat info updating + + def _get_telegram_users_in_matrix_room(self): + user_tgids = set() + user_mxids = self.main_intent.get_room_members(self.mxid, ("join", "invite")) + for user in user_mxids: + if user == self.az.intent.mxid: + continue + mx_user = u.User.get_by_mxid(user, create=False) + if mx_user and mx_user.tgid: + user_tgids.add(mx_user.tgid) + puppet_match = p.Puppet.mxid_regex.match(user) + if puppet_match: + user_tgids.add(int(puppet_match.group(1))) + return user_tgids + + def create_telegram_chat(self, source, supergroup=False): + if not self.mxid: + raise ValueError("Can't create Telegram chat for portal without Matrix room.") + elif self.tgid: + raise ValueError("Can't create Telegram chat for portal with existing Telegram chat.") + + invites = self._get_telegram_users_in_matrix_room() + if len(invites) < 2: + # TODO when we get the option for a bot, this won't happen when the bot is activated. + raise ValueError("Not enough Telegram users to create a chat") + + invites = [source.client.get_input_entity(id) for id in invites] + + if self.peer_type == "chat": + updates = source.client(CreateChatRequest(title=self.title, users=invites)) + elif self.peer_type == "channel": + updates = source.client(CreateChannelRequest(title=self.title, megagroup=supergroup)) + # TODO invite people + else: + raise ValueError("Invalid peer type for Telegram chat creation") + + entity = updates.chats[0] + self.tgid = entity.id + self.update_info(source, entity) + self.save() + + def invite_telegram(self, source, puppet): + if self.peer_type == "chat": + source.client(AddChatUserRequest(chat_id=self.tgid, user_id=puppet.tgid, fwd_limit=0)) + elif self.peer_type == "channel": + source.client(InviteToChannelRequest(channel=self.peer, + users=[InputUser(user_id=puppet.tgid)], + fwd_limit=0)) + else: + raise ValueError("Invalid peer type for Telegram user invite") + # endregion # region Telegram event handling @@ -342,7 +398,7 @@ class Portal: type = "m.image" sender.intent.set_typing(self.mxid, is_typing=False) return sender.intent.send_file(self.mxid, uploaded["content_uri"], info=info, text=name, - type=type) + type=type) def handle_telegram_location(self, source, sender, location): long = location.long @@ -376,7 +432,7 @@ class Portal: def handle_telegram_message(self, source, sender, evt): if not self.mxid: - self.create_room(source, invites=[source.mxid]) + self.create_matrix_room(source, invites=[source.mxid]) if evt.message: response = self.handle_telegram_text(source, sender, evt) @@ -403,7 +459,7 @@ class Portal: create_and_exit = (MessageActionChatCreate, MessageActionChannelCreate) create_and_continue = (MessageActionChatAddUser, MessageActionChatJoinedByLink) if isinstance(action, create_and_exit + create_and_continue): - self.create_room(source, invites=[source.mxid]) + self.create_matrix_room(source, invites=[source.mxid]) if not isinstance(action, create_and_continue): return @@ -486,6 +542,9 @@ class Portal: self.to_db() self.db.commit() + def delete(self): + self.db.delete(self.to_db()) + @classmethod def from_db(cls, db_portal): return Portal(db_portal.tgid, db_portal.peer_type, db_portal.mxid, db_portal.username, diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index 5cba5ce9..f209b504 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -24,6 +24,7 @@ class Puppet: log = None db = None az = None + mxid_regex = None cache = {} def __init__(self, id=None, username=None, displayname=None, photo_id=None): diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index 0fe6c18b..ca9b9431 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -173,7 +173,7 @@ class User: or isinstance(entity, (ChannelForbidden, ChatForbidden))): continue portal = po.Portal.get_by_entity(entity) - portal.create_room(self, entity, invites=[self.mxid]) + portal.create_matrix_room(self, entity, invites=[self.mxid]) # endregion # region Telegram update handling