From 2b92483c50ed0bd12da2a1eb726ab33bbd5623f9 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 20 Jul 2018 12:35:22 -0400 Subject: [PATCH 01/10] Initial option to replace Matrix puppet of own Telegram account --- ...dd_access_token_and_custom_mxid_fields_.py | 26 +++++++++ mautrix_telegram/abstract_user.py | 4 +- mautrix_telegram/commands/auth.py | 20 +++++++ mautrix_telegram/db.py | 2 + mautrix_telegram/matrix.py | 30 +++++----- mautrix_telegram/puppet.py | 55 ++++++++++++++----- mautrix_telegram/user.py | 4 +- 7 files changed, 108 insertions(+), 33 deletions(-) create mode 100644 alembic/versions/d5f7b8b4b456_add_access_token_and_custom_mxid_fields_.py diff --git a/alembic/versions/d5f7b8b4b456_add_access_token_and_custom_mxid_fields_.py b/alembic/versions/d5f7b8b4b456_add_access_token_and_custom_mxid_fields_.py new file mode 100644 index 00000000..5c5a940a --- /dev/null +++ b/alembic/versions/d5f7b8b4b456_add_access_token_and_custom_mxid_fields_.py @@ -0,0 +1,26 @@ +"""Add access_token and custom_mxid fields for puppets + +Revision ID: d5f7b8b4b456 +Revises: 6ca3d74d51e4 +Create Date: 2018-07-20 12:09:30.277960 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "d5f7b8b4b456" +down_revision = "6ca3d74d51e4" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("puppet", sa.Column("access_token", sa.String(), nullable=True)) + op.add_column("puppet", sa.Column("custom_mxid", sa.String(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table("puppet") as batch_op: + batch_op.drop_column("custom_mxid") + batch_op.drop_column("access_token") diff --git a/mautrix_telegram/abstract_user.py b/mautrix_telegram/abstract_user.py index 1095e993..a4ae028c 100644 --- a/mautrix_telegram/abstract_user.py +++ b/mautrix_telegram/abstract_user.py @@ -229,9 +229,9 @@ class AbstractUser: async def update_status(self, update): puppet = pu.Puppet.get(update.user_id) if isinstance(update.status, UserStatusOnline): - await puppet.intent.set_presence("online") + await puppet.default_mxid_intent.set_presence("online") elif isinstance(update.status, UserStatusOffline): - await puppet.intent.set_presence("offline") + await puppet.default_mxid_intent.set_presence("offline") else: self.log.warning("Unexpected user status update: %s", update) return diff --git a/mautrix_telegram/commands/auth.py b/mautrix_telegram/commands/auth.py index 67c7ab64..38bcdf52 100644 --- a/mautrix_telegram/commands/auth.py +++ b/mautrix_telegram/commands/auth.py @@ -49,6 +49,26 @@ async def ping_bot(evt: CommandEvent): "To use the bot, simply invite it to a portal room.") +@command_handler(needs_auth=True, management_only=True, + help_section=SECTION_AUTH, + help_args="<_token_>", + help_text="Replace your Telegram account's Matrix puppet with your own Matrix " + "account") +async def login_matrix(evt: CommandEvent): + puppet = pu.Puppet.get(evt.sender.tgid) + prev_info = puppet.custom_mxid, puppet.access_token + puppet.custom_mxid = evt.sender.mxid + puppet.access_token = " ".join(evt.args) + puppet.refresh_intents() + if not await puppet.get_profile(): + puppet.custom_mxid, puppet.access_token = prev_info + puppet.refresh_intents() + return await evt.reply("Failed to verify access token.") + puppet.save() + return await evt.reply( + f"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}.") + + @command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH, help_args="<_phone_> <_full name_>", diff --git a/mautrix_telegram/db.py b/mautrix_telegram/db.py index 5393acad..32099908 100644 --- a/mautrix_telegram/db.py +++ b/mautrix_telegram/db.py @@ -137,6 +137,8 @@ class Puppet(Base): __tablename__ = "puppet" id = Column(Integer, primary_key=True) + custom_mxid = Column(String, nullable=True) + access_token = Column(String, nullable=True) displayname = Column(String, nullable=True) displayname_source = Column(Integer, nullable=True) username = Column(String, nullable=True) diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index 30604296..6da5a9f5 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -39,7 +39,8 @@ class MatrixHandler: displayname = self.config["appservice.bot_displayname"] if displayname: try: - await self.az.intent.set_display_name(displayname if displayname != "remove" else "") + await self.az.intent.set_display_name( + displayname if displayname != "remove" else "") except asyncio.TimeoutError: self.log.exception("TimeoutError when trying to set displayname") @@ -51,19 +52,20 @@ class MatrixHandler: self.log.exception("TimeoutError when trying to set avatar") async def handle_puppet_invite(self, room, puppet, inviter): + intent = puppet.default_mxid_intent self.log.debug(f"{inviter} invited puppet for {puppet.tgid} to {room}") if not await inviter.is_logged_in(): - await puppet.intent.error_and_leave( + await intent.error_and_leave( room, text="Please log in before inviting Telegram puppets.") return portal = Portal.get_by_mxid(room) if portal: if portal.peer_type == "user": - await puppet.intent.error_and_leave( + await intent.error_and_leave( room, text="You can not invite additional users to private chats.") return await portal.invite_telegram(inviter, puppet) - await puppet.intent.join_room(room) + await intent.join_room(room) return try: members = await self.az.intent.get_room_members(room) @@ -71,34 +73,34 @@ class MatrixHandler: members = [] if self.az.bot_mxid not in members: if len(members) > 1: - await puppet.intent.error_and_leave(room, text=None, html=( + await intent.error_and_leave(room, text=None, html=( f"Please invite " f"the bridge bot " f"first if you want to create a Telegram chat.")) return - await puppet.intent.join_room(room) + await intent.join_room(room) portal = Portal.get_by_tgid(puppet.tgid, inviter.tgid, "user") if portal.mxid: try: - await puppet.intent.invite(portal.mxid, inviter.mxid) - await puppet.intent.send_notice(room, text=None, html=( + await intent.invite(portal.mxid, inviter.mxid) + await intent.send_notice(room, text=None, html=( "You already have a private chat with me: " f"" "Link to room" "")) - await puppet.intent.leave_room(room) + await intent.leave_room(room) return except MatrixRequestError: pass portal.mxid = room portal.save() inviter.register_portal(portal) - await puppet.intent.send_notice(room, "Portal to private chat created.") + await intent.send_notice(room, "Portal to private chat created.") else: - await puppet.intent.join_room(room) - await puppet.intent.send_notice(room, "This puppet will remain inactive until a " - "Telegram chat is created for this room.") + await intent.join_room(room) + await intent.send_notice(room, "This puppet will remain inactive until a " + "Telegram chat is created for this room.") async def accept_bot_invite(self, room, inviter): tries = 0 @@ -215,7 +217,7 @@ class MatrixHandler: await portal.handle_matrix_message(sender, message, event_id) return - if not sender.whitelisted or message["msgtype"] != "m.text": + if not sender.whitelisted or message.get("msgtype", "m.unknown") != "m.text": return try: diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index b0977036..1403e64d 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -15,10 +15,12 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from difflib import SequenceMatcher +from typing import Optional import re import logging from telethon.tl.types import UserProfilePhoto +from mautrix_appservice import MatrixError, IntentAPI from .db import Puppet as DBPuppet from . import util @@ -35,10 +37,15 @@ class Puppet: hs_domain = None cache = {} - def __init__(self, id=None, username=None, displayname=None, displayname_source=None, - photo_id=None, is_bot=None, is_registered=False, db_instance=None): + def __init__(self, id=None, access_token=None, custom_mxid=None, username=None, + displayname=None, displayname_source=None, photo_id=None, is_bot=None, + is_registered=False, db_instance=None): self.id = id - self.mxid = self.get_mxid_from_id(self.id) + self.access_token = access_token + self.custom_mxid = custom_mxid + self.is_real_user = self.custom_mxid and self.access_token + self.default_mxid = self.get_mxid_from_id(self.id) + self.mxid = self.custom_mxid or self.default_mxid self.username = username self.displayname = displayname @@ -48,10 +55,23 @@ class Puppet: self.is_registered = is_registered self._db_instance = db_instance - self.intent = self.az.intent.user(self.mxid) + self.default_mxid_intent = self.az.intent.user(self.default_mxid) + self.intent = None # type: IntentAPI + self.refresh_intents() self.cache[id] = self + def refresh_intents(self): + self.is_real_user = self.custom_mxid and self.access_token + self.intent = (self.az.intent.user(self.custom_mxid, self.access_token) + if self.is_real_user else self.default_mxid_intent) + + async def get_profile(self): + try: + return await self.intent.get_profile(self.custom_mxid) + except MatrixError: + return None + @property def tgid(self): return self.id @@ -66,17 +86,21 @@ class Puppet: return self._db_instance def new_db_instance(self): - return DBPuppet(id=self.id, username=self.username, displayname=self.displayname, + return DBPuppet(id=self.id, access_token=self.access_token, custom_mxid=self.custom_mxid, + username=self.username, displayname=self.displayname, displayname_source=self.displayname_source, photo_id=self.photo_id, is_bot=self.is_bot, matrix_registered=self.is_registered) @classmethod def from_db(cls, db_puppet): - return Puppet(db_puppet.id, db_puppet.username, db_puppet.displayname, - db_puppet.displayname_source, db_puppet.photo_id, db_puppet.is_bot, - db_puppet.matrix_registered, db_instance=db_puppet) + return Puppet(db_puppet.id, db_puppet.access_token, db_puppet.custom_mxid, + db_puppet.username, db_puppet.displayname, db_puppet.displayname_source, + db_puppet.photo_id, db_puppet.is_bot, db_puppet.matrix_registered, + db_instance=db_puppet) def save(self): + self.db_instance.access_token = self.access_token + self.db_instance.custom_mxid = self.custom_mxid self.db_instance.username = self.username self.db_instance.displayname = self.displayname self.db_instance.displayname_source = self.displayname_source @@ -145,7 +169,7 @@ class Puppet: displayname = self.get_displayname(info) if displayname != self.displayname: - await self.intent.set_display_name(displayname) + await self.default_mxid_intent.set_display_name(displayname) self.displayname = displayname self.displayname_source = source.tgid return True @@ -156,15 +180,16 @@ class Puppet: async def update_avatar(self, source, photo): photo_id = f"{photo.volume_id}-{photo.local_id}" if self.photo_id != photo_id: - file = await util.transfer_file_to_matrix(self.db, source.client, self.intent, photo) + file = await util.transfer_file_to_matrix(self.db, source.client, + self.default_mxid_intent, photo) if file: - await self.intent.set_avatar(file.mxc) + await self.default_mxid_intent.set_avatar(file.mxc) self.photo_id = photo_id return True return False @classmethod - def get(cls, id, create=True): + def get(cls, id, create=True) -> "Optional[Puppet]": try: return cls.cache[id] except KeyError: @@ -183,7 +208,7 @@ class Puppet: return None @classmethod - def get_by_mxid(cls, mxid, create=True): + def get_by_mxid(cls, mxid, create=True) -> "Optional[Puppet]": tgid = cls.get_id_from_mxid(mxid) return cls.get(tgid, create) if tgid else None @@ -199,7 +224,7 @@ class Puppet: return f"@{cls.username_template.format(userid=id)}:{cls.hs_domain}" @classmethod - def find_by_username(cls, username): + def find_by_username(cls, username) -> "Optional[Puppet]": if not username: return None @@ -214,7 +239,7 @@ class Puppet: return None @classmethod - def find_by_displayname(cls, displayname): + def find_by_displayname(cls, displayname) -> "Optional[Puppet]": if not displayname: return None diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index ea0b92e3..43ad5dec 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -111,14 +111,14 @@ class User(AbstractUser): def new_db_instance(self): return DBUser(mxid=self.mxid, tgid=self.tgid, tg_username=self.username, - contacts=self.db_contacts, saved_contacts=self.saved_contacts, + contacts=self.db_contacts, saved_contacts=self.saved_contacts or 0, portals=self.db_portals) def save(self): self.db_instance.tgid = self.tgid self.db_instance.username = self.username self.db_instance.contacts = self.db_contacts - self.db_instance.saved_contacts = self.saved_contacts + self.db_instance.saved_contacts = self.saved_contacts or 0 self.db_instance.portals = self.db_portals self.db.commit() From ecdca21e32ecee9e0cc578422cf2bdfe12d27a50 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 20 Jul 2018 14:13:13 -0400 Subject: [PATCH 02/10] Stop handling events from custom puppets --- mautrix_telegram/__main__.py | 6 ++- mautrix_telegram/abstract_user.py | 2 +- mautrix_telegram/commands/auth.py | 12 ++---- mautrix_telegram/db.py | 23 +++++------ mautrix_telegram/portal.py | 7 +++- mautrix_telegram/puppet.py | 67 ++++++++++++++++++++++++++++--- mautrix_telegram/user.py | 11 +++-- 7 files changed, 94 insertions(+), 34 deletions(-) diff --git a/mautrix_telegram/__main__.py b/mautrix_telegram/__main__.py index 430696eb..fff2d868 100644 --- a/mautrix_telegram/__main__.py +++ b/mautrix_telegram/__main__.py @@ -110,8 +110,10 @@ with appserv.run(config["appservice.hostname"], config["appservice.port"]) as st context.mx = MatrixHandler(context) init_formatter(context) init_portal(context) - init_puppet(context) - startup_actions = init_user(context) + [start, context.mx.init_as_bot()] + startup_actions = (init_puppet(context) + + init_user(context) + + [start, + context.mx.init_as_bot()]) if context.bot: startup_actions.append(context.bot.start()) diff --git a/mautrix_telegram/abstract_user.py b/mautrix_telegram/abstract_user.py index a4ae028c..dd17234f 100644 --- a/mautrix_telegram/abstract_user.py +++ b/mautrix_telegram/abstract_user.py @@ -124,7 +124,7 @@ class AbstractUser: self.log.debug("%s connected: %s", self.mxid, self.connected) return self - async def ensure_started(self, even_if_no_session=False): + async def ensure_started(self, even_if_no_session=False) -> "AbstractUser": if not self.puppet_whitelisted: return self self.log.debug("ensure_started(%s, connected=%s, even_if_no_session=%s, session_count=%s)", diff --git a/mautrix_telegram/commands/auth.py b/mautrix_telegram/commands/auth.py index 38bcdf52..33c88cb7 100644 --- a/mautrix_telegram/commands/auth.py +++ b/mautrix_telegram/commands/auth.py @@ -56,15 +56,11 @@ async def ping_bot(evt: CommandEvent): "account") async def login_matrix(evt: CommandEvent): puppet = pu.Puppet.get(evt.sender.tgid) - prev_info = puppet.custom_mxid, puppet.access_token - puppet.custom_mxid = evt.sender.mxid - puppet.access_token = " ".join(evt.args) - puppet.refresh_intents() - if not await puppet.get_profile(): - puppet.custom_mxid, puppet.access_token = prev_info - puppet.refresh_intents() + resp = puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid) + if resp == 2: + return await evt.reply("You can only log in as your own Matrix user.") + elif resp == 1: return await evt.reply("Failed to verify access token.") - puppet.save() return await evt.reply( f"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}.") diff --git a/mautrix_telegram/db.py b/mautrix_telegram/db.py index 32099908..81bc0598 100644 --- a/mautrix_telegram/db.py +++ b/mautrix_telegram/db.py @@ -17,14 +17,14 @@ from sqlalchemy import (Column, UniqueConstraint, ForeignKey, ForeignKeyConstraint, Integer, BigInteger, String, Boolean, Text) from sqlalchemy.sql import expression -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, Query import json from .base import Base class Portal(Base): - query = None + query = None # type: Query __tablename__ = "portal" # Telegram chat information @@ -42,9 +42,8 @@ class Portal(Base): about = Column(String, nullable=True) photo_id = Column(String, nullable=True) - class Message(Base): - query = None + query = None # type: Query __tablename__ = "message" mxid = Column(String) @@ -56,7 +55,7 @@ class Message(Base): class UserPortal(Base): - query = None + query = None # type: Query __tablename__ = "user_portal" user = Column(Integer, ForeignKey("user.tgid", onupdate="CASCADE", ondelete="CASCADE"), @@ -70,7 +69,7 @@ class UserPortal(Base): class User(Base): - query = None + query = None # type: Query __tablename__ = "user" mxid = Column(String, primary_key=True) @@ -83,7 +82,7 @@ class User(Base): class RoomState(Base): - query = None + query = None # type: Query __tablename__ = "mx_room_state" room_id = Column(String, primary_key=True) @@ -107,7 +106,7 @@ class RoomState(Base): class UserProfile(Base): - query = None + query = None # type: Query __tablename__ = "mx_user_profile" room_id = Column(String, primary_key=True) @@ -125,7 +124,7 @@ class UserProfile(Base): class Contact(Base): - query = None + query = None # type: Query __tablename__ = "contact" user = Column(Integer, ForeignKey("user.tgid"), primary_key=True) @@ -133,7 +132,7 @@ class Contact(Base): class Puppet(Base): - query = None + query = None # type: Query __tablename__ = "puppet" id = Column(Integer, primary_key=True) @@ -149,14 +148,14 @@ class Puppet(Base): # Fucking Telegram not telling bots what chats they are in 3:< class BotChat(Base): - query = None + query = None # type: Query __tablename__ = "bot_chat" id = Column(Integer, primary_key=True) type = Column(String, nullable=False) class TelegramFile(Base): - query = None + query = None # type: Query __tablename__ = "telegram_file" id = Column(String, primary_key=True) diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 35872438..c78869d2 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -824,7 +824,12 @@ class Portal: mxid=event_id)) self.db.commit() - async def handle_matrix_message(self, sender, message, event_id): + async def handle_matrix_message(self, sender: u.User, message: dict, event_id: str): + puppet = p.Puppet.get_by_custom_mxid(sender.mxid) + if puppet and message.get("net.maunium.telegram.puppet", False): + self.log.debug("Ignoring puppet-sent message by confirmed puppet user %s", sender.mxid) + return + logged_in = not await sender.needs_relaybot(self) client = sender.client if logged_in else self.bot.client sender_id = sender.tgid if logged_in else self.bot.tgid diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index 1403e64d..f708196e 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -36,6 +36,7 @@ class Puppet: username_template = None hs_domain = None cache = {} + by_custom_mxid = {} def __init__(self, id=None, access_token=None, custom_mxid=None, username=None, displayname=None, displayname_source=None, photo_id=None, is_bot=None, @@ -60,22 +61,51 @@ class Puppet: self.refresh_intents() self.cache[id] = self + if self.custom_mxid: + self.by_custom_mxid[self.custom_mxid] = self def refresh_intents(self): self.is_real_user = self.custom_mxid and self.access_token self.intent = (self.az.intent.user(self.custom_mxid, self.access_token) if self.is_real_user else self.default_mxid_intent) - async def get_profile(self): - try: - return await self.intent.get_profile(self.custom_mxid) - except MatrixError: - return None - @property def tgid(self): return self.id + async def switch_mxid(self, access_token, mxid): + prev_mxid = self.custom_mxid + self.custom_mxid = mxid + self.access_token = access_token + self.refresh_intents() + + err = await self.test_custom_mxid() + if err != 0: + return err + + try: + del self.by_custom_mxid[prev_mxid] + except KeyError: + pass + self.mxid = self.custom_mxid or self.default_mxid + self.by_custom_mxid[self.mxid] = self + self.save() + return 0 + + async def test_custom_mxid(self): + if not self.is_real_user: + return 0 + + mxid = await self.intent.whoami() + if not mxid or mxid != self.custom_mxid: + self.custom_mxid = None + self.access_token = None + self.refresh_intents() + if mxid != self.custom_mxid: + return 2 + return 1 + return 0 + async def is_logged_in(self): return True @@ -212,6 +242,30 @@ class Puppet: tgid = cls.get_id_from_mxid(mxid) return cls.get(tgid, create) if tgid else None + @classmethod + def get_by_custom_mxid(cls, mxid): + if not mxid: + raise ValueError("Matrix ID can't be empty") + + try: + return cls.by_custom_mxid[mxid] + except KeyError: + pass + + puppet = DBPuppet.query.filter(DBPuppet.custom_mxid == mxid).one_or_none() + if puppet: + puppet = cls.from_db(puppet) + return puppet + + return None + + @classmethod + def get_all_with_custom_mxid(cls): + return [cls.by_custom_mxid[puppet.mxid] + if puppet.custom_mxid in cls.by_custom_mxid + else cls.from_db(puppet) + for puppet in DBPuppet.query.filter(DBPuppet.custom_mxid is not None).all()] + @classmethod def get_id_from_mxid(cls, mxid): match = cls.mxid_regex.match(mxid) @@ -261,3 +315,4 @@ def init(context): Puppet.hs_domain = config["homeserver"]["domain"] localpart = Puppet.username_template.format(userid="(.+)") Puppet.mxid_regex = re.compile(f"@{localpart}:{Puppet.hs_domain}") + return [puppet.test_custom_mxid() for puppet in Puppet.get_all_with_custom_mxid()] diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index 43ad5dec..110c9f8e 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -14,7 +14,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict +from typing import Dict, Awaitable, Optional import logging import asyncio import re @@ -185,6 +185,9 @@ class User(AbstractUser): # endregion # region Telegram actions that need custom methods + def ensure_started(self, even_if_no_session=False) -> "Awaitable[User]": + return super().ensure_started(even_if_no_session) + async def update_info(self, info: User = None): info = info or await self.client.get_me() changed = False @@ -309,7 +312,7 @@ class User(AbstractUser): # region Class instance lookup @classmethod - def get_by_mxid(cls, mxid, create=True): + def get_by_mxid(cls, mxid, create=True) -> "Optional[User]": if not mxid: raise ValueError("Matrix ID can't be empty") @@ -332,7 +335,7 @@ class User(AbstractUser): return None @classmethod - def get_by_tgid(cls, tgid): + def get_by_tgid(cls, tgid) -> "Optional[User]": try: return cls.by_tgid[tgid] except KeyError: @@ -346,7 +349,7 @@ class User(AbstractUser): return None @classmethod - def find_by_username(cls, username): + def find_by_username(cls, username) -> "Optional[User]": if not username: return None From 54287c344fa55c441df52dfad4b7a66e47f50376 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 21 Jul 2018 10:45:29 -0400 Subject: [PATCH 03/10] Implement syncing with custom puppets --- mautrix_telegram/matrix.py | 21 ++++++--- mautrix_telegram/puppet.py | 96 ++++++++++++++++++++++++++++++++++---- 2 files changed, 103 insertions(+), 14 deletions(-) diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index 6da5a9f5..350ee029 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -289,17 +289,26 @@ class MatrixHandler: await portal.name_change_matrix(user, displayname, prev_displayname, event_id) def filter_matrix_event(self, event): - return (event["sender"] == self.az.bot_mxid - or Puppet.get_id_from_mxid(event["sender"]) is not None) + sender = event.get("sender", None) + if not sender: + return False + return (sender == self.az.bot_mxid + or Puppet.get_id_from_mxid(sender) is not None) + + async def try_handle_event(self, evt): + try: + await self.handle_event(evt) + except Exception: + self.log.exception("Error handling manually received Matrix event") async def handle_event(self, evt): if self.filter_matrix_event(evt): return self.log.debug("Received event: %s", evt) - type = evt["type"] - room_id = evt["room_id"] - event_id = evt["event_id"] - sender = evt["sender"] + type = evt.get("type", "m.unknown") + room_id = evt.get("room_id", None) + event_id = evt.get("event_id", None) + sender = evt.get("sender", None) content = evt.get("content", {}) if type == "m.room.member": state_key = evt["state_key"] diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index f708196e..1fbe6f76 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -15,15 +15,16 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from difflib import SequenceMatcher -from typing import Optional +from typing import Optional, Awaitable import re import logging +import asyncio from telethon.tl.types import UserProfilePhoto -from mautrix_appservice import MatrixError, IntentAPI +from mautrix_appservice import AppService, IntentAPI, MatrixRequestError from .db import Puppet as DBPuppet -from . import util +from . import util, matrix config = None @@ -31,7 +32,9 @@ config = None class Puppet: log = logging.getLogger("mau.puppet") db = None - az = None + az = None # type: AppService + mx = None # type: matrix.MatrixHandler + loop = None # type: asyncio.AbstractEventLoop mxid_regex = None username_template = None hs_domain = None @@ -79,7 +82,7 @@ class Puppet: self.access_token = access_token self.refresh_intents() - err = await self.test_custom_mxid() + err = await self.init_custom_mxid() if err != 0: return err @@ -92,7 +95,7 @@ class Puppet: self.save() return 0 - async def test_custom_mxid(self): + async def init_custom_mxid(self): if not self.is_real_user: return 0 @@ -104,8 +107,84 @@ class Puppet: if mxid != self.custom_mxid: return 2 return 1 + asyncio.ensure_future(self.sync(), loop=self.loop) return 0 + def create_sync_filter(self) -> Awaitable[str]: + return self.intent.client.create_filter(self.custom_mxid, { + "room": { + "include_leave": False, + "state": { + "types": [] + }, + "timeline": { + "types": [], + }, + "ephemeral": { + "types": ["m.typing", "m.receipt"] + }, + "account_data": { + "types": [] + } + }, + "account_data": { + "types": [], + }, + "presence": { + "types": ["m.presence"] + }, + }) + + def handle_sync(self, presence, ephemeral): + presence = [self.mx.try_handle_event(event) for event in presence] + + for room_id, events in ephemeral.items(): + for event in events: + event["room_id"] = room_id + + ephemeral = [self.mx.try_handle_event(event) + for events in ephemeral.values() + for event in events] + + events = ephemeral + presence + coro = asyncio.gather(*events, loop=self.loop) + asyncio.ensure_future(coro, loop=self.loop) + + async def sync(self): + try: + await self._sync() + except Exception: + self.log.exception("Fatal error syncing") + + async def _sync(self): + if not self.is_real_user: + self.log.warning("Called sync() for non-custom puppet.") + return + custom_mxid = self.custom_mxid + access_token_at_start = self.access_token + errors = 0 + next_batch = None + filter_id = await self.create_sync_filter() + self.log.debug(f"Starting syncer for {custom_mxid} with sync filter {filter_id}.") + while access_token_at_start == self.access_token: + try: + sync_resp = await self.intent.client.sync(filter=filter_id, since=next_batch) + errors = 0 + if next_batch is not None: + presence = sync_resp.get("presence", {}).get("events", []) + ephemeral = {room: data.get("ephemeral", {}).get("events", []) + for room, data + in sync_resp.get("rooms", {}).get("join", {}).items()} + self.handle_sync(presence, ephemeral) + next_batch = sync_resp.get("next_batch", None) + except MatrixRequestError as e: + wait = min(errors, 11) ** 2 + self.log.warning(f"Syncer for {custom_mxid} errored: {e}. " + f"Waiting for {wait} seconds...") + errors += 1 + await asyncio.sleep(wait) + self.log.debug(f"Syncer for custom puppet {custom_mxid} stopped.") + async def is_logged_in(self): return True @@ -310,9 +389,10 @@ class Puppet: def init(context): global config - Puppet.az, Puppet.db, config, _, _ = context + Puppet.az, Puppet.db, config, Puppet.loop, _ = context + Puppet.mx = context.mx 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}") - return [puppet.test_custom_mxid() for puppet in Puppet.get_all_with_custom_mxid()] + return [puppet.init_custom_mxid() for puppet in Puppet.get_all_with_custom_mxid()] From 54d7ac5542019cf9c52ed1e206d5ba5142405db6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 21 Jul 2018 11:19:19 -0400 Subject: [PATCH 04/10] Implement Matrix->Telegram typing notifications --- mautrix_telegram/abstract_user.py | 18 +++++++------- mautrix_telegram/matrix.py | 39 +++++++++++++++++++++++++++++++ mautrix_telegram/user.py | 5 ++++ 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/mautrix_telegram/abstract_user.py b/mautrix_telegram/abstract_user.py index dd17234f..0de32c91 100644 --- a/mautrix_telegram/abstract_user.py +++ b/mautrix_telegram/abstract_user.py @@ -36,15 +36,15 @@ class AbstractUser: az = None def __init__(self): - self.puppet_whitelisted = False - self.whitelisted = False - self.relaybot_whitelisted = False - self.is_admin = False - self.client = None - self.tgid = None - self.mxid = None - self.is_relaybot = False - self.is_bot = False + self.puppet_whitelisted = False # type: bool + self.whitelisted = False # type: bool + self.relaybot_whitelisted = False # type: bool + self.is_admin = False # type: bool + self.client = None # type: MautrixTelegramClient + self.tgid = None # type: int + self.mxid = None # type: str + self.is_relaybot = False # type: bool + self.is_bot = False # type: bool @property def connected(self): diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index 350ee029..e78e8a49 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -14,6 +14,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from typing import List import logging import asyncio import re @@ -32,6 +33,7 @@ class MatrixHandler: def __init__(self, context): self.az, self.db, self.config, _, self.tgbot = context self.commands = CommandProcessor(context) + self.previously_typing = [] self.az.matrix_event_handler(self.handle_event) @@ -288,6 +290,37 @@ class MatrixHandler: if await user.needs_relaybot(portal): await portal.name_change_matrix(user, displayname, prev_displayname, event_id) + @staticmethod + def parse_read_receipts(content: dict) -> dict: + return {user_id: event_id + for event_id, receipts in content.items() + for user_id in receipts.get("m.read", {})} + + async def handle_read_receipts(self, room_id: str, receipts: dict): + pass + + async def handle_presence(self, user: str, presence: str): + pass + + async def handle_typing(self, room_id: str, now_typing: List[str]): + portal = Portal.get_by_mxid(room_id) + if not portal: + return + + for user_id in set(self.previously_typing + now_typing): + is_typing = user_id in now_typing + was_typing = user_id in self.previously_typing + if is_typing and was_typing: + continue + + user = await User.get_by_mxid(user_id).ensure_started() + if not user.tgid: + continue + + await user.set_typing(portal.peer, is_typing) + + self.previously_typing = now_typing + def filter_matrix_event(self, event): sender = event.get("sender", None) if not sender: @@ -346,3 +379,9 @@ class MatrixHandler: except KeyError: old_events = set() await self.handle_room_pin(room_id, sender, new_events, old_events) + elif type == "m.receipt": + await self.handle_read_receipts(room_id, self.parse_read_receipts(content)) + elif type == "m.presence": + await self.handle_presence(sender, content.get("presence", "offline")) + elif type == "m.typing": + await self.handle_typing(room_id, content.get("user_ids", [])) diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index 110c9f8e..fb9e426f 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -22,6 +22,7 @@ import re from telethon.tl.types import * from telethon.tl.types.contacts import ContactsNotModified from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest +from telethon.tl.functions.messages import SetTypingRequest from mautrix_appservice import MatrixRequestError from .db import User as DBUser, Contact as DBContact @@ -203,6 +204,10 @@ class User(AbstractUser): if changed: self.save() + def set_typing(self, peer, typing=True, action=SendMessageTypingAction): + return self.client( + SetTypingRequest(peer, action() if typing else SendMessageCancelAction())) + async def log_out(self): for _, portal in self.portals.items(): if portal.has_bot: From e4e100a184e7b97aae5233459bfd45dcccb50414 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 21 Jul 2018 11:23:34 -0400 Subject: [PATCH 05/10] Add option to disable /syncing with custom puppets --- example-config.yaml | 3 +++ mautrix_telegram/config.py | 1 + mautrix_telegram/puppet.py | 3 ++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/example-config.yaml b/example-config.yaml index 7668c236..4f0aa3b1 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -125,6 +125,9 @@ bridge: # Whether or not to fetch and handle Telegram updates at startup from the time the bridge was down. # WARNING: Probably buggy, might get stuck in infinite loop. catch_up: false + # Whether or not to use /sync to get presence, read receipts and typing notifications when using + # your own Matrix account as the Matrix puppet for your Telegram account. + sync_with_custom_puppets: true # The formats to use when sending messages to Telegram via the relay bot. # diff --git a/mautrix_telegram/config.py b/mautrix_telegram/config.py index 1dad0fb4..b7766f47 100644 --- a/mautrix_telegram/config.py +++ b/mautrix_telegram/config.py @@ -191,6 +191,7 @@ class Config(DictWithRecursion): copy("bridge.public_portals") copy("bridge.native_stickers") copy("bridge.catch_up") + copy("bridge.sync_with_custom_puppets") if "bridge.message_formats.m_text" in self: del self["bridge.message_formats"] diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index 1fbe6f76..f3da0c37 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -107,7 +107,8 @@ class Puppet: if mxid != self.custom_mxid: return 2 return 1 - asyncio.ensure_future(self.sync(), loop=self.loop) + if config["bridge.sync_with_custom_puppets"]: + asyncio.ensure_future(self.sync(), loop=self.loop) return 0 def create_sync_filter(self) -> Awaitable[str]: From af46aee191c29b79f52d5a52cd0c4c42d8ad81d8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 22 Jul 2018 17:27:56 -0400 Subject: [PATCH 06/10] Implement Matrix->Telegram read receipts --- mautrix_telegram/matrix.py | 20 ++++++++++++++------ mautrix_telegram/portal.py | 19 +++++++++++++++++++ mautrix_telegram/user.py | 5 ----- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index e78e8a49..81660a9b 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -14,7 +14,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import List +from typing import List, Dict import logging import asyncio import re @@ -291,13 +291,21 @@ class MatrixHandler: await portal.name_change_matrix(user, displayname, prev_displayname, event_id) @staticmethod - def parse_read_receipts(content: dict) -> dict: + def parse_read_receipts(content: dict) -> Dict[str, str]: return {user_id: event_id for event_id, receipts in content.items() for user_id in receipts.get("m.read", {})} - async def handle_read_receipts(self, room_id: str, receipts: dict): - pass + async def handle_read_receipts(self, room_id: str, receipts: Dict[str, str]): + portal = Portal.get_by_mxid(room_id) + if not portal: + return + + for user_id, event_id in receipts.items(): + user = await User.get_by_mxid(user_id).ensure_started() + if not await user.is_logged_in(): + continue + await portal.mark_read(user, event_id) async def handle_presence(self, user: str, presence: str): pass @@ -314,10 +322,10 @@ class MatrixHandler: continue user = await User.get_by_mxid(user_id).ensure_started() - if not user.tgid: + if not await user.is_logged_in(): continue - await user.set_typing(portal.peer, is_typing) + await portal.set_typing(user, is_typing) self.previously_typing = now_typing diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index c78869d2..687d137a 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -31,6 +31,8 @@ from sqlalchemy.orm.exc import FlushError from telethon.tl.functions.messages import * from telethon.tl.functions.channels import * +from telethon.tl.functions.messages import ReadHistoryRequest +from telethon.tl.functions.channels import ReadHistoryRequest as ReadChannelHistoryRequest from telethon.errors import * from telethon.tl.types import * from mautrix_appservice import MatrixRequestError, IntentError @@ -652,6 +654,23 @@ class Portal: return (await self.main_intent.get_displayname(self.mxid, user.mxid) or user.mxid_localpart) + def set_typing(self, user, typing=True, action=SendMessageTypingAction): + return user.client( + SetTypingRequest(self.peer, action() if typing else SendMessageCancelAction())) + + async def mark_read(self, user, event_id): + space = self.tgid if self.peer_type == "channel" else user.tgid + message = DBMessage.query.filter(DBMessage.mxid == event_id, + DBMessage.mx_room == self.mxid, + DBMessage.tg_space == space).one_or_none() + if not message: + return + if self.peer_type == "channel": + await user.client(ReadChannelHistoryRequest( + channel=await self.get_input_entity(user), max_id=message.tgid)) + else: + await user.client(ReadHistoryRequest(peer=self.peer, max_id=message.tgid)) + async def leave_matrix(self, user, source, event_id): if await user.needs_relaybot(self): async with self.require_send_lock(self.bot.tgid): diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index fb9e426f..110c9f8e 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -22,7 +22,6 @@ import re from telethon.tl.types import * from telethon.tl.types.contacts import ContactsNotModified from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest -from telethon.tl.functions.messages import SetTypingRequest from mautrix_appservice import MatrixRequestError from .db import User as DBUser, Contact as DBContact @@ -204,10 +203,6 @@ class User(AbstractUser): if changed: self.save() - def set_typing(self, peer, typing=True, action=SendMessageTypingAction): - return self.client( - SetTypingRequest(peer, action() if typing else SendMessageCancelAction())) - async def log_out(self): for _, portal in self.portals.items(): if portal.has_bot: From 76410ee7cbfa7c4357dc5f9b18b13a4b82a0b67f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 22 Jul 2018 17:42:29 -0400 Subject: [PATCH 07/10] Implement Matrix->Telegram presence --- mautrix_telegram/matrix.py | 5 ++++- mautrix_telegram/user.py | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index 81660a9b..28cf6796 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -308,7 +308,10 @@ class MatrixHandler: await portal.mark_read(user, event_id) async def handle_presence(self, user: str, presence: str): - pass + user = await User.get_by_mxid(user).ensure_started() + if not await user.is_logged_in(): + return + await user.set_presence(presence == "online") async def handle_typing(self, room_id: str, now_typing: List[str]): portal = Portal.get_by_mxid(room_id) diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index 110c9f8e..9c42089a 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -22,6 +22,7 @@ import re from telethon.tl.types import * from telethon.tl.types.contacts import ContactsNotModified from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest +from telethon.tl.functions.account import UpdateStatusRequest from mautrix_appservice import MatrixRequestError from .db import User as DBUser, Contact as DBContact @@ -188,6 +189,9 @@ class User(AbstractUser): def ensure_started(self, even_if_no_session=False) -> "Awaitable[User]": return super().ensure_started(even_if_no_session) + def set_presence(self, online: bool = True): + return self.client(UpdateStatusRequest(offline=not online)) + async def update_info(self, info: User = None): info = info or await self.client.get_me() changed = False From ab098879fdeb48a8b9ea0594141ae90161ca2948 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 22 Jul 2018 18:08:18 -0400 Subject: [PATCH 08/10] Don't set presence when /syncing custom puppets --- mautrix_telegram/puppet.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index f3da0c37..e128561f 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -169,7 +169,8 @@ class Puppet: self.log.debug(f"Starting syncer for {custom_mxid} with sync filter {filter_id}.") while access_token_at_start == self.access_token: try: - sync_resp = await self.intent.client.sync(filter=filter_id, since=next_batch) + sync_resp = await self.intent.client.sync(filter=filter_id, since=next_batch, + set_presence="offline") errors = 0 if next_batch is not None: presence = sync_resp.get("presence", {}).get("events", []) From f3e1c755ebf7b148bb8150b84a0031ed21c33e93 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 22 Jul 2018 18:22:13 -0400 Subject: [PATCH 09/10] Bump mautrix-appservice version requirement --- mautrix_telegram/__main__.py | 3 ++- setup.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/mautrix_telegram/__main__.py b/mautrix_telegram/__main__.py index fff2d868..ad4cb4ef 100644 --- a/mautrix_telegram/__main__.py +++ b/mautrix_telegram/__main__.py @@ -86,7 +86,8 @@ state_store = SQLStateStore(db_session) 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, - verify_ssl=config["homeserver.verify_ssl"], state_store=state_store) + verify_ssl=config["homeserver.verify_ssl"], state_store=state_store, + real_user_content_key="net.maunium.telegram.puppet") public_website = None provisioning_api = None diff --git a/setup.py b/setup.py index 1ed8106d..c2d72e38 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setuptools.setup( install_requires=[ "aiohttp>=3.0.1,<4", - "mautrix-appservice>=0.3.0,<0.4.0", + "mautrix-appservice>=0.3.1,<0.4.0", "SQLAlchemy>=1.2.3,<2", "alembic>=1.0.0,<2", "Markdown>=2.6.11,<3", From 473668645450a8389e22ba6f3def015f60a7abe4 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 23 Jul 2018 11:49:42 -0400 Subject: [PATCH 10/10] Implement Matrix login with web interface --- mautrix_telegram/commands/auth.py | 58 ++++++++++++-- mautrix_telegram/puppet.py | 36 +++++++-- mautrix_telegram/web/common/auth_api.py | 28 +++++++ mautrix_telegram/web/provisioning/__init__.py | 4 + mautrix_telegram/web/public/__init__.py | 69 ++++++++++++++-- mautrix_telegram/web/public/login.css | 53 ++++++++++++- mautrix_telegram/web/public/login.html.mako | 10 +-- .../web/public/matrix-login.html.mako | 78 +++++++++++++++++++ 8 files changed, 311 insertions(+), 25 deletions(-) create mode 100644 mautrix_telegram/web/public/matrix-login.html.mako diff --git a/mautrix_telegram/commands/auth.py b/mautrix_telegram/commands/auth.py index 33c88cb7..9d52ab0d 100644 --- a/mautrix_telegram/commands/auth.py +++ b/mautrix_telegram/commands/auth.py @@ -49,14 +49,62 @@ async def ping_bot(evt: CommandEvent): "To use the bot, simply invite it to a portal room.") +@command_handler(needs_auth=True, + help_section=SECTION_AUTH, + help_text="Revert your Telegram account's Matrix puppet to use the default Matrix " + "account.") +async def logout_matrix(evt: CommandEvent): + puppet = pu.Puppet.get(evt.sender.tgid) + if not puppet.is_real_user: + return await evt.reply("You are not logged in with your Matrix account.") + await puppet.switch_mxid(None, None) + await evt.reply("Reverted your Telegram account's Matrix puppet back to the default.") + + @command_handler(needs_auth=True, management_only=True, help_section=SECTION_AUTH, - help_args="<_token_>", help_text="Replace your Telegram account's Matrix puppet with your own Matrix " "account") async def login_matrix(evt: CommandEvent): puppet = pu.Puppet.get(evt.sender.tgid) - resp = puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid) + if puppet.is_real_user: + return await evt.reply("You have already logged in with your Matrix account. " + "Log out with `$cmdprefix+sp logout-matrix` first.") + allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True) + if allow_matrix_login: + evt.sender.command_status = { + "next": enter_matrix_token, + "action": "Matrix login", + } + if evt.config["appservice.public.enabled"]: + prefix = evt.config["appservice.public.external"] + url = f"{prefix}/matrix-login?token={evt.public_website.make_token(evt.sender.mxid, '/matrix-login')}" + if allow_matrix_login: + return await evt.reply( + "This bridge instance allows you to log in inside or outside Matrix.\n\n" + "If you would like to log in within Matrix, please send your Matrix access token " + "here.\n" + f"If you would like to log in outside of Matrix, [click here]({url}).\n\n" + "Logging in outside of Matrix is recommended, because in-Matrix login would save " + "your access token in the message history.") + return await evt.reply("This bridge instance does not allow logging in inside Matrix.\n\n" + f"Please visit [the login page]({url}) to log in.") + elif allow_matrix_login: + return await evt.reply( + "This bridge instance does not allow you to log in outside of Matrix.\n\n" + "Please send your Matrix access token here to log in.") + return await evt.reply("This bridge instance has been configured to not allow logging in.") + + +async def enter_matrix_token(evt: CommandEvent): + evt.sender.command_status = None + + puppet = pu.Puppet.get(evt.sender.tgid) + if puppet.is_real_user: + return await evt.reply("You have already logged in with your Matrix account. " + "Log out with `$cmdprefix+sp logout-matrix` first.") + + resp = await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid) if resp == 2: return await evt.reply("You can only log in as your own Matrix user.") elif resp == 1: @@ -130,8 +178,8 @@ async def login(evt: CommandEvent): if evt.config["appservice.public.enabled"]: prefix = evt.config["appservice.public.external"] - url = f"{prefix}/login?token={evt.public_website.make_token(evt.sender.mxid)}" - if evt.config.get("bridge.allow_matrix_login", True): + url = f"{prefix}/login?token={evt.public_website.make_token(evt.sender.mxid, '/login')}" + if allow_matrix_login: return await evt.reply( "This bridge instance allows you to log in inside or outside Matrix.\n\n" "If you would like to log in within Matrix, please send your phone number or bot " @@ -144,7 +192,7 @@ async def login(evt: CommandEvent): elif allow_matrix_login: return await evt.reply( "This bridge instance does not allow you to log in outside of Matrix.\n\n" - "Please send your phone number or bot aut token here to start the login process.") + "Please send your phone number or bot auth token here to start the login process.") return await evt.reply("This bridge instance has been configured to not allow logging in.") diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index e128561f..222fd972 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -21,7 +21,7 @@ import logging import asyncio from telethon.tl.types import UserProfilePhoto -from mautrix_appservice import AppService, IntentAPI, MatrixRequestError +from mautrix_appservice import AppService, IntentAPI, IntentError, MatrixRequestError from .db import Puppet as DBPuppet from . import util, matrix @@ -67,15 +67,19 @@ class Puppet: if self.custom_mxid: self.by_custom_mxid[self.custom_mxid] = self + @property + def tgid(self): + return self.id + + async def is_logged_in(self): + return True + + # region Custom puppet management def refresh_intents(self): self.is_real_user = self.custom_mxid and self.access_token self.intent = (self.az.intent.user(self.custom_mxid, self.access_token) if self.is_real_user else self.default_mxid_intent) - @property - def tgid(self): - return self.id - async def switch_mxid(self, access_token, mxid): prev_mxid = self.custom_mxid self.custom_mxid = mxid @@ -91,7 +95,9 @@ class Puppet: except KeyError: pass self.mxid = self.custom_mxid or self.default_mxid - self.by_custom_mxid[self.mxid] = self + if self.mxid != self.default_mxid: + self.by_custom_mxid[self.mxid] = self + await self.leave_rooms_with_default_user() self.save() return 0 @@ -111,6 +117,14 @@ class Puppet: asyncio.ensure_future(self.sync(), loop=self.loop) return 0 + async def leave_rooms_with_default_user(self): + for room_id in await self.default_mxid_intent.get_joined_rooms(): + try: + await self.default_mxid_intent.leave_room(room_id) + await self.intent.ensure_joined(room_id) + except (IntentError, MatrixRequestError): + pass + def create_sync_filter(self) -> Awaitable[str]: return self.intent.client.create_filter(self.custom_mxid, { "room": { @@ -187,8 +201,8 @@ class Puppet: await asyncio.sleep(wait) self.log.debug(f"Syncer for custom puppet {custom_mxid} stopped.") - async def is_logged_in(self): - return True + # endregion + # region DB conversion @property def db_instance(self): @@ -220,6 +234,8 @@ class Puppet: self.db_instance.matrix_registered = self.is_registered self.db.commit() + # endregion + # region Info updating def similarity(self, query): username_similarity = (SequenceMatcher(None, self.username, query).ratio() if self.username else 0) @@ -299,6 +315,9 @@ class Puppet: return True return False + # endregion + # region Getters + @classmethod def get(cls, id, create=True) -> "Optional[Puppet]": try: @@ -387,6 +406,7 @@ class Puppet: return cls.from_db(puppet) return None + # endregion def init(context): diff --git a/mautrix_telegram/web/common/auth_api.py b/mautrix_telegram/web/common/auth_api.py index 70b66136..24fa74e9 100644 --- a/mautrix_telegram/web/common/auth_api.py +++ b/mautrix_telegram/web/common/auth_api.py @@ -23,6 +23,8 @@ from telethon.errors import * from ...commands.auth import enter_password from ...util import format_duration +from ...puppet import Puppet +from ...user import User class AuthAPI(abc.ABC): @@ -36,6 +38,32 @@ class AuthAPI(abc.ABC): errcode=""): raise NotImplementedError() + @abstractmethod + def get_mx_login_response(self, status=200, state="", username="", mxid="", message="", + error="", errcode=""): + raise NotImplementedError() + + async def post_matrix_token(self, user: User, token): + puppet = Puppet.get(user.tgid) + if puppet.is_real_user: + return self.get_mx_login_response(state="already-logged-in", status=409, + error="You have already logged in with your Matrix " + "account.", errcode="already-logged-in") + + resp = await puppet.switch_mxid(token, user.mxid) + if resp == 2: + return self.get_mx_login_response(status=403, errcode="only-login-self", + error="You can only log in as your own Matrix user.") + elif resp == 1: + return self.get_mx_login_response(status=401, errcode="invalid-access-token", + error="Failed to verify access token.") + + return self.get_mx_login_response(mxid=user.mxid, status=200, state="logged-in") + + async def post_matrix_password(self, user, password): + return self.get_mx_login_response(mxid=user.mxid, status=501, error="Not yet implemented", + errcode="not-yet-implemented") + async def post_login_phone(self, user, phone): try: await user.client.sign_in(phone or "+123") diff --git a/mautrix_telegram/web/provisioning/__init__.py b/mautrix_telegram/web/provisioning/__init__.py index 63bb208d..731a91ff 100644 --- a/mautrix_telegram/web/provisioning/__init__.py +++ b/mautrix_telegram/web/provisioning/__init__.py @@ -297,6 +297,10 @@ class ProvisioningAPI(AuthAPI): "errcode": errcode, }, status=status) + def get_mx_login_response(self, status=200, state="", username="", mxid="", message="", + error="", errcode=""): + raise NotImplementedError() + def get_login_response(self, status=200, state="", username="", mxid="", message="", error="", errcode="") -> web.Response: if username: diff --git a/mautrix_telegram/web/public/__init__.py b/mautrix_telegram/web/public/__init__.py index fb5f6de7..be6bb3b0 100644 --- a/mautrix_telegram/web/public/__init__.py +++ b/mautrix_telegram/web/public/__init__.py @@ -24,6 +24,7 @@ import time from ...util import sign_token, verify_token from ...user import User +from ...puppet import Puppet from ..common import AuthAPI @@ -38,28 +39,35 @@ class PublicBridgeWebsite(AuthAPI): self.login = Template( pkg_resources.resource_string("mautrix_telegram", "web/public/login.html.mako")) + self.mx_login = Template( + pkg_resources.resource_string("mautrix_telegram", "web/public/matrix-login.html.mako")) + self.app = web.Application(loop=loop) self.app.router.add_route("GET", "/login", self.get_login) self.app.router.add_route("POST", "/login", self.post_login) + self.app.router.add_route("GET", "/matrix-login", self.get_matrix_login) + self.app.router.add_route("POST", "/matrix-login", self.post_matrix_login) self.app.router.add_static("/", pkg_resources.resource_filename("mautrix_telegram", "web/public/")) - def make_token(self, mxid, expires_in=900): + def make_token(self, mxid, endpoint="/login", expires_in=900): return sign_token(self.secret_key, { "mxid": mxid, + "endpoint": endpoint, "expiry": int(time.time()) + expires_in, }) - def verify_token(self, token): + def verify_token(self, token, endpoint="/login"): token = verify_token(self.secret_key, token) - if token and token.get("expiry", 0) > int(time.time()): + if token and (token.get("expiry", 0) > int(time.time()) and + token.get("endpoint", None) == endpoint): return token.get("mxid", None) return None async def get_login(self, request): state = "bot_token" if request.rel_url.query.get("mode", "") == "bot" else "request" - mxid = self.verify_token(request.rel_url.query.get("token", None)) + mxid = self.verify_token(request.rel_url.query.get("token", None), endpoint="/login") if not mxid: return self.get_login_response(status=401, state="invalid-token") user = User.get_by_mxid(mxid, create=False) if mxid else None @@ -75,14 +83,65 @@ class PublicBridgeWebsite(AuthAPI): return self.get_login_response(mxid=user.mxid, username=user.username) + async def get_matrix_login(self, request): + mxid = self.verify_token(request.rel_url.query.get("token", None), endpoint="/matrix-login") + if not mxid: + return self.get_mx_login_response(status=401, state="invalid-token") + user = User.get_by_mxid(mxid, create=False) if mxid else None + + if not user: + return self.get_mx_login_response(mxid=mxid) + elif not user.puppet_whitelisted: + return self.get_mx_login_response(mxid=user.mxid, error="You are not whitelisted.", + status=403) + await user.ensure_started() + if not await user.is_logged_in(): + return self.get_mx_login_response(mxid=user.mxid, status=403, + error="You are not logged in to Telegram.") + + puppet = Puppet.get(user.tgid) + if puppet.is_real_user: + return self.get_mx_login_response(state="already-logged-in", status=409) + + return self.get_mx_login_response(mxid=user.mxid) + def get_login_response(self, status=200, state="", username="", mxid="", message="", error="", errcode=""): return web.Response(status=status, content_type="text/html", text=self.login.render(username=username, state=state, error=error, message=message, mxid=mxid)) + def get_mx_login_response(self, status=200, state="", username="", mxid="", message="", + error="", errcode=""): + return web.Response(status=status, content_type="text/html", + text=self.mx_login.render(username=username, state=state, error=error, + message=message, mxid=mxid)) + + async def post_matrix_login(self, request): + mxid = self.verify_token(request.rel_url.query.get("token", None), endpoint="/matrix-login") + if not mxid: + return self.get_mx_login_response(status=401, state="invalid-token") + + data = await request.post() + + user = await User.get_by_mxid(mxid).ensure_started() + if not user.puppet_whitelisted: + return self.get_mx_login_response(mxid=user.mxid, error="You are not whitelisted.", + status=403) + elif not await user.is_logged_in(): + return self.get_mx_login_response(mxid=user.mxid, status=403, + error="You are not logged in to Telegram.") + mode = data.get("mode", "access_token") + if mode == "password": + return await self.post_matrix_password(user, data["value"]) + elif mode == "access_token": + return await self.post_matrix_token(user, data["value"]) + return self.get_mx_login_response(mxid=user.mxid, status=400, + error="You must provide an access token or " + "password.") + async def post_login(self, request): - mxid = self.verify_token(request.rel_url.query.get("token", None)) + mxid = self.verify_token(request.rel_url.query.get("token", None), endpoint="/login") if not mxid: return self.get_login_response(status=401, state="invalid-token") diff --git a/mautrix_telegram/web/public/login.css b/mautrix_telegram/web/public/login.css index c7ade95b..7b035792 100644 --- a/mautrix_telegram/web/public/login.css +++ b/mautrix_telegram/web/public/login.css @@ -19,8 +19,8 @@ form > div { display: none; } -form[data-status="request"] > div.status-request, -form[data-status="code"] > div.status-code, +form[data-status="request"] > div.status-request, +form[data-status="code"] > div.status-code, form[data-status="password"] > div.status-password { display: initial; } @@ -48,3 +48,52 @@ form[data-status="password"] > div.status-password { background-color: #d4edda; color: #155724; } + +[type="checkbox"], [type="radio"] { + position: absolute; + opacity: 0; +} + +[type="checkbox"] + label, [type="radio"] + label { + position: relative; + padding-left: 2.5rem; + cursor: pointer; + display: inline-block; +} + +[type="checkbox"] + label:before, [type="radio"] + label:before { + content: ''; + position: absolute; + left: 0; + top: 0.4rem; + width: 1.8rem; + height: 1.8rem; + border: 0.1rem solid #d1d1d1; +} + +[type="radio"] + label:before, [type="radio"] + label:after { + border-radius: 50%; +} + +[type="checkbox"]:checked + label:after, +[type="radio"]:checked + label:after { + content: ''; + width: 0.8rem; + height: 0.8rem; + background: #9b4dca; + position: absolute; + top: 0.9rem; + left: 0.5rem; +} + +[type="radio"]:disabled + label:before, [type="checkbox"]:disabled + label:before { + background-color: #d1d1d1; +} + +[type="radio"]:disabled + label, [type="checkbox"]:disabled + label { + color: #d1d1d1; +} + +[type="radio"]:disabled:checked + label:after, [type="checkbox"]:disabled:checked + label:after { + background: #606c76; +} diff --git a/mautrix_telegram/web/public/login.html.mako b/mautrix_telegram/web/public/login.html.mako index f00b6a69..96db7bac 100644 --- a/mautrix_telegram/web/public/login.html.mako +++ b/mautrix_telegram/web/public/login.html.mako @@ -18,9 +18,9 @@ along with this program. If not, see . - Mautrix-Telegram bridge + Login - Mautrix-Telegram bridge - + @@ -40,10 +40,10 @@ along with this program. If not, see . function goBack() { let params = new URLSearchParams(location.search.slice(1)) - const mxid = params.get("mxid") + const token = params.get("token") params = new URLSearchParams() - if (mxid) { - params.set("mxid", mxid) + if (token) { + params.set("token", token) } location.replace(location.href.split("?")[0] + "?" + params.toString()) } diff --git a/mautrix_telegram/web/public/matrix-login.html.mako b/mautrix_telegram/web/public/matrix-login.html.mako new file mode 100644 index 00000000..6135cb0d --- /dev/null +++ b/mautrix_telegram/web/public/matrix-login.html.mako @@ -0,0 +1,78 @@ + + + + + Matrix login - Mautrix-Telegram bridge + + + + + + + + + + + +
+ % if state == "logged-in": +

Logged in successfully!

+

+ Logged in as ${mxid}. + You can now close this page. +

+ % elif state == "already-logged-in": +

You're already logged in!

+

+ If you want to log in with another account, log out using the + logout-matrix management command first. +

+ % elif state == "invalid-token": +

Invalid or expired token

+
Please ask the bridge bot for a new login link.
+ % else: +

Log in to Matrix

+ % if error: +
${error}
+ % endif + % if message: +
${message}
+ % endif +
+
+ + + + +
+ +
+ + + + + +
+
+ % endif +
+ +