From 2f75fa1cfe426db66e895e8c5a613ba605ea0ef3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 17 Feb 2018 17:48:48 +0200 Subject: [PATCH 01/15] 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] From 4673546b42ca1b6ffcb554c5a2146fbb1b3e03e1 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 17 Feb 2018 19:16:21 +0200 Subject: [PATCH 02/15] Add option to bridge notices and command to get relaybot info --- example-config.yaml | 2 ++ mautrix_telegram/commands/auth.py | 15 +++++++++++++++ mautrix_telegram/commands/handler.py | 4 +++- mautrix_telegram/commands/meta.py | 1 + mautrix_telegram/portal.py | 4 +++- 5 files changed, 24 insertions(+), 2 deletions(-) diff --git a/example-config.yaml b/example-config.yaml index 73d20d34..221ff539 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -67,6 +67,8 @@ bridge: # Show message editing as a reply to the original message. # If this is false, message edits are not shown at all, as Matrix does not support editing yet. edits_as_replies: False + # Whether or not Matrix bot messages (type m.notice) should be bridged. + bridge_notices: False # The prefix for commands. Only required in non-management rooms. command_prefix: "!tg" diff --git a/mautrix_telegram/commands/auth.py b/mautrix_telegram/commands/auth.py index e96d47b0..de38fdbf 100644 --- a/mautrix_telegram/commands/auth.py +++ b/mautrix_telegram/commands/auth.py @@ -32,6 +32,21 @@ async def ping(evt): return await evt.reply("You're not logged in.") +@command_handler() +async def ping_bot(evt): + if not evt.tgbot: + return await evt.reply("Telegram message relay bot not configured.") + bot_info = await evt.tgbot.client.get_me() + localpart = evt.config.get("bridge.username_template", "telegram_{userid}").format( + userid=bot_info.id) + hs = evt.config["homeserver"]["domain"] + mxid = f"@{localpart}:{hs}" + displayname = bot_info.first_name + return await evt.reply(f"Telegram message relay bot is active: " + + f"[{displayname}](https://matrix.to/#/{mxid}) (ID {bot_info.id})\n\n" + + f"To use the bot, simply invite it to a portal room.") + + @command_handler(needs_auth=False, management_only=True) def register(evt): return evt.reply("Not yet implemented.") diff --git a/mautrix_telegram/commands/handler.py b/mautrix_telegram/commands/handler.py index e7cf956f..2ed4679d 100644 --- a/mautrix_telegram/commands/handler.py +++ b/mautrix_telegram/commands/handler.py @@ -45,6 +45,8 @@ class CommandEvent: self.az = handler.az self.log = handler.log self.loop = handler.loop + self.tgbot = handler.tgbot + self.config = handler.config self.command_prefix = handler.command_prefix self.room_id = room self.sender = sender @@ -87,7 +89,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, self.tgbot = context self.command_prefix = self.config["bridge.command_prefix"] # region Utility functions for handling commands diff --git a/mautrix_telegram/commands/meta.py b/mautrix_telegram/commands/meta.py index 6ffff94f..0338b402 100644 --- a/mautrix_telegram/commands/meta.py +++ b/mautrix_telegram/commands/meta.py @@ -65,6 +65,7 @@ def help(evt): `channel` (defaults to `group`). #### Portal management +**ping-bot** - Get info of the message relay Telegram bot. **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 diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 421f7400..dee0974e 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -45,6 +45,7 @@ class Portal: db = None az = None bot = None + bridge_notices = False by_mxid = {} by_tgid = {} @@ -490,7 +491,7 @@ class Portal: f"<{sender.displayname}> {message['formatted_body']}" message["body"] = f"<{sender.displayname}> {message['body']}" - if type == "m.text": + if type == "m.text" or (self.bridge_notices and type == "m.notice"): if "format" in message and message["format"] == "org.matrix.custom.html": message, entities = formatter.matrix_to_telegram(message["formatted_body"]) response = await client.send_message(self.peer, message, entities=entities, @@ -1092,3 +1093,4 @@ class Portal: def init(context): global config Portal.az, Portal.db, config, _, Portal.bot = context + Portal.bridge_notices = config["bridge.bridge_notices"] From d7e40a86c66fab538d56faff3e29c110c75511e3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 17 Feb 2018 20:23:50 +0200 Subject: [PATCH 03/15] Check if bot is still in chat at startup --- mautrix_telegram/bot.py | 53 +++++++++++++++++++++++++++++------ mautrix_telegram/db.py | 1 + mautrix_telegram/formatter.py | 1 - 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/mautrix_telegram/bot.py b/mautrix_telegram/bot.py index 0aaa2da8..663d5b45 100644 --- a/mautrix_telegram/bot.py +++ b/mautrix_telegram/bot.py @@ -17,6 +17,9 @@ import logging from telethon.tl.types import * +from telethon.errors import ChannelInvalidError, ChannelPrivateError +from telethon.tl.functions.messages import GetChatsRequest +from telethon.tl.functions.channels import GetChannelsRequest from .abstract_user import AbstractUser from .db import BotChat @@ -32,7 +35,7 @@ class Bot(AbstractUser): self.token = token self.whitelisted = True self._init_client() - self.chats = {chat.id for chat in BotChat.query.all()} + self.chats = {(chat.id, chat.type) for chat in BotChat.query.all()} async def start(self): await super().start() @@ -45,24 +48,56 @@ class Bot(AbstractUser): info = await self.client.get_me() self.tgid = info.id + chat_ids = [id for (id, type) in self.chats if type == "chat"] + response = await self.client(GetChatsRequest(chat_ids)) + for chat in response.chats: + if isinstance(chat, ChatForbidden) or chat.left or chat.deactivated: + self.remove_chat(chat.id, "chat") + + channel_ids = [InputChannel(id, 0) + for (id, type) in self.chats + if type == "channel"] + for id in channel_ids: + try: + await self.client(GetChannelsRequest([id])) + except (ChannelPrivateError, ChannelInvalidError): + self.remove_chat(id.channel_id, "channel") + + def add_chat(self, id, type): + entry = (id, type) + if entry not in self.chats: + self.chats.add(entry) + self.db.add(BotChat(id=id, type=type)) + self.db.commit() + + def remove_chat(self, id, type): + self.chats.remove((id, type)) + self.db.delete(BotChat.query.get(id)) + self.db.commit() + 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(to_id, PeerChannel): + to_id = to_id.channel_id + type = "channel" + elif isinstance(to_id, PeerChat): + to_id = to_id.chat_id + type = "chat" + else: + return + + action = update.message.action 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() + self.add_chat(to_id, type) elif isinstance(action, MessageActionChatDeleteUser): if action.user_id == self.tgid: - self.chats.remove(to_id) - BotChat.query.get(to_id).delete() - self.db.commit() + self.remove_chat(to_id, type) def is_in_chat(self, peer_id): return peer_id in self.chats diff --git a/mautrix_telegram/db.py b/mautrix_telegram/db.py index 21bc3792..fadd4559 100644 --- a/mautrix_telegram/db.py +++ b/mautrix_telegram/db.py @@ -100,6 +100,7 @@ class BotChat(Base): query = None __tablename__ = "bot_chat" id = Column(Integer, primary_key=True) + type = Column(String, nullable=False) def init(db_session): diff --git a/mautrix_telegram/formatter.py b/mautrix_telegram/formatter.py index 8a7ea319..faa03799 100644 --- a/mautrix_telegram/formatter.py +++ b/mautrix_telegram/formatter.py @@ -189,7 +189,6 @@ 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"] - 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() From eef48a9a56a5f84dc6b947a24ead4628f15db889 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 17 Feb 2018 20:35:23 +0200 Subject: [PATCH 04/15] Synchronize deleted users in sync_telegram_users() --- mautrix_telegram/portal.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index dee0974e..17199c08 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -265,11 +265,29 @@ class Portal: groupname=username) async def sync_telegram_users(self, source, users): + allowed_tgids = set() for entity in users: puppet = p.Puppet.get(entity.id) + allowed_tgids.add(entity.id) await puppet.intent.ensure_joined(self.mxid) await puppet.update_info(source, entity) + joined_mxids = await self.main_intent.get_room_members(self.mxid) + print(allowed_tgids) + for user in joined_mxids: + if user == self.az.intent.mxid: + continue + puppet_id = p.Puppet.get_id_from_mxid(user) + if puppet_id and puppet_id not in allowed_tgids: + await self.main_intent.kick(self.mxid, user, + "User had left this Telegram chat.") + continue + mx_user = u.User.get_by_mxid(user, create=False) + if mx_user and not self.has_bot and mx_user.tgid not in allowed_tgids: + await self.main_intent.kick(self.mxid, mx_user.mxid, + "You had left this Telegram chat.") + continue + async def add_telegram_user(self, user_id, source=None): puppet = p.Puppet.get(user_id) if source: From c1f582f17a1d1f85e6212f14c7de127044a0a8a9 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 17 Feb 2018 21:26:22 +0200 Subject: [PATCH 05/15] Remove debug print --- mautrix_telegram/portal.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 17199c08..e25cd8fb 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -273,7 +273,6 @@ class Portal: await puppet.update_info(source, entity) joined_mxids = await self.main_intent.get_room_members(self.mxid) - print(allowed_tgids) for user in joined_mxids: if user == self.az.intent.mxid: continue From 7dc5384d5242ab43ef4d5ffdd4c405e54a95b0d6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 18 Feb 2018 11:24:51 +0200 Subject: [PATCH 06/15] Update future-fstrings and stop concatenating multiline strings --- mautrix_appservice/errors.py | 2 +- mautrix_appservice/intent_api.py | 2 +- mautrix_telegram/commands/auth.py | 6 +++--- mautrix_telegram/commands/clean_rooms.py | 12 ++++++------ mautrix_telegram/commands/handler.py | 6 +++--- mautrix_telegram/commands/telegram.py | 22 +++++++++++----------- mautrix_telegram/config.py | 2 +- mautrix_telegram/db.py | 2 +- mautrix_telegram/formatter.py | 8 ++++---- mautrix_telegram/matrix.py | 10 +++++----- mautrix_telegram/portal.py | 4 ++-- mautrix_telegram/user.py | 3 --- setup.py | 4 ++-- 13 files changed, 40 insertions(+), 43 deletions(-) diff --git a/mautrix_appservice/errors.py b/mautrix_appservice/errors.py index 8c09936f..02c4f87b 100644 --- a/mautrix_appservice/errors.py +++ b/mautrix_appservice/errors.py @@ -31,7 +31,7 @@ class MatrixRequestError(MatrixError): """ The home server returned an error response. """ def __init__(self, code=0, text="", errcode=None, message=None): - super().__init__("%d: %s" % (code, text)) + super().__init__(f"{code}: {text}") self.code = code self.text = text self.errcode = errcode diff --git a/mautrix_appservice/intent_api.py b/mautrix_appservice/intent_api.py index a8434932..fb9710d6 100644 --- a/mautrix_appservice/intent_api.py +++ b/mautrix_appservice/intent_api.py @@ -524,7 +524,7 @@ class IntentAPI: self.log.warning( f"Power level of {self.mxid} is not enough for {event_type} in {room_id}") # raise IntentError(f"Power level of {self.mxid} is not enough" - # + f"for {event_type} in {room_id}") + # f"for {event_type} in {room_id}") return # TODO implement diff --git a/mautrix_telegram/commands/auth.py b/mautrix_telegram/commands/auth.py index de38fdbf..ed254540 100644 --- a/mautrix_telegram/commands/auth.py +++ b/mautrix_telegram/commands/auth.py @@ -42,9 +42,9 @@ async def ping_bot(evt): hs = evt.config["homeserver"]["domain"] mxid = f"@{localpart}:{hs}" displayname = bot_info.first_name - return await evt.reply(f"Telegram message relay bot is active: " - + f"[{displayname}](https://matrix.to/#/{mxid}) (ID {bot_info.id})\n\n" - + f"To use the bot, simply invite it to a portal room.") + return await evt.reply("Telegram message relay bot is active: " + f"[{displayname}](https://matrix.to/#/{mxid}) (ID {bot_info.id})\n\n" + "To use the bot, simply invite it to a portal room.") @command_handler(needs_auth=False, management_only=True) diff --git a/mautrix_telegram/commands/clean_rooms.py b/mautrix_telegram/commands/clean_rooms.py index 12dcd144..06ebb428 100644 --- a/mautrix_telegram/commands/clean_rooms.py +++ b/mautrix_telegram/commands/clean_rooms.py @@ -66,7 +66,7 @@ async def clean_rooms(evt): or ["No management rooms found."]) reply.append("#### Active portal rooms (A)") reply += ([f"{n+1}. [P{n+1}](https://matrix.to/#/{portal.mxid}) " - + f"(to Telegram chat \"{portal.title}\")" + f"(to Telegram chat \"{portal.title}\")" for n, portal in enumerate(portals)] or ["No active portal rooms found."]) reply.append("#### Unidentified rooms (U)") @@ -75,7 +75,7 @@ async def clean_rooms(evt): or ["No unidentified rooms found."]) reply.append("#### Inactive portal rooms (I)") reply += ([f"{n}. [E{n}](https://matrix.to/#/{portal.mxid}) " - + f"(to Telegram chat \"{portal.title}\")" + f"(to Telegram chat \"{portal.title}\")" for n, portal in enumerate(empty_portals)] or ["No inactive portal rooms found."]) @@ -141,21 +141,21 @@ async def set_rooms_to_clean(evt, management_rooms, unidentified_rooms, portals, "**Usage:** `$cmdprefix+sp clean-groups <_M|A|U|I_>") else: return await evt.reply(f"Unknown room cleaning action `{command}`. " - + "Use `$cmdprefix+sp cancel` to cancel room " - + "cleaning.") + "Use `$cmdprefix+sp cancel` to cancel room " + "cleaning.") evt.sender.command_status = { "next": lambda confirm: execute_room_cleanup(confirm, rooms_to_clean), "action": "Room cleaning", } await evt.reply(f"To confirm cleaning up {len(rooms_to_clean)} rooms, type" - + "`$cmdprefix+sp confirm-clean`.") + "`$cmdprefix+sp confirm-clean`.") async def execute_room_cleanup(evt, rooms_to_clean): if len(evt.args) > 0 and evt.args[0] == "confirm-clean": await evt.reply(f"Cleaning {len(rooms_to_clean)} rooms. " - + "This might take a while.") + "This might take a while.") cleaned = 0 for room in rooms_to_clean: if isinstance(room, po.Portal): diff --git a/mautrix_telegram/commands/handler.py b/mautrix_telegram/commands/handler.py index 2ed4679d..94c6a356 100644 --- a/mautrix_telegram/commands/handler.py +++ b/mautrix_telegram/commands/handler.py @@ -27,7 +27,7 @@ def command_handler(needs_auth=True, management_only=False, needs_admin=False, n def wrapper(evt): if management_only and not evt.is_management: return evt.reply(f"`{evt.command}` is a restricted command:" - + "you may only run it in management rooms.") + "you may only run it in management rooms.") elif needs_auth and not evt.sender.logged_in: return evt.reply("This command requires you to be logged in.") elif needs_admin and not evt.sender.is_admin: @@ -112,6 +112,6 @@ class CommandHandler: except FloodWaitError as e: return evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}") except Exception: - self.log.exception(f"Fatal error handling command " - + f"{evt.command} {' '.join(args)} from {sender.mxid}") + self.log.exception("Fatal error handling command " + f"{evt.command} {' '.join(args)} from {sender.mxid}") return evt.reply("Fatal error while handling command. Check logs for more details.") diff --git a/mautrix_telegram/commands/telegram.py b/mautrix_telegram/commands/telegram.py index f77c2110..10693a94 100644 --- a/mautrix_telegram/commands/telegram.py +++ b/mautrix_telegram/commands/telegram.py @@ -51,7 +51,7 @@ async def search(evt): else: reply += ["**Results in contacts:**", ""] reply += [(f"* [{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): " - + f"{puppet.id} ({similarity}% match)") + f"{puppet.id} ({similarity}% match)") for puppet, similarity in results] # TODO somehow show remote channel results when joining by alias is possible? @@ -71,8 +71,8 @@ async def pm(evt): return await evt.reply("That doesn't seem to be a user.") portal = po.Portal.get_by_entity(user, evt.sender.tgid) await portal.create_matrix_room(evt.sender, user, [evt.sender.mxid]) - return await evt.reply( - f"Created private chat room with {pu.Puppet.get_displayname(user, False)}") + return await evt.reply("Created private chat room with " + f"{pu.Puppet.get_displayname(user, False)}") @command_handler() @@ -116,9 +116,9 @@ async def delete_portal(evt): "action": "Portal deletion", } return await evt.reply("Please confirm deletion of portal " - + f"[{room_id}](https://matrix.to/#/{room_id}) " - + f"to Telegram chat \"{portal.title}\" " - + "by typing `$cmdprefix+sp confirm-delete`") + f"[{room_id}](https://matrix.to/#/{room_id}) " + f"to Telegram chat \"{portal.title}\" " + "by typing `$cmdprefix+sp confirm-delete`") @command_handler() @@ -183,16 +183,16 @@ async def create(evt): return await evt.reply("Please set a title before creating a Telegram chat.") elif (not levels or not levels["users"] or evt.az.intent.mxid not in levels["users"] or levels["users"][evt.az.intent.mxid] < 100): - return await evt.reply(f"Please give " - + f"[the bridge bot](https://matrix.to/#/{evt.az.intent.mxid})" - + f" a power level of 100 before creating a Telegram chat.") + return await evt.reply("Please give " + f"[the bridge bot](https://matrix.to/#/{evt.az.intent.mxid})" + " a power level of 100 before creating a Telegram chat.") else: for user, level in levels["users"].items(): if level >= 100 and user != evt.az.intent.mxid: return await evt.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.") + f"99 before creating a Telegram chat.\n\n" + f"Use power level 95 instead of 100 for admins.") supergroup = type == "supergroup" type = { diff --git a/mautrix_telegram/config.py b/mautrix_telegram/config.py index f2f81426..8b4dd5e3 100644 --- a/mautrix_telegram/config.py +++ b/mautrix_telegram/config.py @@ -94,7 +94,7 @@ class Config(DictWithRecursion): self.set("appservice.hs_token", self._new_token()) url = (f"{self['appservice.protocol']}://" - + f"{self['appservice.hostname']}:{self['appservice.port']}") + f"{self['appservice.hostname']}:{self['appservice.port']}") self._registration = { "id": self.get("appservice.id", "telegram"), "as_token": self["appservice.as_token"], diff --git a/mautrix_telegram/db.py b/mautrix_telegram/db.py index fadd4559..20dff82b 100644 --- a/mautrix_telegram/db.py +++ b/mautrix_telegram/db.py @@ -48,7 +48,7 @@ class Message(Base): tgid = Column(Integer, primary_key=True) tg_space = Column(Integer, primary_key=True) - __table_args__ = (UniqueConstraint('mxid', 'mx_room', 'tg_space', name='_mx_id_room'),) + __table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"),) class UserPortal(Base): diff --git a/mautrix_telegram/formatter.py b/mautrix_telegram/formatter.py index faa03799..1a21d68a 100644 --- a/mautrix_telegram/formatter.py +++ b/mautrix_telegram/formatter.py @@ -244,7 +244,7 @@ async def telegram_event_to_matrix(evt, source, native_replies=False, message_li if not fwd_from: fwd_from = "Unknown user" html = (f"Forwarded message from {fwd_from}
" - + f"
{html}
") + f"
{html}
") if evt.reply_to_msg_id: space = (evt.to_id.channel_id @@ -271,7 +271,7 @@ async def telegram_event_to_matrix(evt, source, native_replies=False, message_li displayname = puppet.displayname if puppet else sender reply_to_user = f"{displayname}" reply_to_msg = (("{reply_text}") + f"{msg.mx_room}/{msg.mxid}'>{reply_text}") if message_link_in_reply else "Reply") quote = f"{reply_to_msg} to {reply_to_user}
{body}
" except (ValueError, KeyError, MatrixRequestError): @@ -331,8 +331,8 @@ def _telegram_to_matrix(text, entities): elif entity_type == MessageEntityPre: if entity.language: html.append("
"
-                            + f"{entity_text}"
-                            + "
") + f"{entity_text}" + "") else: html.append(f"
{entity_text}
") elif entity_type == MessageEntityMention: diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index 74ca003e..208cfa8c 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -60,8 +60,8 @@ class MatrixHandler: if len(members) > 1: await 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.")) + f"the bridge bot " + f"first if you want to create a Telegram chat.")) return await puppet.intent.join_room(room) @@ -71,9 +71,9 @@ class MatrixHandler: await puppet.intent.invite(portal.mxid, inviter.mxid) await puppet.intent.send_notice(room, text=None, html=( "You already have a private chat with me: " - + f"" - + "Link to room" - + "")) + f"" + "Link to room" + "")) await puppet.intent.leave_room(room) return except MatrixRequestError: diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index e25cd8fb..849be917 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -504,8 +504,8 @@ class Portal: type = "m.text" elif not sender.logged_in: if "formatted_body" in message: - message["formatted_body"] = \ - f"<{sender.displayname}> {message['formatted_body']}" + message["formatted_body"] = (f"<{sender.displayname}> " + f"{message['formatted_body']}") message["body"] = f"<{sender.displayname}> {message['body']}" if type == "m.text" or (self.bridge_notices and type == "m.notice"): diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index 2a3637f4..336f0892 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -396,14 +396,12 @@ class User(AbstractUser): user = DBUser.query.get(mxid) if user: user = cls.from_db(user) - # 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) return user return None @@ -418,7 +416,6 @@ class User(AbstractUser): 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) return user return None diff --git a/setup.py b/setup.py index 6f02919e..2b78675c 100644 --- a/setup.py +++ b/setup.py @@ -17,12 +17,12 @@ setuptools.setup( install_requires=[ "aiohttp>=2.3.10,<3", - "SQLAlchemy>=1.2.2,<2", + "SQLAlchemy>=1.2.3,<2", "alembic>=0.9.7", "Markdown>=2.6.11,<3", "ruamel.yaml>=0.15.35,<0.16", "Pillow>=5.0.0,<6", - "future-fstrings>=0.4.1", + "future-fstrings>=0.4.2", "python-magic>=0.4.15,<0.5", ], dependency_links=[ From 7b0c58aa27b9d697324aacfa4ba4eddfdf8ff1c8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 18 Feb 2018 12:03:20 +0200 Subject: [PATCH 07/15] Handle incoming messages from bot --- mautrix_telegram/abstract_user.py | 139 +++++++++++++++++++++++++++++- mautrix_telegram/bot.py | 24 +++--- mautrix_telegram/matrix.py | 2 + mautrix_telegram/portal.py | 6 +- mautrix_telegram/user.py | 125 +-------------------------- 5 files changed, 156 insertions(+), 140 deletions(-) diff --git a/mautrix_telegram/abstract_user.py b/mautrix_telegram/abstract_user.py index 8cfb033b..4782bbcf 100644 --- a/mautrix_telegram/abstract_user.py +++ b/mautrix_telegram/abstract_user.py @@ -17,10 +17,12 @@ import platform import os -from .tgclient import MautrixTelegramClient -from . import __version__ from telethon.tl.types import * +from .tgclient import MautrixTelegramClient +from .db import Message as DBMessage +from . import portal as po, puppet as pu, __version__ + config = None @@ -50,14 +52,15 @@ class AbstractUser: self.client.add_update_handler(self._update_catch) async def update(self, update): - raise NotImplementedError() + return False async def post_login(self): raise NotImplementedError() async def _update_catch(self, update): try: - await self.update(update) + if not await self.update(update): + await self._update(update) except Exception: self.log.exception("Failed to handle Telegram update") @@ -95,6 +98,134 @@ class AbstractUser: self.client = None self.connected = False + # region Telegram update handling + + async def _update(self, update): + if isinstance(update, + (UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage, + UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage)): + await self.update_message(update) + elif isinstance(update, (UpdateChatUserTyping, UpdateUserTyping)): + await self.update_typing(update) + elif isinstance(update, UpdateUserStatus): + await self.update_status(update) + elif isinstance(update, (UpdateChatAdmins, UpdateChatParticipantAdmin)): + await self.update_admin(update) + elif isinstance(update, UpdateChatParticipants): + portal = po.Portal.get_by_tgid(update.participants.chat_id) + if portal and portal.mxid: + await portal.update_telegram_participants(update.participants.participants) + elif isinstance(update, UpdateChannelPinnedMessage): + portal = po.Portal.get_by_tgid(update.channel_id) + if portal and portal.mxid: + await portal.update_telegram_pin(self, update.id) + elif isinstance(update, (UpdateUserName, UpdateUserPhoto)): + await self.update_others_info(update) + elif isinstance(update, UpdateReadHistoryOutbox): + await self.update_read_receipt(update) + else: + self.log.debug("Unhandled update: %s", update) + + async def update_read_receipt(self, update): + if not isinstance(update.peer, PeerUser): + self.log.debug("Unexpected read receipt peer: %s", update.peer) + return + + portal = po.Portal.get_by_tgid(update.peer.user_id, self.tgid) + if not portal or not portal.mxid: + return + + # We check that these are user read receipts, so tg_space is always the user ID. + message = DBMessage.query.get((update.max_id, self.tgid)) + if not message: + return + + puppet = pu.Puppet.get(update.peer.user_id) + await puppet.intent.mark_read(portal.mxid, message.mxid) + + async def update_admin(self, update): + portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat") + if isinstance(update, UpdateChatAdmins): + await portal.set_telegram_admins_enabled(update.enabled) + elif isinstance(update, UpdateChatParticipantAdmin): + await portal.set_telegram_admin(update.user_id) + else: + self.log.warninng("Unexpected admin status update: %s", update) + + async def update_typing(self, update): + if isinstance(update, UpdateUserTyping): + portal = po.Portal.get_by_tgid(update.user_id, self.tgid, "user") + else: + portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat") + sender = pu.Puppet.get(update.user_id) + await portal.handle_telegram_typing(sender, update) + + async def update_others_info(self, update): + puppet = pu.Puppet.get(update.user_id) + if isinstance(update, UpdateUserName): + if await puppet.update_displayname(self, update): + puppet.save() + elif isinstance(update, UpdateUserPhoto): + if await puppet.update_avatar(self, update.photo.photo_big): + puppet.save() + else: + self.log.warninng("Unexpected other user info update: %s", update) + + async def update_status(self, update): + puppet = pu.Puppet.get(update.user_id) + if isinstance(update.status, UserStatusOnline): + await puppet.intent.set_presence("online") + elif isinstance(update.status, UserStatusOffline): + await puppet.intent.set_presence("offline") + else: + self.log.warning("Unexpected user status update: %s", update) + return + + def get_message_details(self, update): + if isinstance(update, UpdateShortChatMessage): + portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat") + sender = pu.Puppet.get(update.from_id) + elif isinstance(update, UpdateShortMessage): + portal = po.Portal.get_by_tgid(update.user_id, self.tgid, "user") + sender = pu.Puppet.get(self.tgid if update.out else update.user_id) + elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage, + UpdateEditMessage, UpdateEditChannelMessage)): + update = update.message + if isinstance(update.to_id, PeerUser) and not update.out: + portal = po.Portal.get_by_tgid(update.from_id, peer_type="user", + tg_receiver=self.tgid) + else: + portal = po.Portal.get_by_entity(update.to_id, receiver_id=self.tgid) + sender = pu.Puppet.get(update.from_id) if update.from_id else None + else: + self.log.warning( + f"Unexpected message type in User#get_message_details: {type(update)}") + return update, None, None + return update, sender, portal + + def update_message(self, original_update): + update, sender, portal = self.get_message_details(original_update) + + if isinstance(update, MessageService): + if isinstance(update.action, MessageActionChannelMigrateFrom): + self.log.debug(f"Ignoring action %s to %s by %d", update.action, + portal.tgid_log, + sender.id) + return + self.log.debug("Handling action %s to %s by %d", update.action, portal.tgid_log, + sender.id) + return portal.handle_telegram_action(self, sender, update.action) + + user = sender.tgid if sender else "admin" + if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)): + self.log.debug("Handling edit %s to %s by %s", update, portal.tgid_log, user) + return portal.handle_telegram_edit(self, sender, update) + + self.log.debug("Handling message %s to %s by %s", update, portal.tgid_log, user) + return portal.handle_telegram_message(self, sender, update) + + # endregion + def init(context): global config diff --git a/mautrix_telegram/bot.py b/mautrix_telegram/bot.py index 663d5b45..02a15b47 100644 --- a/mautrix_telegram/bot.py +++ b/mautrix_telegram/bot.py @@ -35,7 +35,7 @@ class Bot(AbstractUser): self.token = token self.whitelisted = True self._init_client() - self.chats = {(chat.id, chat.type) for chat in BotChat.query.all()} + self.chats = {chat.id: chat.type for chat in BotChat.query.all()} async def start(self): await super().start() @@ -48,30 +48,32 @@ class Bot(AbstractUser): info = await self.client.get_me() self.tgid = info.id - chat_ids = [id for (id, type) in self.chats if type == "chat"] + chat_ids = [id for id, type in self.chats.items() if type == "chat"] response = await self.client(GetChatsRequest(chat_ids)) for chat in response.chats: if isinstance(chat, ChatForbidden) or chat.left or chat.deactivated: - self.remove_chat(chat.id, "chat") + self.remove_chat(chat.id) channel_ids = [InputChannel(id, 0) - for (id, type) in self.chats + for id, type in self.chats.items() if type == "channel"] for id in channel_ids: try: await self.client(GetChannelsRequest([id])) except (ChannelPrivateError, ChannelInvalidError): - self.remove_chat(id.channel_id, "channel") + self.remove_chat(id.channel_id) def add_chat(self, id, type): - entry = (id, type) - if entry not in self.chats: - self.chats.add(entry) + if id not in self.chats: + self.chats[id] = type self.db.add(BotChat(id=id, type=type)) self.db.commit() - def remove_chat(self, id, type): - self.chats.remove((id, type)) + def remove_chat(self, id): + try: + del self.chats[id] + except KeyError: + pass self.db.delete(BotChat.query.get(id)) self.db.commit() @@ -97,7 +99,7 @@ class Bot(AbstractUser): self.add_chat(to_id, type) elif isinstance(action, MessageActionChatDeleteUser): if action.user_id == self.tgid: - self.remove_chat(to_id, type) + self.remove_chat(to_id) def is_in_chat(self, peer_id): return peer_id in self.chats diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index 208cfa8c..bfa88b49 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -169,10 +169,12 @@ class MatrixHandler: is_command, text = self.is_command(message) sender = await User.get_by_mxid(sender).ensure_started() + print(sender, sender.whitelisted) if not sender.whitelisted: return portal = Portal.get_by_mxid(room) + print(is_command, portal, sender.logged_in, portal.has_bot) if not is_command and portal and (sender.logged_in or portal.has_bot): await portal.handle_matrix_message(sender, message, event_id) return diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 849be917..8988b08b 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -92,6 +92,7 @@ class Portal: @property def has_bot(self): + print("BOT PRINT", self.bot) return self.bot and self.bot.is_in_chat(self.tgid) def _hash_event(self, event): @@ -934,7 +935,10 @@ class Portal: else: self.log.debug("Unhandled Telegram action in %s: %s", self.title, action) - async def set_telegram_admin(self, puppet, user): + async def set_telegram_admin(self, user_id): + puppet = p.Puppet.get(user_id) + user = await u.User.get_by_tgid(user_id) + levels = await self.main_intent.get_power_levels(self.mxid) if user: levels["users"][user.mxid] = 50 diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index 336f0892..4ecd64f6 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -23,7 +23,7 @@ from telethon.tl.types.contacts import ContactsNotModified 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 .db import User as DBUser, Contact as DBContact from .abstract_user import AbstractUser from . import portal as po, puppet as pu @@ -260,129 +260,6 @@ class User(AbstractUser): self.contacts.append(puppet) self.save() - # endregion - # region Telegram update handling - - async def update(self, update): - if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage, - UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage)): - await self.update_message(update) - elif isinstance(update, (UpdateChatUserTyping, UpdateUserTyping)): - await self.update_typing(update) - elif isinstance(update, UpdateUserStatus): - await self.update_status(update) - elif isinstance(update, (UpdateChatAdmins, UpdateChatParticipantAdmin)): - await self.update_admin(update) - elif isinstance(update, UpdateChatParticipants): - portal = po.Portal.get_by_tgid(update.participants.chat_id) - if portal and portal.mxid: - await portal.update_telegram_participants(update.participants.participants) - elif isinstance(update, UpdateChannelPinnedMessage): - portal = po.Portal.get_by_tgid(update.channel_id) - if portal and portal.mxid: - await portal.update_telegram_pin(self, update.id) - elif isinstance(update, (UpdateUserName, UpdateUserPhoto)): - await self.update_others_info(update) - elif isinstance(update, UpdateReadHistoryOutbox): - await self.update_read_receipt(update) - else: - self.log.debug("Unhandled update: %s", update) - - async def update_read_receipt(self, update): - if not isinstance(update.peer, PeerUser): - self.log.debug("Unexpected read receipt peer: %s", update.peer) - return - - portal = po.Portal.get_by_tgid(update.peer.user_id, self.tgid) - if not portal or not portal.mxid: - return - - # We check that these are user read receipts, so tg_space is always the user ID. - message = DBMessage.query.get((update.max_id, self.tgid)) - if not message: - return - - puppet = pu.Puppet.get(update.peer.user_id) - await puppet.intent.mark_read(portal.mxid, message.mxid) - - async def update_admin(self, update): - portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat") - if isinstance(update, UpdateChatAdmins): - await portal.set_telegram_admins_enabled(update.enabled) - elif isinstance(update, UpdateChatParticipantAdmin): - puppet = pu.Puppet.get(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): - if isinstance(update, UpdateUserTyping): - portal = po.Portal.get_by_tgid(update.user_id, self.tgid, "user") - else: - portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat") - sender = pu.Puppet.get(update.user_id) - await portal.handle_telegram_typing(sender, update) - - async def update_others_info(self, update): - puppet = pu.Puppet.get(update.user_id) - if isinstance(update, UpdateUserName): - if await puppet.update_displayname(self, update): - puppet.save() - elif isinstance(update, UpdateUserPhoto): - if await puppet.update_avatar(self, update.photo.photo_big): - puppet.save() - - async def update_status(self, update): - puppet = pu.Puppet.get(update.user_id) - if isinstance(update.status, UserStatusOnline): - await puppet.intent.set_presence("online") - elif isinstance(update.status, UserStatusOffline): - await puppet.intent.set_presence("offline") - else: - self.log.warning("Unexpected user status update: %s", update) - return - - def get_message_details(self, update): - if isinstance(update, UpdateShortChatMessage): - portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat") - sender = pu.Puppet.get(update.from_id) - elif isinstance(update, UpdateShortMessage): - portal = po.Portal.get_by_tgid(update.user_id, self.tgid, "user") - sender = pu.Puppet.get(self.tgid if update.out else update.user_id) - elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage, - UpdateEditMessage, UpdateEditChannelMessage)): - update = update.message - if isinstance(update.to_id, PeerUser) and not update.out: - portal = po.Portal.get_by_tgid(update.from_id, peer_type="user", - tg_receiver=self.tgid) - else: - portal = po.Portal.get_by_entity(update.to_id, receiver_id=self.tgid) - sender = pu.Puppet.get(update.from_id) if update.from_id else None - else: - self.log.warning( - f"Unexpected message type in User#get_message_details: {type(update)}") - return update, None, None - return update, sender, portal - - def update_message(self, original_update): - update, sender, portal = self.get_message_details(original_update) - - if isinstance(update, MessageService): - if isinstance(update.action, MessageActionChannelMigrateFrom): - self.log.debug(f"Ignoring action %s to %s by %d", update.action, portal.tgid_log, - sender.id) - return - self.log.debug("Handling action %s to %s by %d", update.action, portal.tgid_log, - sender.id) - return portal.handle_telegram_action(self, sender, update.action) - - user = sender.tgid if sender else "admin" - if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)): - self.log.debug("Handling edit %s to %s by %s", update, portal.tgid_log, user) - return portal.handle_telegram_edit(self, sender, update) - - self.log.debug("Handling message %s to %s by %s", update, portal.tgid_log, user) - return portal.handle_telegram_message(self, sender, update) - # endregion # region Class instance lookup From 457df435ac082223c42b49f1dd08f3ea3f71b233 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 18 Feb 2018 12:31:07 +0200 Subject: [PATCH 08/15] Deduplicate service messages, typing notifications and presence --- mautrix_appservice/intent_api.py | 16 ++++++--- mautrix_appservice/state_store.py | 32 ++++++++++++++++- mautrix_telegram/abstract_user.py | 8 +++-- mautrix_telegram/portal.py | 58 ++++++++++++++++++++----------- 4 files changed, 85 insertions(+), 29 deletions(-) diff --git a/mautrix_appservice/intent_api.py b/mautrix_appservice/intent_api.py index fb9710d6..c05ba587 100644 --- a/mautrix_appservice/intent_api.py +++ b/mautrix_appservice/intent_api.py @@ -194,12 +194,16 @@ class IntentAPI: content = {"displayname": name} return await self.client.request("PUT", f"/profile/{self.mxid}/displayname", content) - async def set_presence(self, status="online"): + async def set_presence(self, status="online", ignore_cache=False): await self.ensure_registered() + if not ignore_cache and self.state_store.has_presence(self.mxid, status): + return content = { "presence": status } - return await self.client.request("PUT", f"/presence/{self.mxid}/status", content) + resp = await self.client.request("PUT", f"/presence/{self.mxid}/status", content) + self.state_store.set_presence(self.mxid, status) + return resp async def set_avatar(self, url): await self.ensure_registered() @@ -340,14 +344,18 @@ class IntentAPI: await self.ensure_joined(room_id) return await self.client.request("GET", f"/rooms/{room_id}/event/{event_id}") - async def set_typing(self, room_id, is_typing=True, timeout=5000): + async def set_typing(self, room_id, is_typing=True, timeout=5000, ignore_cache=False): await self.ensure_joined(room_id) + if not ignore_cache and is_typing == self.state_store.is_typing(room_id, self.mxid): + return content = { "typing": is_typing } if is_typing: content["timeout"] = timeout - return await self.client.request("PUT", f"/rooms/{room_id}/typing/{self.mxid}", content) + resp = await self.client.request("PUT", f"/rooms/{room_id}/typing/{self.mxid}", content) + self.state_store.set_typing(room_id, self.mxid, is_typing, timeout) + return resp async def mark_read(self, room_id, event_id): await self.ensure_joined(room_id) diff --git a/mautrix_appservice/state_store.py b/mautrix_appservice/state_store.py index 1174dcde..23f43b0d 100644 --- a/mautrix_appservice/state_store.py +++ b/mautrix_appservice/state_store.py @@ -15,14 +15,21 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . import json +import time class StateStore: def __init__(self, autosave_file=None): + self.autosave_file = autosave_file + + # Persistent storage self.registrations = set() self.memberships = {} self.power_levels = {} - self.autosave_file = autosave_file + + # Non-persistent storage + self.presence = {} + self.typing = {} def save(self, file): if isinstance(file, str): @@ -63,6 +70,29 @@ class StateStore: if self.autosave_file: self.save(self.autosave_file) + def set_presence(self, user, presence): + self.presence[user] = presence + + def has_presence(self, user, presence): + try: + return self.presence[user] == presence + except KeyError: + return False + + def set_typing(self, room_id, user, is_typing, timeout=0): + if is_typing: + ts = int(round(time.time() * 1000)) + self.typing[(room_id, user)] = ts + timeout + else: + del self.typing[(room_id, user)] + + def is_typing(self, room_id, user): + ts = int(round(time.time() * 1000)) + try: + return self.typing[(room_id, user)] > ts + except KeyError: + return False + def is_registered(self, user): return user in self.registrations diff --git a/mautrix_telegram/abstract_user.py b/mautrix_telegram/abstract_user.py index 4782bbcf..ce3c6008 100644 --- a/mautrix_telegram/abstract_user.py +++ b/mautrix_telegram/abstract_user.py @@ -144,13 +144,14 @@ class AbstractUser: await puppet.intent.mark_read(portal.mxid, message.mxid) async def update_admin(self, update): + # TODO duplication not checked portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat") if isinstance(update, UpdateChatAdmins): await portal.set_telegram_admins_enabled(update.enabled) elif isinstance(update, UpdateChatParticipantAdmin): await portal.set_telegram_admin(update.user_id) else: - self.log.warninng("Unexpected admin status update: %s", update) + self.log.warning("Unexpected admin status update: %s", update) async def update_typing(self, update): if isinstance(update, UpdateUserTyping): @@ -161,6 +162,7 @@ class AbstractUser: await portal.handle_telegram_typing(sender, update) async def update_others_info(self, update): + # TODO duplication not checked puppet = pu.Puppet.get(update.user_id) if isinstance(update, UpdateUserName): if await puppet.update_displayname(self, update): @@ -169,7 +171,7 @@ class AbstractUser: if await puppet.update_avatar(self, update.photo.photo_big): puppet.save() else: - self.log.warninng("Unexpected other user info update: %s", update) + self.log.warning("Unexpected other user info update: %s", update) async def update_status(self, update): puppet = pu.Puppet.get(update.user_id) @@ -214,7 +216,7 @@ class AbstractUser: return self.log.debug("Handling action %s to %s by %d", update.action, portal.tgid_log, sender.id) - return portal.handle_telegram_action(self, sender, update.action) + return portal.handle_telegram_action(self, sender, update) user = sender.tgid if sender else "admin" if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)): diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 8988b08b..75077dd7 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -64,6 +64,7 @@ class Portal: self._dedup = deque() self._dedup_mxid = {} + self._dedup_action = deque() if save_to_cache: if tgid: @@ -95,36 +96,47 @@ class Portal: print("BOT PRINT", self.bot) 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 - return event.id - + @staticmethod + def _hash_event(event): # Non-channel messages are unique per-user (wtf telegram), so we have no other choice than # to deduplicate based on a hash of the message content. - # The timestamp is only accurate to the second, so we can't rely on solely that either. - hash_content = [event.date.timestamp(), event.message] - if event.fwd_from: - hash_content += [event.fwd_from.from_id, event.fwd_from.channel_id] - elif isinstance(event, Message) and event.media: - try: - hash_content += { - MessageMediaContact: lambda media: [media.user_id], - MessageMediaDocument: lambda media: [media.document.id, media.caption], - MessageMediaPhoto: lambda media: [media.photo.id, media.caption], - MessageMediaGeo: lambda media: [media.geo.long, media.geo.lat], - }[type(event.media)](event.media) - except KeyError: - pass + # The timestamp is only accurate to the second, so we can't rely solely on that either. + if isinstance(event, MessageService): + hash_content = [event.date.timestamp(), event.from_id, event.action] + else: + hash_content = [event.date.timestamp(), event.message] + if event.fwd_from: + hash_content += [event.fwd_from.from_id, event.fwd_from.channel_id] + elif isinstance(event, Message) and event.media: + try: + hash_content += { + MessageMediaContact: lambda media: [media.user_id], + MessageMediaDocument: lambda media: [media.document.id, media.caption], + MessageMediaPhoto: lambda media: [media.photo.id, media.caption], + MessageMediaGeo: lambda media: [media.geo.long, media.geo.lat], + }[type(event.media)](event.media) + except KeyError: + pass return hashlib.md5("-" .join(str(a) for a in hash_content) .encode("utf-8") ).hexdigest() + def is_duplicate_action(self, event): + hash = self._hash_event(event) if self.peer_type != "channel" else event.id + if hash in self._dedup_action: + return True + + self._dedup_action.append(hash) + + if len(self._dedup_action) > 20: + self._dedup_action.popleft() + return False + def is_duplicate(self, event, mxid=None): - hash = self._hash_event(event) + hash = self._hash_event(event) if self.peer_type != "channel" else event.id if hash in self._dedup: return self._dedup_mxid[hash] @@ -901,7 +913,8 @@ class Portal: self.db.add(DBMessage(tgid=evt.id, mx_room=self.mxid, mxid=mxid, tg_space=tg_space)) self.db.commit() - async def handle_telegram_action(self, source, sender, action): + async def handle_telegram_action(self, source, sender, update): + action = update.action if not self.mxid: create_and_exit = (MessageActionChatCreate, MessageActionChannelCreate) create_and_continue = (MessageActionChatAddUser, MessageActionChatJoinedByLink) @@ -911,6 +924,9 @@ class Portal: if not isinstance(action, create_and_continue): return + if self.is_duplicate_action(update): + return + # TODO figure out how to see changes to about text / channel username if isinstance(action, MessageActionChatEditTitle): if await self.update_title(action.title): From 1560647a5d78ba55df3e97786e15418defe2197b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 18 Feb 2018 12:32:56 +0200 Subject: [PATCH 09/15] Fix Matrix->Telegram room meta changes --- mautrix_telegram/matrix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index bfa88b49..407deae5 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -223,7 +223,7 @@ class MatrixHandler: if content_key not in content: # FIXME handle pass - await handler(sender, content[content_key]) + await handler(sender, content[content_key]) def filter_matrix_event(self, event): return (event["sender"] == self.az.bot_mxid From 95fad313c5b68d232648e971c9cbc4c4c0f0057e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 18 Feb 2018 12:37:47 +0200 Subject: [PATCH 10/15] Deduplicate outgoing avatar/title changes --- mautrix_telegram/portal.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 75077dd7..2f515c25 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -603,10 +603,11 @@ class Portal: return if self.peer_type == "chat": - await sender.client(EditChatTitleRequest(chat_id=self.tgid, title=title)) + response = await sender.client(EditChatTitleRequest(chat_id=self.tgid, title=title)) else: channel = await self.get_input_entity(sender) - await sender.client(EditTitleRequest(channel=channel, title=title)) + response = await sender.client(EditTitleRequest(channel=channel, title=title)) + self._register_outgoing_actions_for_dedup(response) self.title = title self.save() @@ -622,11 +623,12 @@ class Portal: photo = InputChatUploadedPhoto(file=uploaded) if self.peer_type == "chat": - updates = await sender.client(EditChatPhotoRequest(chat_id=self.tgid, photo=photo)) + response = await sender.client(EditChatPhotoRequest(chat_id=self.tgid, photo=photo)) else: channel = await self.get_input_entity(sender) - updates = await sender.client(EditPhotoRequest(channel=channel, photo=photo)) - for update in updates.updates: + response = await sender.client(EditPhotoRequest(channel=channel, photo=photo)) + self._register_outgoing_actions_for_dedup(response) + for update in response.updates: is_photo_update = (isinstance(update, UpdateNewMessage) and isinstance(update.message, MessageService) and isinstance(update.message.action, MessageActionChatEditPhoto)) @@ -636,6 +638,13 @@ class Portal: self.save() break + def _register_outgoing_actions_for_dedup(self, response): + for update in response.updates: + check_dedup = (isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)) + and isinstance(update.message, MessageService)) + if check_dedup: + self.is_duplicate_action(update) + # endregion # region Telegram chat info updating From 7f86ec6c5df55a5e248006b0c9e6c5fc238493b3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 19 Feb 2018 18:13:44 +0200 Subject: [PATCH 11/15] Remove debug prints --- mautrix_telegram/matrix.py | 2 -- mautrix_telegram/portal.py | 1 - 2 files changed, 3 deletions(-) diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index 407deae5..e14801d2 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -169,12 +169,10 @@ class MatrixHandler: is_command, text = self.is_command(message) sender = await User.get_by_mxid(sender).ensure_started() - print(sender, sender.whitelisted) if not sender.whitelisted: return portal = Portal.get_by_mxid(room) - print(is_command, portal, sender.logged_in, portal.has_bot) if not is_command and portal and (sender.logged_in or portal.has_bot): await portal.handle_matrix_message(sender, message, event_id) return diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 2f515c25..5018a58e 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -93,7 +93,6 @@ class Portal: @property def has_bot(self): - print("BOT PRINT", self.bot) return self.bot and self.bot.is_in_chat(self.tgid) @staticmethod From d8dc7c59f48c97ce671dd0fca3b3c05c7f0de979 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 19 Feb 2018 19:31:29 +0200 Subject: [PATCH 12/15] Fix chat join rule preset --- mautrix_appservice/intent_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mautrix_appservice/intent_api.py b/mautrix_appservice/intent_api.py index d738e8da..d0979476 100644 --- a/mautrix_appservice/intent_api.py +++ b/mautrix_appservice/intent_api.py @@ -233,7 +233,7 @@ class IntentAPI: content = { "visibility": "private", "is_direct": is_direct, - "preset": "private_chat" if is_public else "public_chat", + "preset": "public_chat" if is_public else "private_chat", "guests_can_join": guests_can_join, } if alias: From f6b18497b41f43bd7ce22193855f7200a3326a29 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 19 Feb 2018 19:32:40 +0200 Subject: [PATCH 13/15] Update bot chats when updating portal participants --- mautrix_telegram/portal.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index bbfd0689..bb1f1bc3 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -280,6 +280,8 @@ class Portal: allowed_tgids = set() for entity in users: puppet = p.Puppet.get(entity.id) + if self.bot and puppet.tgid == self.bot.tgid: + self.bot.add_chat(self.tgid, self.peer_type) allowed_tgids.add(entity.id) await puppet.intent.ensure_joined(self.mxid) await puppet.update_info(source, entity) @@ -290,6 +292,8 @@ class Portal: continue puppet_id = p.Puppet.get_id_from_mxid(user) if puppet_id and puppet_id not in allowed_tgids: + if self.bot and puppet_id == self.bot.tgid: + self.bot.remove_chat(self.tgid) await self.main_intent.kick(self.mxid, user, "User had left this Telegram chat.") continue From f926727a8d3954e7028e9e34749bbe3f5c697461 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 19 Feb 2018 19:39:26 +0200 Subject: [PATCH 14/15] Update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ef35e4b1..57704bd0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # mautrix-telegram -A Matrix-Telegram puppeting bridge. +A Matrix-Telegram hybrid puppeting/relaybot bridge. ### [Wiki](https://github.com/tulir/mautrix-telegram/wiki) From 0a6130607de8bd9885bc32cdeec8c941bbd578ea Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 19 Feb 2018 19:52:45 +0200 Subject: [PATCH 15/15] Fix avatar changes and outgoing meta change deduplication Also move the telegram ID -> MXID generation to Puppet.get_mxid_from_id() --- mautrix_telegram/abstract_user.py | 1 + mautrix_telegram/bot.py | 2 ++ mautrix_telegram/commands/auth.py | 6 ++---- mautrix_telegram/matrix.py | 2 +- mautrix_telegram/portal.py | 5 ++++- mautrix_telegram/puppet.py | 18 +++++++++++------- 6 files changed, 21 insertions(+), 13 deletions(-) diff --git a/mautrix_telegram/abstract_user.py b/mautrix_telegram/abstract_user.py index ce3c6008..260cc504 100644 --- a/mautrix_telegram/abstract_user.py +++ b/mautrix_telegram/abstract_user.py @@ -37,6 +37,7 @@ class AbstractUser: self.whitelisted = False self.client = None self.tgid = None + self.mxid = None def _init_client(self): self.log.debug(f"Initializing client for {self.name}") diff --git a/mautrix_telegram/bot.py b/mautrix_telegram/bot.py index 02a15b47..0a6e71a4 100644 --- a/mautrix_telegram/bot.py +++ b/mautrix_telegram/bot.py @@ -23,6 +23,7 @@ from telethon.tl.functions.channels import GetChannelsRequest from .abstract_user import AbstractUser from .db import BotChat +from . import puppet as pu config = None @@ -47,6 +48,7 @@ class Bot(AbstractUser): async def post_login(self): info = await self.client.get_me() self.tgid = info.id + self.mxid = pu.Puppet.get_mxid_from_id(self.tgid) chat_ids = [id for id, type in self.chats.items() if type == "chat"] response = await self.client(GetChatsRequest(chat_ids)) diff --git a/mautrix_telegram/commands/auth.py b/mautrix_telegram/commands/auth.py index ed254540..3b6a9100 100644 --- a/mautrix_telegram/commands/auth.py +++ b/mautrix_telegram/commands/auth.py @@ -19,6 +19,7 @@ import asyncio from telethon.errors import * from . import command_handler +from .. import puppet as pu @command_handler(needs_auth=False) @@ -37,10 +38,7 @@ async def ping_bot(evt): if not evt.tgbot: return await evt.reply("Telegram message relay bot not configured.") bot_info = await evt.tgbot.client.get_me() - localpart = evt.config.get("bridge.username_template", "telegram_{userid}").format( - userid=bot_info.id) - hs = evt.config["homeserver"]["domain"] - mxid = f"@{localpart}:{hs}" + mxid = pu.Puppet.get_mxid_from_id(bot_info.id) displayname = bot_info.first_name return await evt.reply("Telegram message relay bot is active: " f"[{displayname}](https://matrix.to/#/{mxid}) (ID {bot_info.id})\n\n" diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index e14801d2..25b2eb97 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -248,5 +248,5 @@ class MatrixHandler: elif type == "m.room.power_levels": await 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": + elif type in ("m.room.name", "m.room.avatar", "m.room.topic"): await 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 bb1f1bc3..7cc88c6d 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -654,7 +654,7 @@ class Portal: check_dedup = (isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)) and isinstance(update.message, MessageService)) if check_dedup: - self.is_duplicate_action(update) + self.is_duplicate_action(update.message) # endregion # region Telegram chat info updating @@ -727,6 +727,9 @@ class Portal: await self.update_info(source, entity) self.save() + if self.bot and self.bot.mxid in invites: + self.bot.add_chat(self.tgid, self.peer_type) + levels = await self.main_intent.get_power_levels(self.mxid) levels = self._get_base_power_levels(levels, entity) already_saved = await self.handle_matrix_power_levels(source, levels["users"], {}) diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index 2ee48c79..bfb9c28c 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -31,15 +31,14 @@ class Puppet: db = None az = None mxid_regex = None + username_template = None + hs_domain = None cache = {} def __init__(self, id=None, username=None, displayname=None, photo_id=None): self.id = id + self.mxid = self.get_mxid_from_id(self.id) - self.localpart = config.get("bridge.username_template", "telegram_{userid}").format( - userid=self.id) - hs = config["homeserver"]["domain"] - self.mxid = f"@{self.localpart}:{hs}" self.username = username self.displayname = displayname self.photo_id = photo_id @@ -161,6 +160,10 @@ class Puppet: return int(match.group(1)) return None + @classmethod + def get_mxid_from_id(cls, id): + return f"@{cls.username_template.format(userid=id)}:{cls.hs_domain}" + @classmethod def find_by_username(cls, username): for _, puppet in cls.cache.items(): @@ -177,6 +180,7 @@ class Puppet: def init(context): global config 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}") + Puppet.username_template = config.get("bridge.username_template", "telegram_{userid}") + Puppet.hs_domain = config["homeserver"]["domain"] + localpart = Puppet.username_template.format(userid="(.+)") + Puppet.mxid_regex = re.compile(f"@{localpart}:{Puppet.hs_domain}")