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
+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()