Add support for bot message relaying

This commit is contained in:
Tulir Asokan
2018-02-17 17:48:48 +02:00
parent 504be22b4b
commit 2f75fa1cfe
16 changed files with 359 additions and 99 deletions
+2 -2
View File
@@ -25,7 +25,7 @@
* [x] Matrix users who have logged into Telegram * [x] Matrix users who have logged into Telegram
* [x] Kicking * [x] Kicking
* [ ] Joining * [ ] Joining
* [ ] Chat name as alias * [x] Chat name as alias
* [ ] ‡ Chat invite link as alias * [ ] ‡ Chat invite link as alias
* [x] Leaving * [x] Leaving
* [x] Room metadata changes (name, topic, avatar) * [x] Room metadata changes (name, topic, avatar)
@@ -74,7 +74,7 @@
* [x] At startup * [x] At startup
* [x] When receiving invite or message * [x] When receiving invite or message
* [x] Private chat creation by inviting Matrix puppet of Telegram user to new room * [x] Private chat creation by inviting Matrix puppet of Telegram user to new room
* [ ] Option to use bot to relay messages for unauthenticated Matrix users * [x] Option to use bot to relay messages for unauthenticated Matrix users
* [ ] Option to use own Matrix account for messages sent from other Telegram clients * [ ] Option to use own Matrix account for messages sent from other Telegram clients
* [Commands](https://github.com/tulir/mautrix-telegram/wiki/Management-commands) * [Commands](https://github.com/tulir/mautrix-telegram/wiki/Management-commands)
* [x] Logging in and out (`login` + code entering) * [x] Logging in and out (`login` + code entering)
+2
View File
@@ -87,3 +87,5 @@ telegram:
# Get your own API keys at https://my.telegram.org/apps # Get your own API keys at https://my.telegram.org/apps
api_id: 12345 api_id: 12345
api_hash: tjyd5yge35lbodk1xwzw2jstp90k55qz api_hash: tjyd5yge35lbodk1xwzw2jstp90k55qz
# (Optional) Create your own bot at https://t.me/BotFather
#bot_token: 123456789:ABCD-QBPd3VrWRhg623xYh07WUWErYA9eMI
+12 -2
View File
@@ -223,11 +223,14 @@ class IntentAPI:
# region Room actions # region Room actions
async def create_room(self, alias=None, is_public=False, name=None, topic=None, async def create_room(self, alias=None, is_public=False, name=None, topic=None,
is_direct=False, invitees=None, initial_state=None): is_direct=False, invitees=None, initial_state=None,
guests_can_join=False):
await self.ensure_registered() await self.ensure_registered()
content = { content = {
"visibility": "public" if is_public else "private", "visibility": "private",
"is_direct": is_direct, "is_direct": is_direct,
"preset": "private_chat" if is_public else "public_chat",
"guests_can_join": guests_can_join,
} }
if alias: if alias:
content["room_alias_name"] = alias content["room_alias_name"] = alias
@@ -326,6 +329,13 @@ class IntentAPI:
events.remove(event_id) events.remove(event_id)
await self.set_pinned_messages(room_id, events) await self.set_pinned_messages(room_id, events)
async def set_join_rule(self, room_id, join_rule):
if join_rule not in ("public", "knock", "invite", "private"):
raise ValueError(f"Invalid join rule \"{join_rule}\"")
await self.send_state_event(room_id, "m.room.join_rules", {
"join_rule": join_rule,
})
async def get_event(self, room_id, event_id): async def get_event(self, room_id, event_id):
await self.ensure_joined(room_id) await self.ensure_joined(room_id)
return await self.client.request("GET", f"/rooms/{room_id}/event/{event_id}") return await self.client.request("GET", f"/rooms/{room_id}/event/{event_id}")
+14 -3
View File
@@ -29,9 +29,12 @@ from .config import Config
from .matrix import MatrixHandler from .matrix import MatrixHandler
from .db import init as init_db from .db import init as init_db
from .abstract_user import init as init_abstract_user
from .user import init as init_user, User from .user import init as init_user, User
from .bot import init as init_bot
from .portal import init as init_portal from .portal import init as init_portal
from .puppet import init as init_puppet from .puppet import init as init_puppet
from .context import Context
log = logging.getLogger("mau") log = logging.getLogger("mau")
time_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s@%(name)s] %(message)s") time_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s@%(name)s] %(message)s")
@@ -76,14 +79,22 @@ loop = asyncio.get_event_loop()
appserv = AppService(config["homeserver.address"], config["homeserver.domain"], appserv = AppService(config["homeserver.address"], config["homeserver.domain"],
config["appservice.as_token"], config["appservice.hs_token"], config["appservice.as_token"], config["appservice.hs_token"],
config["appservice.bot_username"], log="mau.as", loop=loop) config["appservice.bot_username"], log="mau.as", loop=loop)
context = (appserv, db_session, config, loop)
context = Context(appserv, db_session, config, loop, None, None)
with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start: with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start:
MatrixHandler(context)
init_db(db_session) init_db(db_session)
init_abstract_user(context)
context.bot = init_bot(context)
context.mx = MatrixHandler(context)
init_portal(context) init_portal(context)
init_puppet(context) init_puppet(context)
startup_actions = init_user(context) + [start] startup_actions = init_user(context) + [start, context.mx.init_as_bot()]
if context.bot:
startup_actions.append(context.bot.start())
try: try:
loop.run_until_complete(asyncio.gather(*startup_actions, loop=loop)) loop.run_until_complete(asyncio.gather(*startup_actions, loop=loop))
loop.run_forever() loop.run_forever()
+101
View File
@@ -0,0 +1,101 @@
# -*- coding: future_fstrings -*-
# 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 platform
import os
from .tgclient import MautrixTelegramClient
from . import __version__
from telethon.tl.types import *
config = None
class AbstractUser:
loop = None
log = None
db = None
az = None
def __init__(self):
self.connected = False
self.whitelisted = False
self.client = None
self.tgid = None
def _init_client(self):
self.log.debug(f"Initializing client for {self.name}")
device = f"{platform.system()} {platform.release()}"
sysversion = MautrixTelegramClient.__version__
self.client = MautrixTelegramClient(self.name,
config["telegram.api_id"],
config["telegram.api_hash"],
loop=self.loop,
app_version=__version__,
system_version=sysversion,
device_model=device)
self.client.add_update_handler(self._update_catch)
async def update(self, update):
raise NotImplementedError()
async def post_login(self):
raise NotImplementedError()
async def _update_catch(self, update):
try:
await self.update(update)
except Exception:
self.log.exception("Failed to handle Telegram update")
async def _get_dialogs(self, limit=None):
dialogs = await self.client.get_dialogs(limit=limit)
return [dialog.entity for dialog in dialogs if (
not isinstance(dialog.entity, (User, ChatForbidden, ChannelForbidden))
and not (isinstance(dialog.entity, Chat)
and (dialog.entity.deactivated or dialog.entity.left)))]
@property
def name(self):
raise NotImplementedError()
@property
def logged_in(self):
return self.client and self.client.is_user_authorized()
@property
def has_full_access(self):
return self.logged_in and self.whitelisted
async def start(self):
self.connected = await self.client.connect()
async def ensure_started(self, even_if_no_session=False):
if not self.whitelisted:
return self
elif not self.connected and (even_if_no_session or os.path.exists(f"{self.name}.session")):
return await self.start()
return self
def stop(self):
self.client.disconnect()
self.client = None
self.connected = False
def init(context):
global config
AbstractUser.az, AbstractUser.db, config, AbstractUser.loop, _ = context
+81
View File
@@ -0,0 +1,81 @@
# -*- coding: future_fstrings -*-
# 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 logging
from telethon.tl.types import *
from .abstract_user import AbstractUser
from .db import BotChat
config = None
class Bot(AbstractUser):
log = logging.getLogger("mau.bot")
def __init__(self, token):
super().__init__()
self.token = token
self.whitelisted = True
self._init_client()
self.chats = {chat.id for chat in BotChat.query.all()}
async def start(self):
await super().start()
if not self.logged_in:
await self.client.sign_in(bot_token=self.token)
await self.post_login()
return self
async def post_login(self):
info = await self.client.get_me()
self.tgid = info.id
async def update(self, update):
if not isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
return
elif not isinstance(update.message, MessageService):
return
action = update.message.action
to_id = update.message.to_id
to_id = to_id.chat_id if isinstance(to_id, PeerChat) else to_id.channel_id
if isinstance(action, MessageActionChatAddUser):
if self.tgid in action.users:
self.chats.add(to_id)
self.db.add(BotChat(id=to_id))
self.db.commit()
elif isinstance(action, MessageActionChatDeleteUser):
if action.user_id == self.tgid:
self.chats.remove(to_id)
BotChat.query.get(to_id).delete()
self.db.commit()
def is_in_chat(self, peer_id):
return peer_id in self.chats
@property
def name(self):
return "bot"
def init(context):
global config
config = context.config
token = config["telegram.bot_token"]
if token:
return Bot(token)
return None
+3
View File
@@ -44,6 +44,7 @@ async def login(evt):
elif len(evt.args) == 0: elif len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp login <phone number>`") return await evt.reply("**Usage:** `$cmdprefix+sp login <phone number>`")
phone_number = evt.args[0] phone_number = evt.args[0]
await evt.sender.ensure_started(even_if_no_session=True)
await evt.sender.client.sign_in(phone_number) await evt.sender.client.sign_in(phone_number)
evt.sender.command_status = { evt.sender.command_status = {
"next": enter_code, "next": enter_code,
@@ -58,6 +59,7 @@ async def enter_code(evt):
return await evt.reply("**Usage:** `$cmdprefix+sp enter-code <code>`") return await evt.reply("**Usage:** `$cmdprefix+sp enter-code <code>`")
try: try:
await evt.sender.ensure_started(even_if_no_session=True)
user = await evt.sender.client.sign_in(code=evt.args[0]) user = await evt.sender.client.sign_in(code=evt.args[0])
asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop) asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop)
evt.sender.command_status = None evt.sender.command_status = None
@@ -98,6 +100,7 @@ async def enter_password(evt):
return await evt.reply("**Usage:** `$cmdprefix+sp enter-password <password>`") return await evt.reply("**Usage:** `$cmdprefix+sp enter-password <password>`")
try: try:
await evt.sender.ensure_started(even_if_no_session=True)
user = await evt.sender.client.sign_in(password=evt.args[0]) user = await evt.sender.client.sign_in(password=evt.args[0])
asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop) asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop)
evt.sender.command_status = None evt.sender.command_status = None
+1 -1
View File
@@ -52,7 +52,7 @@ async def _find_rooms(intent):
return management_rooms, unidentified_rooms, portals, empty_portals return management_rooms, unidentified_rooms, portals, empty_portals
@command_handler(needs_admin=True, name="clean-rooms") @command_handler(needs_admin=True, needs_auth=False, name="clean-rooms")
async def clean_rooms(evt): async def clean_rooms(evt):
if not evt.is_management: if not evt.is_management:
return await evt.reply("`clean-rooms` is a particularly spammy command. Please don't " return await evt.reply("`clean-rooms` is a particularly spammy command. Please don't "
+1 -1
View File
@@ -87,7 +87,7 @@ class CommandHandler:
log = logging.getLogger("mau.commands") log = logging.getLogger("mau.commands")
def __init__(self, context): def __init__(self, context):
self.az, self.db, self.config, self.loop = context self.az, self.db, self.config, self.loop, _ = context
self.command_prefix = self.config["bridge.command_prefix"] self.command_prefix = self.config["bridge.command_prefix"]
# region Utility functions for handling commands # region Utility functions for handling commands
+34
View File
@@ -0,0 +1,34 @@
# -*- coding: future_fstrings -*-
# 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/>.
class Context:
def __init__(self, az, db, config, loop, bot, mx):
self.az = az
self.db = db
self.config = config
self.loop = loop
self.bot = bot
self.mx = mx
def __iter__(self):
yield self.az
yield self.db
yield self.config
yield self.loop
yield self.bot
# yield self.mx
+8
View File
@@ -95,9 +95,17 @@ class Puppet(Base):
photo_id = Column(String, nullable=True) photo_id = Column(String, nullable=True)
# Fucking Telegram not telling bots what chats they are in 3:<
class BotChat(Base):
query = None
__tablename__ = "bot_chat"
id = Column(Integer, primary_key=True)
def init(db_session): def init(db_session):
Portal.query = db_session.query_property() Portal.query = db_session.query_property()
Message.query = db_session.query_property() Message.query = db_session.query_property()
UserPortal.query = db_session.query_property() UserPortal.query = db_session.query_property()
User.query = db_session.query_property() User.query = db_session.query_property()
Puppet.query = db_session.query_property() Puppet.query = db_session.query_property()
BotChat.query = db_session.query_property()
+3 -2
View File
@@ -189,8 +189,9 @@ def matrix_reply_to_telegram(content, tg_space, room_id=None):
reply = content["m.relates_to"]["m.in_reply_to"] reply = content["m.relates_to"]["m.in_reply_to"]
room_id = room_id or reply["room_id"] room_id = room_id or reply["room_id"]
event_id = reply["event_id"] event_id = reply["event_id"]
message = DBMessage.query.filter(DBMessage.mxid == event_id and print(event_id, tg_space, room_id)
DBMessage.tg_space == tg_space and message = DBMessage.query.filter(DBMessage.mxid == event_id,
DBMessage.tg_space == tg_space,
DBMessage.mx_room == room_id).one_or_none() DBMessage.mx_room == room_id).one_or_none()
if message: if message:
return message.tgid return message.tgid
+26 -14
View File
@@ -28,13 +28,13 @@ class MatrixHandler:
log = logging.getLogger("mau.mx") log = logging.getLogger("mau.mx")
def __init__(self, context): def __init__(self, context):
self.az, self.db, self.config, _ = context self.az, self.db, self.config, _, self.tgbot = context
self.commands = CommandHandler(context) self.commands = CommandHandler(context)
self.az.matrix_event_handler(self.handle_event) self.az.matrix_event_handler(self.handle_event)
async def init_as_bot(self): async def init_as_bot(self):
self.az.intent.set_display_name( await self.az.intent.set_display_name(
self.config.get("appservice.bot_displayname", "Telegram bridge bot")) self.config.get("appservice.bot_displayname", "Telegram bridge bot"))
async def handle_puppet_invite(self, room, puppet, inviter): async def handle_puppet_invite(self, room, puppet, inviter):
@@ -88,7 +88,7 @@ class MatrixHandler:
"Telegram chat is created for this room.") "Telegram chat is created for this room.")
async def handle_invite(self, room, user, inviter): async def handle_invite(self, room, user, inviter):
inviter = User.get_by_mxid(inviter) inviter = await User.get_by_mxid(inviter).ensure_started()
if not inviter.whitelisted: if not inviter.whitelisted:
return return
elif user == self.az.bot_mxid: elif user == self.az.bot_mxid:
@@ -101,6 +101,9 @@ class MatrixHandler:
return return
user = User.get_by_mxid(user, create=False) user = User.get_by_mxid(user, create=False)
if not user:
return
await user.ensure_started()
portal = Portal.get_by_mxid(room) portal = Portal.get_by_mxid(room)
if user and user.has_full_access and portal: if user and user.has_full_access and portal:
await portal.invite_telegram(inviter, user) await portal.invite_telegram(inviter, user)
@@ -110,7 +113,7 @@ class MatrixHandler:
self.log.debug(f"{inviter} invited {user} to {room}") self.log.debug(f"{inviter} invited {user} to {room}")
async def handle_join(self, room, user): async def handle_join(self, room, user):
user = User.get_by_mxid(user) user = await User.get_by_mxid(user).ensure_started()
portal = Portal.get_by_mxid(room) portal = Portal.get_by_mxid(room)
if not portal: if not portal:
@@ -120,19 +123,23 @@ class MatrixHandler:
await portal.main_intent.kick(room, user.mxid, await portal.main_intent.kick(room, user.mxid,
"You are not whitelisted on this Telegram bridge.") "You are not whitelisted on this Telegram bridge.")
return return
elif not user.logged_in: elif not user.logged_in and not portal.has_bot:
# TODO[waiting-for-bots] once we have bot support, this won't be needed.
await portal.main_intent.kick(room, user.mxid, await portal.main_intent.kick(room, user.mxid,
"You are not logged into this Telegram bridge.") "This chat does not have a bot relaying "
"messages for unauthenticated users.")
return return
self.log.debug(f"{user} joined {room}") self.log.debug(f"{user} joined {room}")
# TODO join Telegram chat if applicable if user.logged_in:
await portal.join_matrix(user)
async def handle_part(self, room, user, sender): async def handle_part(self, room, user, sender):
self.log.debug(f"{user} left {room}") self.log.debug(f"{user} left {room}")
sender = User.get_by_mxid(sender, create=False) sender = User.get_by_mxid(sender, create=False)
if not sender:
return
await sender.ensure_started()
portal = Portal.get_by_mxid(room) portal = Portal.get_by_mxid(room)
if not portal: if not portal:
@@ -143,7 +150,10 @@ class MatrixHandler:
await portal.leave_matrix(puppet, sender) await portal.leave_matrix(puppet, sender)
user = User.get_by_mxid(user, create=False) user = User.get_by_mxid(user, create=False)
if user and user.logged_in: if not user:
return
await user.ensure_started()
if user.logged_in:
await portal.leave_matrix(user, sender) await portal.leave_matrix(user, sender)
def is_command(self, message): def is_command(self, message):
@@ -158,10 +168,12 @@ class MatrixHandler:
self.log.debug(f"{sender} sent {message} to ${room}") self.log.debug(f"{sender} sent {message} to ${room}")
is_command, text = self.is_command(message) is_command, text = self.is_command(message)
sender = User.get_by_mxid(sender) sender = await User.get_by_mxid(sender).ensure_started()
if not sender.whitelisted:
return
portal = Portal.get_by_mxid(room) portal = Portal.get_by_mxid(room)
if sender.has_full_access and portal and not is_command: if not is_command and portal and (sender.logged_in or portal.has_bot):
await portal.handle_matrix_message(sender, message, event_id) await portal.handle_matrix_message(sender, message, event_id)
return return
@@ -187,19 +199,19 @@ class MatrixHandler:
async def handle_redaction(self, room, sender, event_id): async def handle_redaction(self, room, sender, event_id):
portal = Portal.get_by_mxid(room) portal = Portal.get_by_mxid(room)
sender = User.get_by_mxid(sender) sender = await User.get_by_mxid(sender).ensure_started()
if sender.has_full_access and portal: if sender.has_full_access and portal:
await portal.handle_matrix_deletion(sender, event_id) await portal.handle_matrix_deletion(sender, event_id)
async def handle_power_levels(self, room, sender, new, old): async def handle_power_levels(self, room, sender, new, old):
portal = Portal.get_by_mxid(room) portal = Portal.get_by_mxid(room)
sender = User.get_by_mxid(sender) sender = await User.get_by_mxid(sender).ensure_started()
if sender.has_full_access and portal: if sender.has_full_access and portal:
await portal.handle_matrix_power_levels(sender, new["users"], old["users"]) await portal.handle_matrix_power_levels(sender, new["users"], old["users"])
async def handle_room_meta(self, type, room, sender, content): async def handle_room_meta(self, type, room, sender, content):
portal = Portal.get_by_mxid(room) portal = Portal.get_by_mxid(room)
sender = User.get_by_mxid(sender) sender = await User.get_by_mxid(sender).ensure_started()
if sender.has_full_access and portal: if sender.has_full_access and portal:
handler, content_key = { handler, content_key = {
"m.room.name": (portal.handle_matrix_title, "name"), "m.room.name": (portal.handle_matrix_title, "name"),
+47 -21
View File
@@ -44,6 +44,7 @@ class Portal:
log = logging.getLogger("mau.portal") log = logging.getLogger("mau.portal")
db = None db = None
az = None az = None
bot = None
by_mxid = {} by_mxid = {}
by_tgid = {} by_tgid = {}
@@ -88,6 +89,10 @@ class Portal:
elif self.peer_type == "channel": elif self.peer_type == "channel":
return PeerChannel(channel_id=self.tgid) return PeerChannel(channel_id=self.tgid)
@property
def has_bot(self):
return self.bot and self.bot.is_in_chat(self.tgid)
def _hash_event(self, event): def _hash_event(self, event):
if self.peer_type == "channel": if self.peer_type == "channel":
# Message IDs are unique per-channel # Message IDs are unique per-channel
@@ -196,8 +201,7 @@ class Portal:
self._main_intent = puppet.intent if direct else self.az.intent self._main_intent = puppet.intent if direct else self.az.intent
if self.peer_type == "channel" and entity.username: if self.peer_type == "channel" and entity.username:
# TODO make public once safe public = True
public = False
alias = self._get_room_alias(entity.username) alias = self._get_room_alias(entity.username)
self.username = entity.username self.username = entity.username
else: else:
@@ -206,7 +210,7 @@ class Portal:
alias = None alias = None
if alias: if alias:
# TODO properly handle existing room aliases # TODO? properly handle existing room aliases
await self.main_intent.remove_room_alias(alias) await self.main_intent.remove_room_alias(alias)
power_levels = self._get_base_power_levels({}, entity) power_levels = self._get_base_power_levels({}, entity)
@@ -319,6 +323,9 @@ class Portal:
self.username = username or None self.username = username or None
if self.username: if self.username:
await self.main_intent.add_room_alias(self.mxid, self._get_room_alias()) await self.main_intent.add_room_alias(self.mxid, self._get_room_alias())
await self.main_intent.set_join_rule(self.mxid, "public")
else:
await self.main_intent.set_join_rule(self.mxid, "invite")
return True return True
return False return False
@@ -396,7 +403,7 @@ class Portal:
for member in members: for member in members:
if p.Puppet.get_id_from_mxid(member) or member == self.main_intent.mxid: if p.Puppet.get_id_from_mxid(member) or member == self.main_intent.mxid:
continue continue
user = u.User.get_by_mxid(member) user = await u.User.get_by_mxid(member).ensure_started()
if user.has_full_access: if user.has_full_access:
authenticated.append(user) authenticated.append(user)
return authenticated return authenticated
@@ -455,22 +462,42 @@ class Portal:
channel = await self.get_input_entity(user) channel = await self.get_input_entity(user)
await user.client(LeaveChannelRequest(channel=channel)) await user.client(LeaveChannelRequest(channel=channel))
async def join_matrix(self, user):
if self.peer_type == "channel":
await user.client(JoinChannelRequest(channel=await self.get_input_entity(user)))
else:
# We'll just assume the user is already in the chat.
pass
async def handle_matrix_message(self, sender, message, event_id): async def handle_matrix_message(self, sender, message, event_id):
type = message["msgtype"] type = message["msgtype"]
space = self.tgid if self.peer_type == "channel" else sender.tgid if sender.logged_in:
client = sender.client
space = self.tgid if self.peer_type == "channel" else sender.tgid
else:
client = self.bot.client
space = self.tgid if self.peer_type == "channel" else self.bot.tgid
reply_to = formatter.matrix_reply_to_telegram(message, space, room_id=self.mxid) reply_to = formatter.matrix_reply_to_telegram(message, space, room_id=self.mxid)
if type in {"m.text", "m.emote"}:
if type == "m.emote":
if "formatted_body" in message:
message["formatted_body"] = f"* {sender.displayname} {message['formatted_body']}"
message["body"] = f"* {sender.displayname} {message['body']}"
type = "m.text"
elif not sender.logged_in:
if "formatted_body" in message:
message["formatted_body"] = \
f"&lt;{sender.displayname}&gt; {message['formatted_body']}"
message["body"] = f"<{sender.displayname}> {message['body']}"
if type == "m.text":
if "format" in message and message["format"] == "org.matrix.custom.html": if "format" in message and message["format"] == "org.matrix.custom.html":
message, entities = formatter.matrix_to_telegram(message["formatted_body"], space) message, entities = formatter.matrix_to_telegram(message["formatted_body"])
if type == "m.emote": response = await client.send_message(self.peer, message, entities=entities,
message = "/me " + message reply_to=reply_to)
response = await sender.client.send_message(self.peer, message, entities=entities,
reply_to=reply_to)
else: else:
if type == "m.emote": response = await client.send_message(self.peer, message["body"],
message["body"] = "/me " + message["body"] reply_to=reply_to)
response = await sender.client.send_message(self.peer, message["body"],
reply_to=reply_to)
elif type in {"m.image", "m.file", "m.audio", "m.video"}: elif type in {"m.image", "m.file", "m.audio", "m.video"}:
file = await self.main_intent.download_file(message["url"]) file = await self.main_intent.download_file(message["url"])
@@ -483,8 +510,8 @@ class Portal:
if "w" in info and "h" in info: if "w" in info and "h" in info:
attributes.append(DocumentAttributeImageSize(w=info["w"], h=info["h"])) attributes.append(DocumentAttributeImageSize(w=info["w"], h=info["h"]))
response = await sender.client.send_file(self.peer, file, mime, caption, attributes, response = await client.send_file(self.peer, file, mime, caption, attributes,
file_name, reply_to=reply_to) file_name, reply_to=reply_to)
else: else:
self.log.debug("Unhandled Matrix event: %s", message) self.log.debug("Unhandled Matrix event: %s", message)
return return
@@ -498,8 +525,8 @@ class Portal:
async def handle_matrix_deletion(self, deleter, event_id): async def handle_matrix_deletion(self, deleter, event_id):
space = self.tgid if self.peer_type == "channel" else deleter.tgid space = self.tgid if self.peer_type == "channel" else deleter.tgid
message = DBMessage.query.filter(DBMessage.mxid == event_id and message = DBMessage.query.filter(DBMessage.mxid == event_id,
DBMessage.tg_space == space and DBMessage.tg_space == space,
DBMessage.mx_room == self.mxid).one_or_none() DBMessage.mx_room == self.mxid).one_or_none()
if not message: if not message:
return return
@@ -627,7 +654,6 @@ class Portal:
invites = await self._get_telegram_users_in_matrix_room() invites = await self._get_telegram_users_in_matrix_room()
if len(invites) < 2: if len(invites) < 2:
# TODO[waiting-for-bots] This won't happen when the bot is enabled
raise ValueError("Not enough Telegram users to create a chat") raise ValueError("Not enough Telegram users to create a chat")
if self.peer_type == "chat": if self.peer_type == "chat":
@@ -1065,4 +1091,4 @@ class Portal:
def init(context): def init(context):
global config global config
Portal.az, Portal.db, config, _ = context Portal.az, Portal.db, config, _, Portal.bot = context
+1 -1
View File
@@ -176,7 +176,7 @@ class Puppet:
def init(context): def init(context):
global config global config
Puppet.az, Puppet.db, config, _ = context Puppet.az, Puppet.db, config, _, _ = context
localpart = config.get("bridge.username_template", "telegram_{userid}").format(userid="(.+)") localpart = config.get("bridge.username_template", "telegram_{userid}").format(userid="(.+)")
hs = config["homeserver"]["domain"] hs = config["homeserver"]["domain"]
Puppet.mxid_regex = re.compile(f"@{localpart}:{hs}") Puppet.mxid_regex = re.compile(f"@{localpart}:{hs}")
+23 -52
View File
@@ -16,31 +16,28 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging import logging
import asyncio import asyncio
import platform import re
from telethon.tl.types import * from telethon.tl.types import *
from telethon.tl.types.contacts import ContactsNotModified from telethon.tl.types.contacts import ContactsNotModified
from telethon.tl.types import User as TLUser
from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest
from mautrix_appservice import MatrixRequestError from mautrix_appservice import MatrixRequestError
from .db import User as DBUser, Message as DBMessage, Contact as DBContact from .db import User as DBUser, Message as DBMessage, Contact as DBContact
from .tgclient import MautrixTelegramClient from .abstract_user import AbstractUser
from . import portal as po, puppet as pu, __version__ from . import portal as po, puppet as pu
config = None config = None
class User: class User(AbstractUser):
loop = None
log = logging.getLogger("mau.user") log = logging.getLogger("mau.user")
db = None
az = None
by_mxid = {} by_mxid = {}
by_tgid = {} by_tgid = {}
def __init__(self, mxid, tgid=None, username=None, db_contacts=None, saved_contacts=0, def __init__(self, mxid, tgid=None, username=None, db_contacts=None, saved_contacts=0,
db_portals=None): db_portals=None):
super().__init__()
self.mxid = mxid self.mxid = mxid
self.tgid = tgid self.tgid = tgid
self.username = username self.username = username
@@ -51,9 +48,6 @@ class User:
self.db_portals = db_portals self.db_portals = db_portals
self.command_status = None self.command_status = None
self.connected = False
self.client = None
self._init_client()
self.is_admin = self.mxid in config.get("bridge.admins", []) self.is_admin = self.mxid in config.get("bridge.admins", [])
@@ -67,13 +61,17 @@ class User:
if tgid: if tgid:
self.by_tgid[tgid] = self self.by_tgid[tgid] = self
@property self._init_client()
def logged_in(self):
return self.client.is_user_authorized()
@property @property
def has_full_access(self): def name(self):
return self.logged_in and self.whitelisted return self.mxid
@property
def displayname(self):
# TODO show better username
match = re.compile("@(.+):(.+)").match(self.mxid)
return match.group(1)
@property @property
def db_contacts(self): def db_contacts(self):
@@ -129,23 +127,13 @@ class User:
# endregion # endregion
# region Telegram connection management # region Telegram connection management
def _init_client(self):
device = f"{platform.system()} {platform.release()}"
sysversion = MautrixTelegramClient.__version__
self.client = MautrixTelegramClient(self.mxid,
config["telegram.api_id"],
config["telegram.api_hash"],
loop=self.loop,
app_version=__version__,
system_version=sysversion,
device_model=device)
self.client.add_update_handler(self.update_catch)
async def start(self, delete_unless_authenticated=False): async def start(self, delete_unless_authenticated=False):
self.connected = await self.client.connect() await super().start()
if self.logged_in: if self.logged_in:
self.log.debug(f"Ensuring post_login() for {self.name}")
asyncio.ensure_future(self.post_login(), loop=self.loop) asyncio.ensure_future(self.post_login(), loop=self.loop)
elif delete_unless_authenticated: elif delete_unless_authenticated:
self.log.debug(f"Unauthenticated user {self.name} start()ed, deleting...")
# User not logged in -> forget user # User not logged in -> forget user
self.client.disconnect() self.client.disconnect()
self.client.session.delete() self.client.session.delete()
@@ -160,11 +148,6 @@ class User:
except Exception: except Exception:
self.log.exception("Failed to run post-login functions") self.log.exception("Failed to run post-login functions")
def stop(self):
self.client.disconnect()
self.client = None
self.connected = False
# endregion # endregion
# region Telegram actions that need custom methods # region Telegram actions that need custom methods
@@ -234,14 +217,8 @@ class User:
return await self._search_remote(query), True return await self._search_remote(query), True
async def sync_dialogs(self): async def sync_dialogs(self):
dialogs = await self.client.get_dialogs(limit=30)
creators = [] creators = []
for dialog in dialogs: for entity in await self._get_dialogs(limit=30):
entity = dialog.entity
invalid = (isinstance(entity, (TLUser, ChatForbidden, ChannelForbidden))
or (isinstance(entity, Chat) and (entity.deactivated or entity.left)))
if invalid:
continue
portal = po.Portal.get_by_entity(entity) portal = po.Portal.get_by_entity(entity)
self.portals[portal.tgid_full] = portal self.portals[portal.tgid_full] = portal
creators.append(portal.create_matrix_room(self, entity, invites=[self.mxid])) creators.append(portal.create_matrix_room(self, entity, invites=[self.mxid]))
@@ -286,12 +263,6 @@ class User:
# endregion # endregion
# region Telegram update handling # region Telegram update handling
async def update_catch(self, update):
try:
await self.update(update)
except Exception:
self.log.exception("Failed to handle Telegram update")
async def update(self, update): async def update(self, update):
if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage, if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage)): UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage)):
@@ -340,7 +311,7 @@ class User:
await portal.set_telegram_admins_enabled(update.enabled) await portal.set_telegram_admins_enabled(update.enabled)
elif isinstance(update, UpdateChatParticipantAdmin): elif isinstance(update, UpdateChatParticipantAdmin):
puppet = pu.Puppet.get(update.user_id) puppet = pu.Puppet.get(update.user_id)
user = User.get_by_tgid(update.user_id) user = await User.get_by_tgid(update.user_id).ensure_started()
await portal.set_telegram_admin(puppet, user) await portal.set_telegram_admin(puppet, user)
async def update_typing(self, update): async def update_typing(self, update):
@@ -425,14 +396,14 @@ class User:
user = DBUser.query.get(mxid) user = DBUser.query.get(mxid)
if user: if user:
user = cls.from_db(user) user = cls.from_db(user)
asyncio.ensure_future(user.start(), loop=cls.loop) # asyncio.ensure_future(user.start(), loop=cls.loop)
return user return user
if create: if create:
user = cls(mxid) user = cls(mxid)
cls.db.add(user.to_db()) cls.db.add(user.to_db())
cls.db.commit() cls.db.commit()
asyncio.ensure_future(user.start(), loop=cls.loop) # asyncio.ensure_future(user.start(), loop=cls.loop)
return user return user
return None return None
@@ -447,7 +418,7 @@ class User:
user = DBUser.query.filter(DBUser.tgid == tgid).one_or_none() user = DBUser.query.filter(DBUser.tgid == tgid).one_or_none()
if user: if user:
user = cls.from_db(user) user = cls.from_db(user)
asyncio.ensure_future(user.start(), loop=cls.loop) # asyncio.ensure_future(user.start(), loop=cls.loop)
return user return user
return None return None
@@ -468,7 +439,7 @@ class User:
def init(context): def init(context):
global config global config
User.az, User.db, config, User.loop = context config = context.config
users = [User.from_db(user) for user in DBUser.query.all()] users = [User.from_db(user) for user in DBUser.query.all()]
return [user.start(delete_unless_authenticated=True) for user in users] return [user.start(delete_unless_authenticated=True) for user in users]