From f1d8312806467c156642b8264fe7994de0c2f369 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 21 Jan 2018 15:15:13 +0200 Subject: [PATCH] Implement Telegram -> Matrix formatted message bridging --- README.md | 6 +-- mautrix_appservice/intent_api.py | 4 +- mautrix_telegram/commands.py | 7 +-- mautrix_telegram/db.py | 6 +-- mautrix_telegram/formatter.py | 88 ++++++++++++++++++++++++++++++++ mautrix_telegram/portal.py | 14 +++-- mautrix_telegram/puppet.py | 23 +++++++-- mautrix_telegram/user.py | 36 +++++++++++-- 8 files changed, 161 insertions(+), 23 deletions(-) create mode 100644 mautrix_telegram/formatter.py diff --git a/README.md b/README.md index 7b7e43f4..0a54ff4f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/mautrix_appservice/intent_api.py b/mautrix_appservice/intent_api.py index d1e322a2..5a922bc9 100644 --- a/mautrix_appservice/intent_api.py +++ b/mautrix_appservice/intent_api.py @@ -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, { diff --git a/mautrix_telegram/commands.py b/mautrix_telegram/commands.py index d97ac954..9562d00a 100644 --- a/mautrix_telegram/commands.py +++ b/mautrix_telegram/commands.py @@ -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.") diff --git a/mautrix_telegram/db.py b/mautrix_telegram/db.py index a9353daf..d7cf1a22 100644 --- a/mautrix_telegram/db.py +++ b/mautrix_telegram/db.py @@ -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): diff --git a/mautrix_telegram/formatter.py b/mautrix_telegram/formatter.py new file mode 100644 index 00000000..c17f61fc --- /dev/null +++ b/mautrix_telegram/formatter.py @@ -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 . +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"{entity_text}") + elif entity_type == MessageEntityItalic: + html.append(f"{entity_text}") + elif entity_type == MessageEntityCode: + html.append(f"{entity_text}") + elif entity_type == MessageEntityPre: + if entity.language: + html.append("
"
+                            f"{entity_text}"
+                            "
") + else: + html.append(f"
{entity_text}
") + 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"{entity_text}") + 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"{entity_text}") + else: + skip_entity = True + elif entity_type == MessageEntityEmail: + html.append(f"{entity_text}") + elif entity_type == MessageEntityUrl: + html.append(f"{entity_text}") + elif entity_type == MessageEntityTextUrl: + html.append(f"{entity_text}") + elif entity_type == MessageEntityBotCommand: + html.append(f"!{entity_text[1:]}") + elif entity_type == MessageEntityHashtag: + html.append(f"{entity_text}") + else: + skip_entity = True + last_offset = entity.offset + (0 if skip_entity else entity.length) + html.append(text[last_offset:]) + return "".join(html) diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 6f6ee043..0b3d129b 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -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): diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index 14734956..ce7e4c77 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -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 diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index 0b7ebd31..dde58eaf 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -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