diff --git a/README.md b/README.md index 37d50b9e..677926a4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # mautrix-telegram -**This is the python rewrite branch and can not yet be used.** -**For a somewhat functional JavaScript version, check the master branch.** +**This is the python rewrite branch and is only barely functional.** +**For a JavaScript version with more bugs and features, check the master branch.** A Matrix-Telegram puppeting bridge. @@ -53,7 +53,7 @@ does not do this automatically. ## Features & Roadmap * Matrix → Telegram - * [ ] Plaintext messages + * [x] Plaintext messages * [ ] Formatted messages * [ ] Bot commands (!command -> /command) * [ ] Mentions @@ -72,7 +72,7 @@ does not do this automatically. * [ ] Room metadata changes * [ ] Room invites * Telegram → Matrix - * [ ] Plaintext messages + * [x] Plaintext messages * [ ] Formatted messages * [ ] Bot commands (/command -> !command) * [ ] Mentions @@ -95,7 +95,7 @@ does not do this automatically. * [ ] Initial chat metadata * [ ] Message edits * Initiating chats - * [ ] Automatic portal creation for groups/channels at startup + * [x] Automatic portal creation for groups/channels at startup * [ ] Automatic portal creation for groups/channels when receiving invite/message * [ ] Private chat creation by inviting Telegram user to new room * [ ] Joining public channels/supergroups using room aliases diff --git a/mautrix_appservice/__init__.py b/mautrix_appservice/__init__.py new file mode 100644 index 00000000..3d104ad9 --- /dev/null +++ b/mautrix_appservice/__init__.py @@ -0,0 +1,4 @@ +from .appservice import AppService + +__version__ = "0.1.0" +__author__ = "Tulir Asokan " diff --git a/mautrix_appservice/appservice.py b/mautrix_appservice/appservice.py new file mode 100644 index 00000000..538a8e9e --- /dev/null +++ b/mautrix_appservice/appservice.py @@ -0,0 +1,156 @@ +# matrix-appservice-python - A Matrix Application Service framework written in Python. +# 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 . +# +# Partly based on github.com/Cadair/python-appservice-framework (MIT license) +import asyncio +import logging +import aiohttp +from aiohttp import web +from functools import partial +from contextlib import contextmanager +from .intent_api import HTTPAPI + + +class AppService: + def __init__(self, server, domain, as_token, hs_token, bot_localpart, loop=None, log=None, + query_user=None, query_alias=None): + self.server = server + self.domain = domain + self.as_token = as_token + self.hs_token = hs_token + self.bot_mxid = f"@{bot_localpart}:{domain}" + + self.transactions = [] + + self._http_session = None + self._intent = None + + self.loop = loop or asyncio.get_event_loop() + self.log = log or logging.getLogger("mautrix_appservice") + + self.query_user = query_user or (lambda: None) + self.query_alias = query_alias or (lambda: None) + + self.event_handlers = [] + + self.app = web.Application(loop=self.loop) + self.app.router.add_route("PUT", "/transactions/{transaction_id}", + self._http_handle_transaction) + self.app.router.add_route("GET", "/rooms/{alias}", self._http_query_alias) + self.app.router.add_route("GET", "/users/{user_id}", self._http_query_user) + + @property + def http_session(self): + if self._http_session is None: + raise AttributeError("the http_session attribute can only be used " + "from within the `AppService.run` context manager") + else: + return self._http_session + + @property + def intent(self): + if self._intent is None: + raise AttributeError("the intent attribute can only be used from " + "within the `AppService.run` context manager") + else: + return self._intent + + @contextmanager + def run(self, host="127.0.0.1", port=8080): + self._http_session = aiohttp.ClientSession(loop=self.loop) + self._intent = HTTPAPI(base_url=self.server, bot_mxid=self.bot_mxid, token=self.as_token, log=self.log).bot_intent() + + yield partial(aiohttp.web.run_app, self.app, host=host, port=port) + + self._intent = None + self._http_session.close() + self._http_session = None + + def _check_token(self, request): + try: + token = request.rel_url.query["access_token"] + except KeyError: + return False + + if token != self.hs_token: + return False + + return True + + async def _http_query_user(self, request): + if not self._check_token(request): + return web.Response(status=401) + + user_id = request.match_info["userId"] + + try: + response = self.query_user(user_id) + except: + self.log.exception("Exception in user query handler") + return web.Response(status=500) + + if not response: + return web.Response(status=404) + return web.json_response(response) + + async def _http_query_alias(self, request): + if not self._check_token(request): + return web.Response(status=401) + + alias = request.match_info["alias"] + + try: + response = self.query_alias(alias) + except: + self.log.exception("Exception in alias query handler") + return web.Response(status=500) + + if not response: + return web.Response(status=404) + return web.json_response(response) + + async def _http_handle_transaction(self, request): + if not self._check_token(request): + return web.Response(status=401) + + transaction_id = request.match_info["transaction_id"] + if transaction_id in self.transactions: + return web.Response(status=200) + + json = await request.json() + + try: + events = json["events"] + except KeyError: + return web.Response(status=400) + + for event in events: + self.handle_matrix_event(event) + + self.transactions.append(transaction_id) + + return web.json_response({}) + + def handle_matrix_event(self, event): + for handler in self.event_handlers: + try: + handler(event) + except: + self.log.exception("Exception in Matrix event handler") + + def matrix_event_handler(self, func): + self.event_handlers.append(func) + return func diff --git a/mautrix_appservice/intent_api.py b/mautrix_appservice/intent_api.py new file mode 100644 index 00000000..f8a15d3d --- /dev/null +++ b/mautrix_appservice/intent_api.py @@ -0,0 +1,201 @@ +# 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 re +import json +from matrix_client.api import MatrixHttpApi +from matrix_client.errors import MatrixRequestError + + +class HTTPAPI(MatrixHttpApi): + def __init__(self, base_url, bot_mxid=None, token=None, identity=None, log=None): + self.base_url = base_url + self.token = token + self.identity = identity + self.txn_id = 0 + self.bot_mxid = bot_mxid + self.log = log + self.validate_cert = True + self.children = {} + + def user(self, user): + try: + return self.children[user] + except KeyError: + child = ChildHTTPAPI(user, self) + self.children[user] = child + return child + + def bot_intent(self): + return IntentAPI(self.bot_mxid, self, log=self.log) + + def intent(self, user): + return IntentAPI(user, self.user(user), self, log=self.log) + + def _send(self, method, path, content=None, query_params={}, headers={}): + if not query_params: + query_params = {} + query_params["user_id"] = self.identity + self.log.debug("%s %s %s", method, path, content) + return super()._send(method, path, content, query_params, headers) + + def create_room(self, alias=None, is_public=False, name=None, topic=None, is_direct=False, invitees=()): + """Perform /createRoom. + Args: + alias (str): Optional. The room alias name to set for this room. + is_public (bool): Optional. The public/private visibility. + name (str): Optional. The name for the room. + topic (str): Optional. The topic for the room. + invitees (list): Optional. The list of user IDs to invite. + """ + content = { + "visibility": "public" if is_public else "private" + } + if alias: + content["room_alias_name"] = alias + if invitees: + content["invite"] = invitees + if name: + content["name"] = name + if topic: + content["topic"] = topic + content["is_direct"] = is_direct + + return self._send("POST", "/createRoom", content) + + +class ChildHTTPAPI(HTTPAPI): + def __init__(self, user, parent): + self.identity = user + self.token = parent.token + self.base_url = parent.base_url + self.validate_cert = parent.validate_cert + self.log = parent.log + self.parent = parent + + @property + def txn_id(self): + return self.parent.txn_id + + @txn_id.setter + def txn_id(self, value): + self.parent.txn_id = value + + +class IntentError(Exception): + def __init__(self, message, source): + super().__init__(message) + self.source = source + + +def matrix_error_code(err): + try: + data = json.loads(err.content) + return data["errcode"] + except: + return err.content + + +class IntentAPI: + mxid_regex = re.compile("@(.+):(.+)") + + def __init__(self, mxid, client, bot=None, log=None): + self.client = client + self.bot = bot + self.mxid = mxid + self.log = log + + results = self.mxid_regex.search(mxid) + if not results: + raise ValueError("invalid MXID") + self.localpart = results.group(1) + + self.memberships = {} + self.power_levels = {} + self.registered = False + + def user(self, user): + if not self.bot: + return self.client.intent(user) + else: + raise ValueError("IntentAPI#user() is only available for base intent objects.") + + def set_display_name(self, name): + self._ensure_registered() + return self.client.set_display_name(self.mxid, name) + + def create_room(self, alias=None, is_public=False, name=None, topic=None, is_direct=False, invitees=()): + self._ensure_registered() + return self.client.create_room(alias, is_public, name, topic, is_direct, invitees) + + def send_text(self, room_id, text, html=False, unformatted_text=None, notice=False): + if html: + return self.send_message(room_id, { + "body": unformatted_text or text, + "msgtype": "m.notice" if notice else "m.text", + "format": "org.matrix.custom.html", + "formatted_body": text, + }) + else: + return self.send_message(room_id, { + "body": text, + "msgtype": "m.notice" if notice else "m.text", + }) + + def send_message(self, room_id, body): + return self.send_event(room_id, "m.room.message", body) + + def send_event(self, room_id, type, body, txn_id=None, timestamp=None): + self._ensure_joined(room_id) + self._ensure_has_power_level_for(room_id, type) + return self.client.send_message_event(room_id, type, body, txn_id, timestamp) + + def send_state_event(self, room_id, type, body, state_key="", timestamp=None): + self._ensure_joined(room_id) + self._ensure_has_power_level_for(room_id, type) + return self.client.send_state_event(room_id, type, body, state_key, timestamp) + + def join_room(self, room_id): + return self._ensure_joined(room_id, ignore_cache=True) + + def _ensure_joined(self, room_id, ignore_cache=False): + if ignore_cache and self.memberships.get(room_id, "") == "join": + return + self._ensure_registered() + try: + self.client.join_room(room_id) + self.memberships[room_id] = "join" + except MatrixRequestError as e: + if matrix_error_code(e) != "M_FORBIDDEN" and not self.bot: + raise IntentError(f"Failed to join room {room_id} as {self.mxid}", e) + try: + self.bot.invite_user(room_id, self.mxid) + self.client.join_room(room_id) + self.memberships[room_id] = "join" + except MatrixRequestError as e2: + raise IntentError(f"Failed to join room {room_id} as {self.mxid}", e2) + + def _ensure_registered(self): + if self.registered: + return + try: + self.client.register({"username": self.localpart}) + except MatrixRequestError as e: + if matrix_error_code(e) != "M_USER_IN_USE": + raise IntentError(f"Failed to register {self.mxid}", e) + self.registered = True + + def _ensure_has_power_level_for(self, room_id, event_type): + pass diff --git a/mautrix_telegram/__init__.py b/mautrix_telegram/__init__.py index cca5d9bd..c7327493 100644 --- a/mautrix_telegram/__init__.py +++ b/mautrix_telegram/__init__.py @@ -1 +1,2 @@ -from .config import Config +__version__ = "0.1.0" +__author__ = "Tulir Asokan " diff --git a/mautrix_telegram/__main__.py b/mautrix_telegram/__main__.py index cc95e5f0..1111725a 100644 --- a/mautrix_telegram/__main__.py +++ b/mautrix_telegram/__main__.py @@ -15,7 +15,27 @@ # along with this program. If not, see . import argparse import sys -from . import Config +import logging + +import sqlalchemy as sql +from sqlalchemy import orm + +from mautrix_appservice import AppService + +from .base import Base +from .config import Config +from .matrix import MatrixHandler + +from .db import init as init_db +from .user import init as init_user +from .portal import init as init_portal +from .puppet import init as init_puppet + +log = logging.getLogger("mau") +time_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s@%(name)s] %(message)s") +handler = logging.StreamHandler() +handler.setFormatter(time_formatter) +log.addHandler(handler) parser = argparse.ArgumentParser( description="A Matrix-Telegram puppeting bridge.", @@ -36,3 +56,27 @@ if args.generate_registration: config.save() print(f"Registration generated and saved to {config.registration_path}") sys.exit(0) + +if config["appservice.debug"]: + log.setLevel(logging.DEBUG) + log.debug("Debug messages enabled.") + +db_engine = sql.create_engine(config.get("appservice.database", "sqlite:///mautrix-telegram.db")) +db_factory = orm.sessionmaker(bind=db_engine) +db = db_factory() +Base.metadata.bind = db_engine +Base.metadata.create_all() + +appserv = AppService(config["homeserver.address"], config["homeserver.domain"], + config["appservice.as_token"], config["appservice.hs_token"], + config["appservice.bot_username"], log=log.getChild("as")) +context = (appserv, db, log, config) + + +with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start: + init_db(db_factory) + init_portal(context) + init_puppet(context) + init_user(context) + MatrixHandler(context) + start() diff --git a/mautrix_telegram/base.py b/mautrix_telegram/base.py new file mode 100644 index 00000000..c64447da --- /dev/null +++ b/mautrix_telegram/base.py @@ -0,0 +1,2 @@ +from sqlalchemy.ext.declarative import declarative_base +Base = declarative_base() diff --git a/mautrix_telegram/commands.py b/mautrix_telegram/commands.py new file mode 100644 index 00000000..968258f6 --- /dev/null +++ b/mautrix_telegram/commands.py @@ -0,0 +1,114 @@ +# 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 . +from contextlib import contextmanager +import markdown + +command_handlers = {} + + +def command_handler(func): + command_handlers[func.__name__] = func + + +class CommandHandler: + def __init__(self, context): + self.appserv, self.db, log, self.config = context + self.log = log.getChild("commands") + self.command_prefix = self.config["bridge.commands.prefix"] + self._room_id = None + + def handle(self, room, sender, command, args, is_management, is_portal): + with self.handler(sender, room, command) as handle_command: + handle_command(self, sender, args, is_management, is_portal) + + @contextmanager + def handler(self, sender, room, command): + self._room_id = room + try: + command = command_handlers[command] + except KeyError: + if sender.command_status and "next" in sender.command_status: + command = sender.command_status["next"] + else: + command = command_handlers["unknown_command"] + yield command + self._room_id = None + + def reply(self, message, allow_html=False, render_markdown=True): + if not self._room_id: + raise AttributeError("the reply function can only be used from within" + "the `CommandHandler.run` context manager") + + message = message.replace("$cmdprefix", self.command_prefix) + html = None + if render_markdown: + html = markdown.markdown(message, safe_mode="escape" if allow_html else False) + elif allow_html: + html = message + self.appserv.api.send_message_event(self._room_id, "m.room.message", { + "msgtype": "m.notice", + "body": message, + "format": "org.matrix.custom.html" if html else None, + "formatted_body": html or None, + }) + + @command_handler + def cancel(self, sender, args, is_management, is_portal): + if sender.command_status: + sender.command_status = None + return self.reply(f"{sender.command_status.action} cancelled.") + else: + return self.reply("No ongoing command.") + + @command_handler + def unknown_command(self, sender, args, is_management, is_portal): + if is_management: + return self.reply("Unknown command. Try `help` for help.") + else: + return self.reply("Unknown command. Try `$cmdprefix help` for help.") + + @command_handler + def help(self, sender, args, is_management, is_portal): + if is_management: + management_status = ("This is a management room: prefixing commands" + "with `$cmdprefix` is not required.\n") + elif is_portal: + management_status = ("**This is a portal room**: you must always" + "prefix commands with `$cmdprefix`.\n" + "Management commands will not be sent to Telegram.") + else: + management_status = ("**This is not a management room**: you must" + "prefix commands with `$cmdprefix`.\n") + help = """ +_**Generic bridge commands**: commands for using the bridge that aren't related to Telegram._ +**help** - Show this help message. +**cancel** - Cancel an ongoing action (such as login). + +_**Telegram actions**: commands for using the bridge to interact with Telegram._ +**login** <_phone_> - Request an authentication code. +**logout** - Log out from Telegram. +**search** [_-r|--remote_] <_query_> - Search your contacts or the Telegram servers for users. +**create** <_group/channel_> [_room ID_] - Create a Telegram chat of the given type for a Matrix room. + If the room ID is not specified, a chat for the current room is created. +**upgrade** - Upgrade a normal Telegram group to a supergroup. + +_**Temporary commands**: commands that will be replaced with more Matrix-y actions later._ +**pm** <_id_> - Open a private chat with the given Telegram user ID. + +_**Debug commands**: commands to help in debugging the bridge. Disabled by default._ +**api** <_method_> <_args_> - Call a Telegram API method. Args is always a single JSON object. +""" + return self.reply(management_status + help) diff --git a/mautrix_telegram/db.py b/mautrix_telegram/db.py new file mode 100644 index 00000000..a9353daf --- /dev/null +++ b/mautrix_telegram/db.py @@ -0,0 +1,53 @@ +# 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 . +from sqlalchemy import orm, \ + Column, ForeignKey, \ + Integer, String +from sqlalchemy.orm.scoping import scoped_session +from .base import Base + + +class Portal(Base): + __tablename__ = "portal" + + tgid = Column(Integer, primary_key=True) + peer_type = Column(String) + mxid = Column(String, unique=True, nullable=True) + + +class User(Base): + __tablename__ = "user" + + mxid = Column(String, primary_key=True) + tgid = Column(Integer, nullable=True) + + def __init__(self, mxid, tgid=None): + self.mxid = mxid + self.tgid = tgid + + +class Puppet(Base): + __tablename__ = "puppet" + + id = Column(Integer, primary_key=True) + displayname = Column(String, nullable=True) + + +def init(db_factory): + db = scoped_session(db_factory) + Portal.query = db.query_property() + User.query = db.query_property() + Puppet.query = db.query_property() diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py new file mode 100644 index 00000000..5dec713d --- /dev/null +++ b/mautrix_telegram/matrix.py @@ -0,0 +1,110 @@ +# 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 re + +from .user import User +from .portal import Portal +from .commands import CommandHandler + + +class MatrixHandler: + def __init__(self, context): + self.az, self.db, log, self.config = context + self.log = log.getChild("mx") + self.commands = CommandHandler(context) + + alias_format = self.config.get("bridge.alias_template", "telegram_{}").format("(.+)") + hs = self.config["homeserver"]["domain"] + self.localpart_regex = re.compile(f"@{alias_format}:{hs}") + + self.az.matrix_event_handler(self.handle_event) + + def is_puppet(self, mxid): + match = self.localpart_regex.match(mxid) + return True if match else False + + def handle_invite(self, room, user, inviter): + if user == self.az.bot_mxid: + self.az.intent.join_room(room) + return + tgid = self.get_puppet(user) + if tgid: + # TODO handle puppet invite + self.log.debug(f"{inviter} invited puppet for {tgid} to {room}") + return + # These can probably be ignored + self.log.debug(f"{inviter} invited {user} to {room}") + + def handle_part(self, room, user): + self.log.debug(f"{user} left {room}") + + def is_management(self, room): + memberships = self.az.intent.get_room_members(room) + return [membership["state_key"] for membership in memberships["chunk"] if + membership["content"]["membership"] == "join"] + + def is_command(self, message): + text = message.get("body", "") + prefix = self.config["bridge.commands.prefix"] + is_command = text.startswith(prefix) + if is_command: + text = text[len(prefix) + 1:] + return is_command, text + + def handle_message(self, room, sender, message): + self.log.debug(f"{sender} sent {message} to ${room}") + + is_command, text = self.is_command(message) + sender = User.get_by_mxid(sender) + + portal = Portal.get_by_mxid(room) + if portal and not is_command: + portal.handle_matrix_message(sender, message) + return + + if message["msgtype"] != "m.text": + return + + is_management = len(self.is_management(room)) == 2 + if is_command or is_management: + try: + command, arguments = text.split(" ", 1) + args = arguments.split(" ") + except ValueError: + # Not enough values to unpack, i.e. no arguments + command = text + args = [] + self.commands.handle(room, sender, command, args, is_management, is_portal=portal is not None) + + def filter_matrix_event(self, event): + return event["sender"] == self.az.bot_mxid or self.is_puppet(event["sender"]) + + def handle_event(self, evt): + if self.filter_matrix_event(evt): + return + self.log.debug("Received event: %s", evt) + type = evt["type"] + content = evt.get("content", {}) + if type == "m.room.member": + membership = content.get("membership", {}) + if membership == "invite": + self.handle_invite(evt["room_id"], evt["state_key"], evt["sender"]) + elif membership == "leave": + self.handle_part(evt["room_id"], evt["state_key"]) + elif membership == "join": + pass + elif type == "m.room.message": + self.handle_message(evt["room_id"], evt["sender"], content) diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py new file mode 100644 index 00000000..6f6ee043 --- /dev/null +++ b/mautrix_telegram/portal.py @@ -0,0 +1,163 @@ +# 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 . +from telethon.tl.functions.messages import GetFullChatRequest +from telethon.tl.functions.channels import GetParticipantsRequest +from telethon.tl.types import ChannelParticipantsRecent, PeerChat, PeerChannel, PeerUser +from .db import Portal as DBPortal +from . import puppet as p + +config = None + + +class Portal: + by_mxid = {} + by_tgid = {} + + def __init__(self, tgid, peer_type, mxid=None): + self.mxid = mxid + self.tgid = tgid + self.peer_type = peer_type + + self.by_tgid[tgid] = self + if mxid: + self.by_mxid[mxid] = self + + def create_room(self, user, entity=None, invites=[]): + self.log.debug("Creating room for %d", self.tgid) + if not entity: + entity = user.client.get_entity(self.peer) + self.log.debug("Fetched data: %s", entity) + + if self.mxid: + self.invite_matrix(invites) + users = self.get_users(user, entity) + self.sync_telegram_users(users) + return self.mxid + + try: + title = entity.title + except AttributeError: + title = None + + direct = self.peer_type == "user" + puppet = p.Puppet.get(self.tgid) if direct else None + intent = puppet.intent if direct else self.az.intent + room = intent.create_room(invitees=invites, name=title, + is_direct=direct) + if not room: + raise Exception(f"Failed to create room for {self.tgid}") + + self.mxid = room["room_id"] + self.by_mxid[self.mxid] = self + self.save() + if not direct: + users = self.get_users(user, entity) + self.sync_telegram_users(users) + else: + puppet.update_info(entity) + puppet.intent.join_room(self.mxid) + + + def sync_telegram_users(self, users=[]): + for entity in users: + user = p.Puppet.get(entity.id) + user.update_info(entity) + user.intent.join_room(self.mxid) + + def handle_matrix_message(self, sender, message): + type = message["msgtype"] + if type == "m.text": + sender.client.send_message(self.peer, message["body"]) + + def handle_telegram_message(self, sender, message): + self.log.debug("Sending %s to %s by %d", message.message, self.mxid, sender.id) + sender.intent.send_text(self.mxid, message.message) + + @property + def peer(self): + if self.peer_type == "user": + return PeerUser(user_id=self.tgid) + elif self.peer_type == "chat": + return PeerChat(chat_id=self.tgid) + elif self.peer_type == "channel": + return PeerChannel(channel_id=self.tgid) + + def get_users(self, user, entity): + if self.peer_type == "chat": + return user.client(GetFullChatRequest(chat_id=self.tgid)).users + elif self.peer_type == "channel": + participants = user.client(GetParticipantsRequest( + entity, ChannelParticipantsRecent(), offset=0, limit=100, hash=0 + )) + return participants.users + elif self.peer_type == "user": + return [entity] + + def invite_matrix(self, users=[]): + pass + + def to_db(self): + return self.db.merge(DBPortal(tgid=self.tgid, peer_type=self.peer_type, mxid=self.mxid)) + + def save(self): + self.to_db() + self.db.commit() + + @classmethod + def from_db(cls, db_portal): + return Portal(db_portal.tgid, db_portal.peer_type, db_portal.mxid) + + @classmethod + def get_by_mxid(cls, mxid): + try: + return cls.by_mxid[mxid] + except KeyError: + pass + + portal = DBPortal.query.filter(DBPortal.mxid == mxid).one_or_none() + if portal: + return cls.from_db(portal) + + return None + + @classmethod + def get_by_tgid(cls, tgid, peer_type=None): + try: + return cls.by_tgid[tgid] + except KeyError: + pass + + portal = DBPortal.query.get(tgid) + if portal: + return cls.from_db(portal) + + if peer_type: + portal = Portal(tgid, peer_type) + cls.db.add(portal.to_db()) + portal.save() + return portal + + return None + + @classmethod + def get_by_entity(cls, entity): + return cls.get_by_tgid(entity.id, entity.__class__.__name__.lower()) + + +def init(context): + global config + Portal.az, Portal.db, log, config = context + Portal.log = log.getChild("portal") diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py new file mode 100644 index 00000000..14734956 --- /dev/null +++ b/mautrix_telegram/puppet.py @@ -0,0 +1,94 @@ +# 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 . +from telethon import TelegramClient +from telethon.tl.types import User as UserEntity, Chat as ChatEntity, Channel as ChannelEntity +from .db import Puppet as DBPuppet +from . import portal as p + +config = None + + +class Puppet: + cache = {} + + def __init__(self, id=None, displayname=None): + self.id = id + + self.localpart = config.get("bridge.alias_template", "telegram_{}").format(self.id) + hs = config["homeserver"]["domain"] + self.mxid = f"@{self.localpart}:{hs}" + self.displayname = displayname + self.intent = self.az.intent.user(self.mxid) + + self.cache[id] = self + + def to_db(self): + return self.db.merge(DBPuppet(id=self.id, displayname=self.displayname)) + + @classmethod + def from_db(cls, db_puppet): + return Puppet(db_puppet.id, db_puppet.displayname) + + def save(self): + self.to_db() + self.db.commit() + + def get_displayname(self, info): + if info.first_name or info.last_name: + name = " ".join([info.first_name or "", info.last_name or ""]).strip() + elif info.username: + name = info.username + elif info.phone_number: + name = info.phone_number + else: + name = info.id + return config.get("bridge.displayname_template", "{} (Telegram)").format(name) + + def update_info(self, info): + changed = False + displayname = self.get_displayname(info) + if displayname != self.displayname: + self.intent.set_display_name(displayname) + self.displayname = displayname + changed = True + + if changed: + self.save() + + @classmethod + def get(cls, id, create=True): + try: + return cls.cache[id] + except KeyError: + pass + + puppet = DBPuppet.query.get(id) + if puppet: + return cls.from_db(puppet) + + if create: + puppet = cls(id) + cls.db.add(puppet.to_db()) + cls.db.commit() + return puppet + + return None + + +def init(context): + global config + Puppet.az, Puppet.db, log, config = context + Puppet.log = log.getChild("puppet") diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py new file mode 100644 index 00000000..3bda746d --- /dev/null +++ b/mautrix_telegram/user.py @@ -0,0 +1,146 @@ +# 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 traceback +from telethon import TelegramClient +from telethon.tl.types import User as UserEntity, Chat as ChatEntity, Channel as ChannelEntity, \ + UpdateShortMessage, UpdateShortChatMessage +from .db import User as DBUser +from . import portal as po, puppet as pu + +config = None + + +class User: + by_mxid = {} + by_tgid = {} + + def __init__(self, mxid, tgid=None): + self.mxid = mxid + self.tgid = tgid + + self.command_status = None + self.connected = False + self.logged_in = False + self.client = None + + self.by_mxid[mxid] = self + if tgid: + self.by_tgid[tgid] = self + + def to_db(self): + return self.db.merge(DBUser(self.mxid, self.tgid)) + + def save(self): + self.to_db() + self.db.commit() + + @classmethod + def from_db(cls, db_user): + return User(db_user.mxid, db_user.tgid) + + def start(self): + self.client = TelegramClient(self.mxid, + config["telegram.api_id"], + config["telegram.api_hash"], + update_workers=2) + self.connected = self.client.connect() + self.logged_in = self.client.is_user_authorized() + if self.logged_in: + self.sync_dialogs() + self.client.add_update_handler(self.update_catch) + return self + + def stop(self): + self.client.disconnect() + self.client = None + self.connected = False + + def sync_dialogs(self): + dialogs = self.client.get_dialogs(limit=30) + for dialog in dialogs: + entity = dialog.entity + if isinstance(entity, UserEntity): + continue + elif isinstance(entity, ChatEntity) and entity.deactivated: + continue + portal = po.Portal.get_by_entity(entity) + portal.create_room(self, entity, invites=[self.mxid]) + # portal.update_info(self, entity) + + def update_catch(self, update): + try: + self.update(update) + except: + self.log.exception("Failed to handle Telegram update") + + def update(self, update): + if isinstance(update, UpdateShortChatMessage): + portal = po.Portal.get_by_tgid(update.chat_id, "chat") + sender = pu.Puppet.get(update.from_id) + elif isinstance(update, UpdateShortMessage): + portal = po.Portal.get_by_tgid(update.user_id, "user") + sender = pu.Puppet.get(self.tgid if update.out else update.user_id) + else: + self.log.debug("Unhandled update: %s", update) + return + + if not portal.mxid: + portal.create_room(self, invites=[self.mxid]) + self.log.debug("Handling message portal=%s sender=%s update=%s", portal, sender, + update) + portal.handle_telegram_message(sender, update) + + @classmethod + def get_by_mxid(cls, mxid, create=True): + try: + return cls.by_mxid[mxid] + except KeyError: + pass + + user = DBUser.query.get(mxid) + if user: + return cls.from_db(user).start() + + if create: + user = cls(mxid) + cls.db.add(user.to_db()) + cls.db.commit() + return user.start() + + return None + + @classmethod + def get_by_tgid(cls, tgid): + try: + return cls.by_tgid[tgid] + except KeyError: + pass + + user = DBUser.query.filter(DBUser.tgid == tgid).one_or_none() + if user: + return cls.from_db(user).start() + + return None + + +def init(context): + global config + User.az, User.db, log, config = context + User.log = log.getChild("user") + + users = [User.from_db(user) for user in DBUser.query.all()] + for user in users: + user.start() diff --git a/requirements.txt b/requirements.txt index bfc386fc..bc946534 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,7 @@ -aiohttp==2.3.7 -async-timeout==2.0.0 -certifi==2017.11.5 -chardet==3.0.4 -idna==2.6 -matrix-client==0.0.6 -multidict==3.3.2 -pkg-resources==0.0.0 -pyaes==1.6.1 -pyasn1==0.4.2 -requests==2.18.4 -rsa==3.4.2 -ruamel.yaml==0.15.35 -SQLAlchemy==1.2.1 -Telethon==0.16.1.3 -urllib3==1.22 -yarl==0.17.0 +aiohttp +-e git+git://github.com/Cadair/matrix-python-sdk#egg=matrix_client +#matrix-client +ruamel.yaml +SQLAlchemy +Telethon +Markdown