Add plain text message bridging

This commit is contained in:
Tulir Asokan
2018-01-20 23:59:51 +02:00
parent ad6a9ebae3
commit 899f491707
14 changed files with 1102 additions and 24 deletions
+5 -5
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
from .appservice import AppService
__version__ = "0.1.0"
__author__ = "Tulir Asokan <tulir@maunium.net>"
+156
View File
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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
+201
View File
@@ -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 <http://www.gnu.org/licenses/>.
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<str>): 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
+2 -1
View File
@@ -1 +1,2 @@
from .config import Config
__version__ = "0.1.0"
__author__ = "Tulir Asokan <tulir@maunium.net>"
+45 -1
View File
@@ -15,7 +15,27 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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()
+2
View File
@@ -0,0 +1,2 @@
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
+114
View File
@@ -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 <http://www.gnu.org/licenses/>.
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)
+53
View File
@@ -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 <http://www.gnu.org/licenses/>.
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()
+110
View File
@@ -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 <http://www.gnu.org/licenses/>.
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)
+163
View File
@@ -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 <http://www.gnu.org/licenses/>.
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")
+94
View File
@@ -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 <http://www.gnu.org/licenses/>.
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")
+146
View File
@@ -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 <http://www.gnu.org/licenses/>.
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()
+7 -17
View File
@@ -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