Implement Telegram -> Matrix formatted message bridging

This commit is contained in:
Tulir Asokan
2018-01-21 15:15:13 +02:00
parent 130a428641
commit f1d8312806
8 changed files with 161 additions and 23 deletions
+3 -3
View File
@@ -73,9 +73,9 @@ does not do this automatically.
* [ ] Room invites
* Telegram → Matrix
* [x] Plaintext messages
* [ ] Formatted messages
* [ ] Bot commands (/command -> !command)
* [ ] Mentions
* [x] Formatted messages
* [x] Bot commands (/command -> !command)
* [x] Mentions
* [ ] Images
* [ ] Locations
* [ ] Stickers
+2 -2
View File
@@ -142,13 +142,13 @@ class IntentAPI:
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, formatted_text=None, notice=False):
def send_text(self, room_id, text, html=None, notice=False):
if html:
return self.send_message(room_id, {
"body": text,
"msgtype": "m.notice" if notice else "m.text",
"format": "org.matrix.custom.html",
"formatted_body": formatted_text or text,
"formatted_body": html or text,
})
else:
return self.send_message(room_id, {
+4 -3
View File
@@ -69,8 +69,7 @@ class CommandHandler:
html = markdown.markdown(message, safe_mode="escape" if allow_html else False)
elif allow_html:
html = message
self.az.intent.send_text(self._room_id, message, formatted_text=html,
html=True if html else False, notice=True)
self.az.intent.send_text(self._room_id, message, html=html, notice=True)
@command_handler
def register(self, sender, args):
@@ -103,6 +102,7 @@ class CommandHandler:
try:
user = sender.client.sign_in(code=args[0])
sender.update_info(user)
sender.command_status = None
return self.reply(f"Successfully logged in as @{user.username}")
except PhoneNumberUnoccupiedError:
@@ -143,6 +143,7 @@ class CommandHandler:
try:
user = sender.client.sign_in(password=args[0])
sender.update_info(user)
sender.command_status = None
return self.reply(f"Successfully logged in as @{user.username}")
except PasswordHashInvalidError:
@@ -156,7 +157,7 @@ class CommandHandler:
def logout(self, sender, args):
if not sender.logged_in:
return self.reply("You're not logged in.")
if sender.client.log_out():
if sender.log_out():
return self.reply("Logged out successfully.")
return self.reply("Failed to log out.")
+2 -4
View File
@@ -33,10 +33,7 @@ class User(Base):
mxid = Column(String, primary_key=True)
tgid = Column(Integer, nullable=True)
def __init__(self, mxid, tgid=None):
self.mxid = mxid
self.tgid = tgid
tg_username = Column(String, nullable=True)
class Puppet(Base):
@@ -44,6 +41,7 @@ class Puppet(Base):
id = Column(Integer, primary_key=True)
displayname = Column(String, nullable=True)
username = Column(String, nullable=True)
def init(db_factory):
+88
View File
@@ -0,0 +1,88 @@
# 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 html import escape, unescape
from telethon.tl.types import *
from . import user as u, puppet as p
def telegram_to_matrix(text, entities):
if not entities:
return text
html = []
last_offset = 0
for entity in entities:
if entity.offset > last_offset:
html.append(escape(text[last_offset:entity.offset]))
elif entity.offset < last_offset:
continue
skip_entity = False
entity_text = escape(text[entity.offset:entity.offset + entity.length])
entity_type = type(entity)
if entity_type == MessageEntityBold:
html.append(f"<strong>{entity_text}</strong>")
elif entity_type == MessageEntityItalic:
html.append(f"<em>{entity_text}</em>")
elif entity_type == MessageEntityCode:
html.append(f"<code>{entity_text}</code>")
elif entity_type == MessageEntityPre:
if entity.language:
html.append("<pre>"
f"<code class='language-{entity.language}'>{entity_text}</code>"
"</pre>")
else:
html.append(f"<pre><code>{entity_text}</code></pre>")
elif entity_type == MessageEntityMention:
username = entity_text[1:]
user = u.User.find_by_username(username)
if user:
mxid = user.mxid
else:
puppet = p.Puppet.find_by_username(username)
mxid = puppet.mxid if puppet else None
if mxid:
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
else:
skip_entity = True
elif entity_type == MessageEntityMentionName:
user = u.User.get_by_tgid(entity.user_id)
if user:
mxid = user.mxid
else:
puppet = p.Puppet.get(entity.user_id, create=False)
mxid = puppet.mxid if puppet else None
if mxid:
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
else:
skip_entity = True
elif entity_type == MessageEntityEmail:
html.append(f"<a href='mailto:{entity_text}'>{entity_text}</a>")
elif entity_type == MessageEntityUrl:
html.append(f"<a href='{entity_text}'>{entity_text}</a>")
elif entity_type == MessageEntityTextUrl:
html.append(f"<a href='{escape(entity.url)}'>{entity_text}</a>")
elif entity_type == MessageEntityBotCommand:
html.append(f"<font color='blue'>!{entity_text[1:]}")
elif entity_type == MessageEntityHashtag:
html.append(f"<font color='blue'>{entity_text}</font>")
else:
skip_entity = True
last_offset = entity.offset + (0 if skip_entity else entity.length)
html.append(text[last_offset:])
return "".join(html)
+9 -5
View File
@@ -17,7 +17,7 @@ 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
from . import puppet as p, formatter
config = None
@@ -70,7 +70,6 @@ class Portal:
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)
@@ -82,9 +81,14 @@ class Portal:
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)
def handle_telegram_message(self, sender, evt):
self.log.debug("Sending %s to %s by %d", evt.message, self.mxid, sender.id)
if evt.message:
if evt.entities:
html = formatter.telegram_to_matrix(evt.message, evt.entities)
sender.intent.send_text(self.mxid, evt.message, html=html)
else:
sender.intent.send_text(self.mxid, evt.message)
@property
def peer(self):
+20 -3
View File
@@ -24,23 +24,25 @@ config = None
class Puppet:
cache = {}
def __init__(self, id=None, displayname=None):
def __init__(self, id=None, username=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.username = username
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))
return self.db.merge(
DBPuppet(id=self.id, username=self.username, displayname=self.displayname))
@classmethod
def from_db(cls, db_puppet):
return Puppet(db_puppet.id, db_puppet.displayname)
return Puppet(db_puppet.id, db_puppet.username, db_puppet.displayname)
def save(self):
self.to_db()
@@ -59,6 +61,9 @@ class Puppet:
def update_info(self, info):
changed = False
if self.username != info.username:
self.username = info.username
changed = True
displayname = self.get_displayname(info)
if displayname != self.displayname:
self.intent.set_display_name(displayname)
@@ -87,6 +92,18 @@ class Puppet:
return None
@classmethod
def find_by_username(cls, username):
for _, puppet in cls.cache.items():
if puppet.username == username:
return puppet
puppet = DBPuppet.query.filter(DBPuppet.username == username).one_or_none()
if puppet:
return cls.from_db(puppet)
return None
def init(context):
global config
+33 -3
View File
@@ -27,9 +27,10 @@ class User:
by_mxid = {}
by_tgid = {}
def __init__(self, mxid, tgid=None):
def __init__(self, mxid, tgid=None, username=None):
self.mxid = mxid
self.tgid = tgid
self.username = username
self.command_status = None
self.connected = False
@@ -44,7 +45,7 @@ class User:
return self.client.is_user_authorized()
def to_db(self):
return self.db.merge(DBUser(self.mxid, self.tgid))
return self.db.merge(DBUser(mxid=self.mxid, tgid=self.tgid, tg_username=self.username))
def save(self):
self.to_db()
@@ -52,7 +53,7 @@ class User:
@classmethod
def from_db(cls, db_user):
return User(db_user.mxid, db_user.tgid)
return User(db_user.mxid, db_user.tgid, db_user.tg_username)
def start(self):
self.client = TelegramClient(self.mxid,
@@ -62,9 +63,27 @@ class User:
self.connected = self.client.connect()
if self.logged_in:
self.sync_dialogs()
self.update_info()
self.client.add_update_handler(self.update_catch)
return self
def update_info(self, info=None):
info = info or self.client.get_me()
self.username = info.username
if self.tgid != info.id:
self.tgid = info.id
self.by_tgid[self.tgid] = self
self.save()
def log_out(self):
self.connected = False
if self.tgid:
try:
del self.tgid[self.tgid]
except KeyError:
pass
return self.client.log_out()
def stop(self):
self.client.disconnect()
self.client = None
@@ -137,6 +156,17 @@ class User:
return None
@classmethod
def find_by_username(cls, username):
for _, user in cls.by_tgid.items():
if user.username == username:
return user
puppet = DBUser.query.filter(DBUser.tg_username == username).one_or_none()
if puppet:
return cls.from_db(puppet)
return None
def init(context):
global config