From 2f75fa1cfe426db66e895e8c5a613ba605ea0ef3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 17 Feb 2018 17:48:48 +0200 Subject: [PATCH] Add support for bot message relaying --- ROADMAP.md | 4 +- example-config.yaml | 2 + mautrix_appservice/intent_api.py | 14 +++- mautrix_telegram/__main__.py | 17 +++- mautrix_telegram/abstract_user.py | 101 +++++++++++++++++++++++ mautrix_telegram/bot.py | 81 ++++++++++++++++++ mautrix_telegram/commands/auth.py | 3 + mautrix_telegram/commands/clean_rooms.py | 2 +- mautrix_telegram/commands/handler.py | 2 +- mautrix_telegram/context.py | 34 ++++++++ mautrix_telegram/db.py | 8 ++ mautrix_telegram/formatter.py | 5 +- mautrix_telegram/matrix.py | 40 +++++---- mautrix_telegram/portal.py | 68 ++++++++++----- mautrix_telegram/puppet.py | 2 +- mautrix_telegram/user.py | 75 ++++++----------- 16 files changed, 359 insertions(+), 99 deletions(-) create mode 100644 mautrix_telegram/abstract_user.py create mode 100644 mautrix_telegram/bot.py create mode 100644 mautrix_telegram/context.py diff --git a/ROADMAP.md b/ROADMAP.md index 89b1763d..23b1f0e3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,7 +25,7 @@ * [x] Matrix users who have logged into Telegram * [x] Kicking * [ ] Joining - * [ ] Chat name as alias + * [x] Chat name as alias * [ ] ‡ Chat invite link as alias * [x] Leaving * [x] Room metadata changes (name, topic, avatar) @@ -74,7 +74,7 @@ * [x] At startup * [x] When receiving invite or message * [x] Private chat creation by inviting Matrix puppet of Telegram user to new room - * [ ] Option to use bot to relay messages for unauthenticated Matrix users + * [x] Option to use bot to relay messages for unauthenticated Matrix users * [ ] Option to use own Matrix account for messages sent from other Telegram clients * [Commands](https://github.com/tulir/mautrix-telegram/wiki/Management-commands) * [x] Logging in and out (`login` + code entering) diff --git a/example-config.yaml b/example-config.yaml index 1aa2a508..73d20d34 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -87,3 +87,5 @@ telegram: # Get your own API keys at https://my.telegram.org/apps api_id: 12345 api_hash: tjyd5yge35lbodk1xwzw2jstp90k55qz + # (Optional) Create your own bot at https://t.me/BotFather + #bot_token: 123456789:ABCD-QBPd3VrWRhg623xYh07WUWErYA9eMI diff --git a/mautrix_appservice/intent_api.py b/mautrix_appservice/intent_api.py index 0fd002e0..a8434932 100644 --- a/mautrix_appservice/intent_api.py +++ b/mautrix_appservice/intent_api.py @@ -223,11 +223,14 @@ class IntentAPI: # region Room actions async def create_room(self, alias=None, is_public=False, name=None, topic=None, - is_direct=False, invitees=None, initial_state=None): + is_direct=False, invitees=None, initial_state=None, + guests_can_join=False): await self.ensure_registered() content = { - "visibility": "public" if is_public else "private", + "visibility": "private", "is_direct": is_direct, + "preset": "private_chat" if is_public else "public_chat", + "guests_can_join": guests_can_join, } if alias: content["room_alias_name"] = alias @@ -326,6 +329,13 @@ class IntentAPI: events.remove(event_id) await self.set_pinned_messages(room_id, events) + async def set_join_rule(self, room_id, join_rule): + if join_rule not in ("public", "knock", "invite", "private"): + raise ValueError(f"Invalid join rule \"{join_rule}\"") + await self.send_state_event(room_id, "m.room.join_rules", { + "join_rule": join_rule, + }) + async def get_event(self, room_id, event_id): await self.ensure_joined(room_id) return await self.client.request("GET", f"/rooms/{room_id}/event/{event_id}") diff --git a/mautrix_telegram/__main__.py b/mautrix_telegram/__main__.py index 9af163e8..1b6d9423 100644 --- a/mautrix_telegram/__main__.py +++ b/mautrix_telegram/__main__.py @@ -29,9 +29,12 @@ from .config import Config from .matrix import MatrixHandler from .db import init as init_db +from .abstract_user import init as init_abstract_user from .user import init as init_user, User +from .bot import init as init_bot from .portal import init as init_portal from .puppet import init as init_puppet +from .context import Context log = logging.getLogger("mau") time_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s@%(name)s] %(message)s") @@ -76,14 +79,22 @@ loop = asyncio.get_event_loop() appserv = AppService(config["homeserver.address"], config["homeserver.domain"], config["appservice.as_token"], config["appservice.hs_token"], config["appservice.bot_username"], log="mau.as", loop=loop) -context = (appserv, db_session, config, loop) + + +context = Context(appserv, db_session, config, loop, None, None) with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start: - MatrixHandler(context) init_db(db_session) + init_abstract_user(context) + context.bot = init_bot(context) + context.mx = MatrixHandler(context) init_portal(context) init_puppet(context) - startup_actions = init_user(context) + [start] + startup_actions = init_user(context) + [start, context.mx.init_as_bot()] + + if context.bot: + startup_actions.append(context.bot.start()) + try: loop.run_until_complete(asyncio.gather(*startup_actions, loop=loop)) loop.run_forever() diff --git a/mautrix_telegram/abstract_user.py b/mautrix_telegram/abstract_user.py new file mode 100644 index 00000000..8cfb033b --- /dev/null +++ b/mautrix_telegram/abstract_user.py @@ -0,0 +1,101 @@ +# -*- coding: future_fstrings -*- +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +import platform +import os + +from .tgclient import MautrixTelegramClient +from . import __version__ +from telethon.tl.types import * + +config = None + + +class AbstractUser: + loop = None + log = None + db = None + az = None + + def __init__(self): + self.connected = False + self.whitelisted = False + self.client = None + self.tgid = None + + def _init_client(self): + self.log.debug(f"Initializing client for {self.name}") + device = f"{platform.system()} {platform.release()}" + sysversion = MautrixTelegramClient.__version__ + self.client = MautrixTelegramClient(self.name, + config["telegram.api_id"], + config["telegram.api_hash"], + loop=self.loop, + app_version=__version__, + system_version=sysversion, + device_model=device) + self.client.add_update_handler(self._update_catch) + + async def update(self, update): + raise NotImplementedError() + + async def post_login(self): + raise NotImplementedError() + + async def _update_catch(self, update): + try: + await self.update(update) + except Exception: + self.log.exception("Failed to handle Telegram update") + + async def _get_dialogs(self, limit=None): + dialogs = await self.client.get_dialogs(limit=limit) + return [dialog.entity for dialog in dialogs if ( + not isinstance(dialog.entity, (User, ChatForbidden, ChannelForbidden)) + and not (isinstance(dialog.entity, Chat) + and (dialog.entity.deactivated or dialog.entity.left)))] + + @property + def name(self): + raise NotImplementedError() + + @property + def logged_in(self): + return self.client and self.client.is_user_authorized() + + @property + def has_full_access(self): + return self.logged_in and self.whitelisted + + async def start(self): + self.connected = await self.client.connect() + + async def ensure_started(self, even_if_no_session=False): + if not self.whitelisted: + return self + elif not self.connected and (even_if_no_session or os.path.exists(f"{self.name}.session")): + return await self.start() + return self + + def stop(self): + self.client.disconnect() + self.client = None + self.connected = False + + +def init(context): + global config + AbstractUser.az, AbstractUser.db, config, AbstractUser.loop, _ = context diff --git a/mautrix_telegram/bot.py b/mautrix_telegram/bot.py new file mode 100644 index 00000000..0aaa2da8 --- /dev/null +++ b/mautrix_telegram/bot.py @@ -0,0 +1,81 @@ +# -*- coding: future_fstrings -*- +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +import logging + +from telethon.tl.types import * + +from .abstract_user import AbstractUser +from .db import BotChat + +config = None + + +class Bot(AbstractUser): + log = logging.getLogger("mau.bot") + + def __init__(self, token): + super().__init__() + self.token = token + self.whitelisted = True + self._init_client() + self.chats = {chat.id for chat in BotChat.query.all()} + + async def start(self): + await super().start() + if not self.logged_in: + await self.client.sign_in(bot_token=self.token) + await self.post_login() + return self + + async def post_login(self): + info = await self.client.get_me() + self.tgid = info.id + + async def update(self, update): + if not isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)): + return + elif not isinstance(update.message, MessageService): + return + action = update.message.action + to_id = update.message.to_id + to_id = to_id.chat_id if isinstance(to_id, PeerChat) else to_id.channel_id + if isinstance(action, MessageActionChatAddUser): + if self.tgid in action.users: + self.chats.add(to_id) + self.db.add(BotChat(id=to_id)) + self.db.commit() + elif isinstance(action, MessageActionChatDeleteUser): + if action.user_id == self.tgid: + self.chats.remove(to_id) + BotChat.query.get(to_id).delete() + self.db.commit() + + def is_in_chat(self, peer_id): + return peer_id in self.chats + + @property + def name(self): + return "bot" + + +def init(context): + global config + config = context.config + token = config["telegram.bot_token"] + if token: + return Bot(token) + return None diff --git a/mautrix_telegram/commands/auth.py b/mautrix_telegram/commands/auth.py index 6c114785..e96d47b0 100644 --- a/mautrix_telegram/commands/auth.py +++ b/mautrix_telegram/commands/auth.py @@ -44,6 +44,7 @@ async def login(evt): elif len(evt.args) == 0: return await evt.reply("**Usage:** `$cmdprefix+sp login `") phone_number = evt.args[0] + await evt.sender.ensure_started(even_if_no_session=True) await evt.sender.client.sign_in(phone_number) evt.sender.command_status = { "next": enter_code, @@ -58,6 +59,7 @@ async def enter_code(evt): return await evt.reply("**Usage:** `$cmdprefix+sp enter-code `") try: + await evt.sender.ensure_started(even_if_no_session=True) user = await evt.sender.client.sign_in(code=evt.args[0]) asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop) evt.sender.command_status = None @@ -98,6 +100,7 @@ async def enter_password(evt): return await evt.reply("**Usage:** `$cmdprefix+sp enter-password `") try: + await evt.sender.ensure_started(even_if_no_session=True) user = await evt.sender.client.sign_in(password=evt.args[0]) asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop) evt.sender.command_status = None diff --git a/mautrix_telegram/commands/clean_rooms.py b/mautrix_telegram/commands/clean_rooms.py index 96a2154c..12dcd144 100644 --- a/mautrix_telegram/commands/clean_rooms.py +++ b/mautrix_telegram/commands/clean_rooms.py @@ -52,7 +52,7 @@ async def _find_rooms(intent): return management_rooms, unidentified_rooms, portals, empty_portals -@command_handler(needs_admin=True, name="clean-rooms") +@command_handler(needs_admin=True, needs_auth=False, name="clean-rooms") async def clean_rooms(evt): if not evt.is_management: return await evt.reply("`clean-rooms` is a particularly spammy command. Please don't " diff --git a/mautrix_telegram/commands/handler.py b/mautrix_telegram/commands/handler.py index 0e06e3a4..e7cf956f 100644 --- a/mautrix_telegram/commands/handler.py +++ b/mautrix_telegram/commands/handler.py @@ -87,7 +87,7 @@ class CommandHandler: log = logging.getLogger("mau.commands") def __init__(self, context): - self.az, self.db, self.config, self.loop = context + self.az, self.db, self.config, self.loop, _ = context self.command_prefix = self.config["bridge.command_prefix"] # region Utility functions for handling commands diff --git a/mautrix_telegram/context.py b/mautrix_telegram/context.py new file mode 100644 index 00000000..d53f7dc9 --- /dev/null +++ b/mautrix_telegram/context.py @@ -0,0 +1,34 @@ +# -*- coding: future_fstrings -*- +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +class Context: + def __init__(self, az, db, config, loop, bot, mx): + self.az = az + self.db = db + self.config = config + self.loop = loop + self.bot = bot + self.mx = mx + + def __iter__(self): + yield self.az + yield self.db + yield self.config + yield self.loop + yield self.bot + # yield self.mx diff --git a/mautrix_telegram/db.py b/mautrix_telegram/db.py index b8a4b372..21bc3792 100644 --- a/mautrix_telegram/db.py +++ b/mautrix_telegram/db.py @@ -95,9 +95,17 @@ class Puppet(Base): photo_id = Column(String, nullable=True) +# Fucking Telegram not telling bots what chats they are in 3:< +class BotChat(Base): + query = None + __tablename__ = "bot_chat" + id = Column(Integer, primary_key=True) + + def init(db_session): Portal.query = db_session.query_property() Message.query = db_session.query_property() UserPortal.query = db_session.query_property() User.query = db_session.query_property() Puppet.query = db_session.query_property() + BotChat.query = db_session.query_property() diff --git a/mautrix_telegram/formatter.py b/mautrix_telegram/formatter.py index 9590b5ec..8a7ea319 100644 --- a/mautrix_telegram/formatter.py +++ b/mautrix_telegram/formatter.py @@ -189,8 +189,9 @@ def matrix_reply_to_telegram(content, tg_space, room_id=None): reply = content["m.relates_to"]["m.in_reply_to"] room_id = room_id or reply["room_id"] event_id = reply["event_id"] - message = DBMessage.query.filter(DBMessage.mxid == event_id and - DBMessage.tg_space == tg_space and + print(event_id, tg_space, room_id) + message = DBMessage.query.filter(DBMessage.mxid == event_id, + DBMessage.tg_space == tg_space, DBMessage.mx_room == room_id).one_or_none() if message: return message.tgid diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index f5f36adf..74ca003e 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -28,13 +28,13 @@ class MatrixHandler: log = logging.getLogger("mau.mx") def __init__(self, context): - self.az, self.db, self.config, _ = context + self.az, self.db, self.config, _, self.tgbot = context self.commands = CommandHandler(context) self.az.matrix_event_handler(self.handle_event) async def init_as_bot(self): - self.az.intent.set_display_name( + await self.az.intent.set_display_name( self.config.get("appservice.bot_displayname", "Telegram bridge bot")) async def handle_puppet_invite(self, room, puppet, inviter): @@ -88,7 +88,7 @@ class MatrixHandler: "Telegram chat is created for this room.") async def handle_invite(self, room, user, inviter): - inviter = User.get_by_mxid(inviter) + inviter = await User.get_by_mxid(inviter).ensure_started() if not inviter.whitelisted: return elif user == self.az.bot_mxid: @@ -101,6 +101,9 @@ class MatrixHandler: return user = User.get_by_mxid(user, create=False) + if not user: + return + await user.ensure_started() portal = Portal.get_by_mxid(room) if user and user.has_full_access and portal: await portal.invite_telegram(inviter, user) @@ -110,7 +113,7 @@ class MatrixHandler: self.log.debug(f"{inviter} invited {user} to {room}") async def handle_join(self, room, user): - user = User.get_by_mxid(user) + user = await User.get_by_mxid(user).ensure_started() portal = Portal.get_by_mxid(room) if not portal: @@ -120,19 +123,23 @@ class MatrixHandler: await portal.main_intent.kick(room, user.mxid, "You are not whitelisted on this Telegram bridge.") return - elif not user.logged_in: - # TODO[waiting-for-bots] once we have bot support, this won't be needed. + elif not user.logged_in and not portal.has_bot: await portal.main_intent.kick(room, user.mxid, - "You are not logged into this Telegram bridge.") + "This chat does not have a bot relaying " + "messages for unauthenticated users.") return self.log.debug(f"{user} joined {room}") - # TODO join Telegram chat if applicable + if user.logged_in: + await portal.join_matrix(user) async def handle_part(self, room, user, sender): self.log.debug(f"{user} left {room}") sender = User.get_by_mxid(sender, create=False) + if not sender: + return + await sender.ensure_started() portal = Portal.get_by_mxid(room) if not portal: @@ -143,7 +150,10 @@ class MatrixHandler: await portal.leave_matrix(puppet, sender) user = User.get_by_mxid(user, create=False) - if user and user.logged_in: + if not user: + return + await user.ensure_started() + if user.logged_in: await portal.leave_matrix(user, sender) def is_command(self, message): @@ -158,10 +168,12 @@ class MatrixHandler: self.log.debug(f"{sender} sent {message} to ${room}") is_command, text = self.is_command(message) - sender = User.get_by_mxid(sender) + sender = await User.get_by_mxid(sender).ensure_started() + if not sender.whitelisted: + return portal = Portal.get_by_mxid(room) - if sender.has_full_access and portal and not is_command: + if not is_command and portal and (sender.logged_in or portal.has_bot): await portal.handle_matrix_message(sender, message, event_id) return @@ -187,19 +199,19 @@ class MatrixHandler: async def handle_redaction(self, room, sender, event_id): portal = Portal.get_by_mxid(room) - sender = User.get_by_mxid(sender) + sender = await User.get_by_mxid(sender).ensure_started() if sender.has_full_access and portal: await portal.handle_matrix_deletion(sender, event_id) async def handle_power_levels(self, room, sender, new, old): portal = Portal.get_by_mxid(room) - sender = User.get_by_mxid(sender) + sender = await User.get_by_mxid(sender).ensure_started() if sender.has_full_access and portal: await portal.handle_matrix_power_levels(sender, new["users"], old["users"]) async def handle_room_meta(self, type, room, sender, content): portal = Portal.get_by_mxid(room) - sender = User.get_by_mxid(sender) + sender = await User.get_by_mxid(sender).ensure_started() if sender.has_full_access and portal: handler, content_key = { "m.room.name": (portal.handle_matrix_title, "name"), diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index c89c8fb8..421f7400 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -44,6 +44,7 @@ class Portal: log = logging.getLogger("mau.portal") db = None az = None + bot = None by_mxid = {} by_tgid = {} @@ -88,6 +89,10 @@ class Portal: elif self.peer_type == "channel": return PeerChannel(channel_id=self.tgid) + @property + def has_bot(self): + return self.bot and self.bot.is_in_chat(self.tgid) + def _hash_event(self, event): if self.peer_type == "channel": # Message IDs are unique per-channel @@ -196,8 +201,7 @@ class Portal: self._main_intent = puppet.intent if direct else self.az.intent if self.peer_type == "channel" and entity.username: - # TODO make public once safe - public = False + public = True alias = self._get_room_alias(entity.username) self.username = entity.username else: @@ -206,7 +210,7 @@ class Portal: alias = None if alias: - # TODO properly handle existing room aliases + # TODO? properly handle existing room aliases await self.main_intent.remove_room_alias(alias) power_levels = self._get_base_power_levels({}, entity) @@ -319,6 +323,9 @@ class Portal: self.username = username or None if self.username: await self.main_intent.add_room_alias(self.mxid, self._get_room_alias()) + await self.main_intent.set_join_rule(self.mxid, "public") + else: + await self.main_intent.set_join_rule(self.mxid, "invite") return True return False @@ -396,7 +403,7 @@ class Portal: for member in members: if p.Puppet.get_id_from_mxid(member) or member == self.main_intent.mxid: continue - user = u.User.get_by_mxid(member) + user = await u.User.get_by_mxid(member).ensure_started() if user.has_full_access: authenticated.append(user) return authenticated @@ -455,22 +462,42 @@ class Portal: channel = await self.get_input_entity(user) await user.client(LeaveChannelRequest(channel=channel)) + async def join_matrix(self, user): + if self.peer_type == "channel": + await user.client(JoinChannelRequest(channel=await self.get_input_entity(user))) + else: + # We'll just assume the user is already in the chat. + pass + async def handle_matrix_message(self, sender, message, event_id): type = message["msgtype"] - space = self.tgid if self.peer_type == "channel" else sender.tgid + if sender.logged_in: + client = sender.client + space = self.tgid if self.peer_type == "channel" else sender.tgid + else: + client = self.bot.client + space = self.tgid if self.peer_type == "channel" else self.bot.tgid reply_to = formatter.matrix_reply_to_telegram(message, space, room_id=self.mxid) - if type in {"m.text", "m.emote"}: + + if type == "m.emote": + if "formatted_body" in message: + message["formatted_body"] = f"* {sender.displayname} {message['formatted_body']}" + message["body"] = f"* {sender.displayname} {message['body']}" + type = "m.text" + elif not sender.logged_in: + if "formatted_body" in message: + message["formatted_body"] = \ + f"<{sender.displayname}> {message['formatted_body']}" + message["body"] = f"<{sender.displayname}> {message['body']}" + + if type == "m.text": if "format" in message and message["format"] == "org.matrix.custom.html": - message, entities = formatter.matrix_to_telegram(message["formatted_body"], space) - if type == "m.emote": - message = "/me " + message - response = await sender.client.send_message(self.peer, message, entities=entities, - reply_to=reply_to) + message, entities = formatter.matrix_to_telegram(message["formatted_body"]) + response = await client.send_message(self.peer, message, entities=entities, + reply_to=reply_to) else: - if type == "m.emote": - message["body"] = "/me " + message["body"] - response = await sender.client.send_message(self.peer, message["body"], - reply_to=reply_to) + response = await client.send_message(self.peer, message["body"], + reply_to=reply_to) elif type in {"m.image", "m.file", "m.audio", "m.video"}: file = await self.main_intent.download_file(message["url"]) @@ -483,8 +510,8 @@ class Portal: if "w" in info and "h" in info: attributes.append(DocumentAttributeImageSize(w=info["w"], h=info["h"])) - response = await sender.client.send_file(self.peer, file, mime, caption, attributes, - file_name, reply_to=reply_to) + response = await client.send_file(self.peer, file, mime, caption, attributes, + file_name, reply_to=reply_to) else: self.log.debug("Unhandled Matrix event: %s", message) return @@ -498,8 +525,8 @@ class Portal: async def handle_matrix_deletion(self, deleter, event_id): space = self.tgid if self.peer_type == "channel" else deleter.tgid - message = DBMessage.query.filter(DBMessage.mxid == event_id and - DBMessage.tg_space == space and + message = DBMessage.query.filter(DBMessage.mxid == event_id, + DBMessage.tg_space == space, DBMessage.mx_room == self.mxid).one_or_none() if not message: return @@ -627,7 +654,6 @@ class Portal: invites = await self._get_telegram_users_in_matrix_room() if len(invites) < 2: - # TODO[waiting-for-bots] This won't happen when the bot is enabled raise ValueError("Not enough Telegram users to create a chat") if self.peer_type == "chat": @@ -1065,4 +1091,4 @@ class Portal: def init(context): global config - Portal.az, Portal.db, config, _ = context + Portal.az, Portal.db, config, _, Portal.bot = context diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index 596bcd2e..2ee48c79 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -176,7 +176,7 @@ class Puppet: def init(context): global config - Puppet.az, Puppet.db, config, _ = context + Puppet.az, Puppet.db, config, _, _ = context localpart = config.get("bridge.username_template", "telegram_{userid}").format(userid="(.+)") hs = config["homeserver"]["domain"] Puppet.mxid_regex = re.compile(f"@{localpart}:{hs}") diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index b1cc5aea..2a3637f4 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -16,31 +16,28 @@ # along with this program. If not, see . import logging import asyncio -import platform +import re from telethon.tl.types import * from telethon.tl.types.contacts import ContactsNotModified -from telethon.tl.types import User as TLUser from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest from mautrix_appservice import MatrixRequestError from .db import User as DBUser, Message as DBMessage, Contact as DBContact -from .tgclient import MautrixTelegramClient -from . import portal as po, puppet as pu, __version__ +from .abstract_user import AbstractUser +from . import portal as po, puppet as pu config = None -class User: - loop = None +class User(AbstractUser): log = logging.getLogger("mau.user") - db = None - az = None by_mxid = {} by_tgid = {} def __init__(self, mxid, tgid=None, username=None, db_contacts=None, saved_contacts=0, db_portals=None): + super().__init__() self.mxid = mxid self.tgid = tgid self.username = username @@ -51,9 +48,6 @@ class User: self.db_portals = db_portals self.command_status = None - self.connected = False - self.client = None - self._init_client() self.is_admin = self.mxid in config.get("bridge.admins", []) @@ -67,13 +61,17 @@ class User: if tgid: self.by_tgid[tgid] = self - @property - def logged_in(self): - return self.client.is_user_authorized() + self._init_client() @property - def has_full_access(self): - return self.logged_in and self.whitelisted + def name(self): + return self.mxid + + @property + def displayname(self): + # TODO show better username + match = re.compile("@(.+):(.+)").match(self.mxid) + return match.group(1) @property def db_contacts(self): @@ -129,23 +127,13 @@ class User: # endregion # region Telegram connection management - def _init_client(self): - device = f"{platform.system()} {platform.release()}" - sysversion = MautrixTelegramClient.__version__ - self.client = MautrixTelegramClient(self.mxid, - config["telegram.api_id"], - config["telegram.api_hash"], - loop=self.loop, - app_version=__version__, - system_version=sysversion, - device_model=device) - self.client.add_update_handler(self.update_catch) - async def start(self, delete_unless_authenticated=False): - self.connected = await self.client.connect() + await super().start() if self.logged_in: + self.log.debug(f"Ensuring post_login() for {self.name}") asyncio.ensure_future(self.post_login(), loop=self.loop) elif delete_unless_authenticated: + self.log.debug(f"Unauthenticated user {self.name} start()ed, deleting...") # User not logged in -> forget user self.client.disconnect() self.client.session.delete() @@ -160,11 +148,6 @@ class User: except Exception: self.log.exception("Failed to run post-login functions") - def stop(self): - self.client.disconnect() - self.client = None - self.connected = False - # endregion # region Telegram actions that need custom methods @@ -234,14 +217,8 @@ class User: return await self._search_remote(query), True async def sync_dialogs(self): - dialogs = await self.client.get_dialogs(limit=30) creators = [] - for dialog in dialogs: - entity = dialog.entity - invalid = (isinstance(entity, (TLUser, ChatForbidden, ChannelForbidden)) - or (isinstance(entity, Chat) and (entity.deactivated or entity.left))) - if invalid: - continue + for entity in await self._get_dialogs(limit=30): portal = po.Portal.get_by_entity(entity) self.portals[portal.tgid_full] = portal creators.append(portal.create_matrix_room(self, entity, invites=[self.mxid])) @@ -286,12 +263,6 @@ class User: # endregion # region Telegram update handling - async def update_catch(self, update): - try: - await self.update(update) - except Exception: - self.log.exception("Failed to handle Telegram update") - async def update(self, update): if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage, UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage)): @@ -340,7 +311,7 @@ class User: await portal.set_telegram_admins_enabled(update.enabled) elif isinstance(update, UpdateChatParticipantAdmin): puppet = pu.Puppet.get(update.user_id) - user = User.get_by_tgid(update.user_id) + user = await User.get_by_tgid(update.user_id).ensure_started() await portal.set_telegram_admin(puppet, user) async def update_typing(self, update): @@ -425,14 +396,14 @@ class User: user = DBUser.query.get(mxid) if user: user = cls.from_db(user) - asyncio.ensure_future(user.start(), loop=cls.loop) + # asyncio.ensure_future(user.start(), loop=cls.loop) return user if create: user = cls(mxid) cls.db.add(user.to_db()) cls.db.commit() - asyncio.ensure_future(user.start(), loop=cls.loop) + # asyncio.ensure_future(user.start(), loop=cls.loop) return user return None @@ -447,7 +418,7 @@ class User: user = DBUser.query.filter(DBUser.tgid == tgid).one_or_none() if user: user = cls.from_db(user) - asyncio.ensure_future(user.start(), loop=cls.loop) + # asyncio.ensure_future(user.start(), loop=cls.loop) return user return None @@ -468,7 +439,7 @@ class User: def init(context): global config - User.az, User.db, config, User.loop = context + config = context.config users = [User.from_db(user) for user in DBUser.query.all()] return [user.start(delete_unless_authenticated=True) for user in users]