Implement Matrix -> Telegram formatted message bridging
This commit is contained in:
@@ -54,9 +54,9 @@ does not do this automatically.
|
||||
## Features & Roadmap
|
||||
* Matrix → Telegram
|
||||
* [x] Plaintext messages
|
||||
* [ ] Formatted messages
|
||||
* [x] Formatted messages
|
||||
* [ ] Bot commands (!command -> /command)
|
||||
* [ ] Mentions
|
||||
* [x] Mentions
|
||||
* [ ] Locations
|
||||
* [ ] Images
|
||||
* [ ] Files
|
||||
|
||||
@@ -30,6 +30,7 @@ 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
|
||||
from .formatter import init as init_formatter
|
||||
|
||||
log = logging.getLogger("mau")
|
||||
time_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s@%(name)s] %(message)s")
|
||||
@@ -75,6 +76,7 @@ context = (appserv, db, log, config)
|
||||
|
||||
with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start:
|
||||
init_db(db_factory)
|
||||
init_formatter(context)
|
||||
init_portal(context)
|
||||
init_puppet(context)
|
||||
init_user(context)
|
||||
|
||||
@@ -15,11 +15,160 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import re
|
||||
from html import escape, unescape
|
||||
from html.parser import HTMLParser
|
||||
from collections import deque
|
||||
from telethon.tl.types import *
|
||||
from . import user as u, puppet as p
|
||||
|
||||
log = None
|
||||
|
||||
|
||||
class MatrixParser(HTMLParser):
|
||||
matrix_to_regex = re.compile("https://matrix.to/#/(@.+)")
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.text = ""
|
||||
self.entities = []
|
||||
self._building_entities = {}
|
||||
self._list_counter = 0
|
||||
self._open_tags = deque()
|
||||
self._open_tags_meta = deque()
|
||||
self._previous_ended_line = True
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
self._open_tags.appendleft(tag)
|
||||
self._open_tags_meta.appendleft(0)
|
||||
attrs = dict(attrs)
|
||||
EntityType = None
|
||||
args = {}
|
||||
if tag == "strong" or tag == "b":
|
||||
EntityType = MessageEntityBold
|
||||
elif tag == "em" or tag == "i":
|
||||
EntityType = MessageEntityItalic
|
||||
elif tag == "code":
|
||||
try:
|
||||
pre = self._building_entities["pre"]
|
||||
try:
|
||||
pre.language = attrs["class"][len("language-"):]
|
||||
except KeyError:
|
||||
pass
|
||||
except KeyError:
|
||||
EntityType = MessageEntityCode
|
||||
elif tag == "pre":
|
||||
EntityType = MessageEntityPre
|
||||
args["language"] = ""
|
||||
elif tag == "a":
|
||||
try:
|
||||
url = attrs["href"]
|
||||
except KeyError:
|
||||
return
|
||||
mention = self.matrix_to_regex.search(url)
|
||||
if mention:
|
||||
mxid = mention.group(1)
|
||||
puppet_match = p.Puppet.mxid_regex.search(mxid)
|
||||
if puppet_match:
|
||||
user = p.Puppet.get(puppet_match.group(1), create=False)
|
||||
else:
|
||||
user = u.User.get_by_mxid(mxid, create=False)
|
||||
if not user:
|
||||
return
|
||||
if user.username:
|
||||
EntityType = MessageEntityMention
|
||||
url = f"@{user.username}"
|
||||
else:
|
||||
EntityType = MessageEntityMentionName
|
||||
args["user_id"] = user.tgid
|
||||
elif url.startswith("mailto:"):
|
||||
url = url[len("mailto:"):]
|
||||
EntityType = MessageEntityEmail
|
||||
else:
|
||||
if self.get_starttag_text() == url:
|
||||
EntityType = MessageEntityUrl
|
||||
else:
|
||||
EntityType = MessageEntityTextUrl
|
||||
args["url"] = url
|
||||
url = None
|
||||
self._open_tags_meta.popleft()
|
||||
self._open_tags_meta.appendleft(url)
|
||||
|
||||
if EntityType and tag not in self._building_entities:
|
||||
self._building_entities[tag] = EntityType(offset=len(self.text), length=0, **args)
|
||||
|
||||
def _list_depth(self):
|
||||
depth = 0
|
||||
for tag in self._open_tags:
|
||||
if tag == "ol" or tag == "ul":
|
||||
depth += 1
|
||||
return depth
|
||||
|
||||
def handle_data(self, text):
|
||||
text = unescape(text)
|
||||
previous_tag = self._open_tags[0] if len(self._open_tags) > 0 else ""
|
||||
list_format_offset = 0
|
||||
if previous_tag == "a":
|
||||
url = self._open_tags_meta[0]
|
||||
if url:
|
||||
text = url
|
||||
elif len(self._open_tags) > 1 and self._previous_ended_line and previous_tag == "li":
|
||||
list_type = self._open_tags[1]
|
||||
indent = (self._list_depth() - 1) * 4 * " "
|
||||
text = text.strip("\n")
|
||||
if len(text) == 0:
|
||||
return
|
||||
elif list_type == "ul":
|
||||
text = f"{indent}* {text}"
|
||||
list_format_offset = len(indent) + 2
|
||||
elif list_type == "ol":
|
||||
n = self._open_tags_meta[1]
|
||||
n += 1
|
||||
self._open_tags_meta[1] = n
|
||||
text = f"{indent}{n}. {text}"
|
||||
list_format_offset = len(indent) + 3
|
||||
for tag, entity in self._building_entities.items():
|
||||
entity.length += len(text.strip("\n"))
|
||||
entity.offset += list_format_offset
|
||||
|
||||
if text.endswith("\n"):
|
||||
self._previous_ended_line = True
|
||||
else:
|
||||
self._previous_ended_line = False
|
||||
|
||||
self.text += text
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
try:
|
||||
self._open_tags.popleft()
|
||||
self._open_tags_meta.popleft()
|
||||
except IndexError:
|
||||
pass
|
||||
if (tag == "ul" or tag == "ol") and self.text.endswith("\n"):
|
||||
self.text = self.text[:-1]
|
||||
entity = self._building_entities.pop(tag, None)
|
||||
if entity:
|
||||
self.entities.append(entity)
|
||||
|
||||
|
||||
def matrix_to_telegram(html):
|
||||
try:
|
||||
parser = MatrixParser()
|
||||
parser.feed(html)
|
||||
return parser.text, parser.entities
|
||||
except:
|
||||
log.exception("Failed to convert Matrix format:\nhtml=%s", html)
|
||||
|
||||
|
||||
def telegram_to_matrix(text, entities):
|
||||
try:
|
||||
return _telegram_to_matrix(text, entities)
|
||||
except:
|
||||
log.exception("Failed to convert Telegram format:\n"
|
||||
"message=%s\n"
|
||||
"entities=%s",
|
||||
text, entities)
|
||||
|
||||
|
||||
def _telegram_to_matrix(text, entities):
|
||||
if not entities:
|
||||
return text
|
||||
html = []
|
||||
@@ -86,3 +235,9 @@ def telegram_to_matrix(text, entities):
|
||||
last_offset = entity.offset + (0 if skip_entity else entity.length)
|
||||
html.append(text[last_offset:])
|
||||
return "".join(html)
|
||||
|
||||
|
||||
def init(context):
|
||||
global log
|
||||
_, _, parent_log, _ = context
|
||||
log = parent_log.getChild("formatter")
|
||||
|
||||
@@ -79,7 +79,11 @@ class Portal:
|
||||
def handle_matrix_message(self, sender, message):
|
||||
type = message["msgtype"]
|
||||
if type == "m.text":
|
||||
sender.client.send_message(self.peer, message["body"])
|
||||
if "format" in message and message["format"] == "org.matrix.custom.html":
|
||||
message, entities = formatter.matrix_to_telegram(message["formatted_body"])
|
||||
sender.send_message(self.peer, message, entities=entities)
|
||||
else:
|
||||
sender.send_message(self.peer, message["body"])
|
||||
|
||||
def handle_telegram_message(self, sender, evt):
|
||||
self.log.debug("Sending %s to %s by %d", evt.message, self.mxid, sender.id)
|
||||
|
||||
@@ -13,10 +13,8 @@
|
||||
#
|
||||
# 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
|
||||
import re
|
||||
from .db import Puppet as DBPuppet
|
||||
from . import portal as p
|
||||
|
||||
config = None
|
||||
|
||||
@@ -36,6 +34,10 @@ class Puppet:
|
||||
|
||||
self.cache[id] = self
|
||||
|
||||
@property
|
||||
def tgid(self):
|
||||
return self.id
|
||||
|
||||
def to_db(self):
|
||||
return self.db.merge(
|
||||
DBPuppet(id=self.id, username=self.username, displayname=self.displayname))
|
||||
@@ -109,3 +111,6 @@ def init(context):
|
||||
global config
|
||||
Puppet.az, Puppet.db, log, config = context
|
||||
Puppet.log = log.getChild("puppet")
|
||||
localpart = config.get("bridge.alias_template", "telegram_{}").format("(.+)")
|
||||
hs = config["homeserver"]["domain"]
|
||||
Puppet.mxid_regex = re.compile(f"@{localpart}:{hs}")
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
import traceback
|
||||
from telethon import TelegramClient
|
||||
from telethon.tl.types import User as UserEntity, Chat as ChatEntity, Channel as ChannelEntity, \
|
||||
UpdateShortMessage, UpdateShortChatMessage
|
||||
UpdateShortMessage, UpdateShortChatMessage, Message, UpdateShortSentMessage
|
||||
from telethon.tl.functions.messages import SendMessageRequest
|
||||
from .db import User as DBUser
|
||||
from . import portal as po, puppet as pu
|
||||
|
||||
@@ -89,6 +90,30 @@ class User:
|
||||
self.client = None
|
||||
self.connected = False
|
||||
|
||||
def send_message(self, entity, message, reply_to=None, entities=None, link_preview=True):
|
||||
entity = self.client.get_input_entity(entity)
|
||||
|
||||
request = SendMessageRequest(
|
||||
peer=entity,
|
||||
message=message,
|
||||
entities=entities,
|
||||
no_webpage=not link_preview,
|
||||
reply_to_msg_id=self.client._get_reply_to(reply_to)
|
||||
)
|
||||
result = self.client(request)
|
||||
if isinstance(result, UpdateShortSentMessage):
|
||||
return Message(
|
||||
id=result.id,
|
||||
to_id=entity,
|
||||
message=message,
|
||||
date=result.date,
|
||||
out=result.out,
|
||||
media=result.media,
|
||||
entities=result.entities
|
||||
)
|
||||
|
||||
return self.client._get_response_message(request, result)
|
||||
|
||||
def sync_dialogs(self):
|
||||
dialogs = self.client.get_dialogs(limit=30)
|
||||
for dialog in dialogs:
|
||||
|
||||
Reference in New Issue
Block a user