diff --git a/example-config.yaml b/example-config.yaml
index ee68b25d..2534728d 100644
--- a/example-config.yaml
+++ b/example-config.yaml
@@ -87,20 +87,26 @@ bridge:
# Allow logging in within Matrix. If false, the only way to log in is using the out-of-Matrix
# login website (see appservice.public config section)
allow_matrix_login: true
+ # Whether or not to allow creating portals from Telegram.
+ authless_relaybot_portals: true
# The prefix for commands. Only required in non-management rooms.
command_prefix: "!tg"
- # Whitelist of user IDs that are allowed to use this bridge. Leave empty to disable.
- # You can enter a domain without the localpart to allow all users from that homeserver to use the bridge.
- whitelist:
- - "internal.example.com"
- - "@user:public.example.com"
-
- # Admins can do things like delete portal rooms. Here you must specify the exact MXID, domains
- # are not accepted.
- admins:
- - "@admin:internal.example.com"
+ # Permissions for using the bridge.
+ # Permitted values:
+ # relaybot - Only use the bridge via the relaybot, no access to commands.
+ # full - Full access to use the bridge via relaybot or logging in with Telegram account.
+ # admin - Full access to use the bridge and some extra administration commands.
+ # Permitted keys:
+ # * - All Matrix users
+ # domain - All users on that homeserver
+ # mxid - Specific user
+ permissions:
+ "*": "relaybot"
+ "example.com": "full"
+ "public.example.com": "full"
+ "@admin:example.com": "admin"
# Telegram config
telegram:
@@ -109,3 +115,7 @@ telegram:
api_hash: tjyd5yge35lbodk1xwzw2jstp90k55qz
# (Optional) Create your own bot at https://t.me/BotFather
#bot_token: 123456789:ABCD-QBPd3VrWRhg623xYh07WUWErYA9eMI
+
+# The version of the config. The bridge will read this and automatically update the config if
+# the schema has changed. For the latest version, check the example config.
+version: 1
diff --git a/mautrix_telegram/__main__.py b/mautrix_telegram/__main__.py
index 2f01e1ac..9761db8c 100644
--- a/mautrix_telegram/__main__.py
+++ b/mautrix_telegram/__main__.py
@@ -56,6 +56,7 @@ args = parser.parse_args()
config = Config(args.config, args.registration)
config.load()
+config.check_updates()
if args.generate_registration:
config.generate_registration()
diff --git a/mautrix_telegram/abstract_user.py b/mautrix_telegram/abstract_user.py
index 7619c432..abdf917a 100644
--- a/mautrix_telegram/abstract_user.py
+++ b/mautrix_telegram/abstract_user.py
@@ -88,6 +88,8 @@ class AbstractUser:
return self.logged_in and self.whitelisted
async def start(self):
+ if not self.client:
+ self._init_client()
self.connected = await self.client.connect()
async def ensure_started(self, even_if_no_session=False):
diff --git a/mautrix_telegram/bot.py b/mautrix_telegram/bot.py
index 5e29a5b2..020c12ee 100644
--- a/mautrix_telegram/bot.py
+++ b/mautrix_telegram/bot.py
@@ -15,6 +15,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
import logging
+import re
from telethon.tl.types import *
from telethon.errors import ChannelInvalidError, ChannelPrivateError
@@ -23,18 +24,20 @@ from telethon.tl.functions.channels import GetChannelsRequest
from .abstract_user import AbstractUser
from .db import BotChat
-from . import puppet as pu
+from . import puppet as pu, portal as po, user as u
config = None
class Bot(AbstractUser):
log = logging.getLogger("mau.bot")
+ mxid_regex = re.compile("@.+:.+")
def __init__(self, token):
super().__init__()
self.token = token
self.whitelisted = True
+ self.username = None
self._init_client()
self.chats = {chat.id: chat.type for chat in BotChat.query.all()}
@@ -48,6 +51,7 @@ class Bot(AbstractUser):
async def post_login(self):
info = await self.client.get_me()
self.tgid = info.id
+ self.username = info.username
self.mxid = pu.Puppet.get_mxid_from_id(self.tgid)
chat_ids = [id for id, type in self.chats.items() if type == "chat"]
@@ -85,10 +89,60 @@ class Bot(AbstractUser):
self.db.delete(BotChat.query.get(id))
self.db.commit()
+ async def handle_command_portal(self, portal, reply):
+ if not config["bridge.authless_relaybot_portals"]:
+ return await reply("This bridge doesn't allow portal creation/invites from Telegram.")
+
+ await portal.create_matrix_room(self)
+ if portal.mxid:
+ if portal.username:
+ return await reply(
+ f"Portal is public: [{portal.alias}](https://matrix.to/#/{portal.alias})")
+ else:
+ return await reply(
+ "Portal is not public. Use `/invite ` to get an invite.")
+
+ async def handle_command_invite(self, portal, reply, mxid):
+ if len(mxid) == 0:
+ return await reply("Usage: `/invite `")
+ elif not portal.mxid:
+ return await reply("Portal does not have Matrix room. "
+ "Create one with /portal first.")
+ if not self.mxid_regex.match(mxid):
+ return await reply("That doesn't look like a Matrix ID.")
+ user = await u.User.get_by_mxid(mxid).ensure_started()
+ if not user.whitelisted:
+ return await reply("That user is not whitelisted to use the bridge.")
+ elif user.logged_in:
+ displayname = f"@{user.username}" if user.username else user.displayname
+ return await reply("That user seems to be logged in. "
+ f"Just invite [{displayname}](tg://user?id={user.tgid})")
+ else:
+ await portal.main_intent.invite(portal.mxid, user.mxid)
+ return await reply(f"Invited `{user.mxid}` to the portal.")
+
+ async def handle_command(self, message):
+ def reply(reply_text):
+ return self.client.send_message_super(message.to_id, reply_text)
+
+ text = message.message
+ portal = po.Portal.get_by_entity(message.to_id)
+ if text == "/portal":
+ await self.handle_command_portal(portal, reply)
+ elif text.startswith("/invite"):
+ await self.handle_command_invite(portal, reply, mxid=text[len("/invite "):])
+
async def update(self, update):
if not isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
return
- elif not isinstance(update.message, MessageService):
+
+ is_command = (isinstance(update.message, Message)
+ and update.message.entities and len(update.message.entities) > 0
+ and isinstance(update.message.entities[0], MessageEntityBotCommand))
+ if is_command:
+ return await self.handle_command(update.message)
+
+ if not isinstance(update.message, MessageService):
return
to_id = update.message.to_id
diff --git a/mautrix_telegram/config.py b/mautrix_telegram/config.py
index ef93094e..7bab5563 100644
--- a/mautrix_telegram/config.py
+++ b/mautrix_telegram/config.py
@@ -15,20 +15,22 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
from ruamel.yaml import YAML
+from ruamel.yaml.comments import CommentedMap
import random
import string
yaml = YAML()
+yaml.indent(4)
class DictWithRecursion:
def __init__(self, data=None):
- self._data = data or {}
+ self._data = data or CommentedMap()
def _recursive_get(self, data, key, default_value):
if '.' in key:
key, next_key = key.split('.', 1)
- next_data = data.get(key, {})
+ next_data = data.get(key, CommentedMap())
return self._recursive_get(next_data, next_key, default_value)
return data.get(key, default_value)
@@ -44,8 +46,8 @@ class DictWithRecursion:
if '.' in key:
key, next_key = key.split('.', 1)
if key not in data:
- data[key] = {}
- next_data = data.get(key, {})
+ data[key] = CommentedMap()
+ next_data = data.get(key, CommentedMap())
self._recursive_set(next_data, next_key, value)
return
data[key] = value
@@ -59,6 +61,42 @@ class DictWithRecursion:
def __setitem__(self, key, value):
self.set(key, value)
+ def _recursive_del(self, data, key):
+ if '.' in key:
+ key, next_key = key.split('.', 1)
+ if key not in data:
+ return
+ next_data = data[key]
+ self._recursive_del(next_data, next_key)
+ return
+ try:
+ del data[key]
+ except KeyError:
+ pass
+
+ def delete(self, key, allow_recursion=True):
+ if allow_recursion and '.' in key:
+ self._recursive_del(self._data, key)
+ return
+ try:
+ del self._data[key]
+ except KeyError:
+ pass
+
+ def __delitem__(self, key):
+ self.delete(key)
+
+ def comment(self, key, message):
+ indent = key.count(".") * 4
+ try:
+ path, key = key.rsplit(".", 1)
+ except ValueError:
+ path = None
+ entry = self[path] if path else self._data
+ c = self._data.ca.items.setdefault(key, [None, [], None, None])
+ c[1] = []
+ entry.yaml_set_comment_before_after_key(key=key, before=message, indent=indent)
+
class Config(DictWithRecursion):
def __init__(self, path, registration_path):
@@ -82,6 +120,68 @@ class Config(DictWithRecursion):
def _new_token():
return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(64))
+ def update_0_1(self):
+ permissions = self["bridge.permissions"] or CommentedMap()
+ for entry in self["bridge.whitelist"] or []:
+ permissions[entry] = "full"
+ for entry in self["bridge.admins"] or []:
+ permissions[entry] = "admin"
+
+ self["bridge.permissions"] = permissions
+ del self["bridge.whitelist"]
+ del self["bridge.admins"]
+
+ self["bridge.authless_relaybot_portals"] = self.get("bridge.authless_relaybot_portals",
+ True)
+ self.comment("bridge.authless_relaybot_portals",
+ "Whether or not to allow creating portals from Telegram.")
+
+ self.comment("bridge.permissions", "\n".join((
+ "",
+ "Permissions for using the bridge.",
+ "Permitted values:",
+ " relaybot - Only use the bridge via the relaybot, no access to commands.",
+ " full - Full access to use the bridge via relaybot or logging in with Telegram account.",
+ " admin - Full access to use the bridge and some extra administration commands.",
+ "Permitted keys:",
+ " * - All Matrix users",
+ " domain - All users on that homeserver",
+ " mxid - Specific user")))
+ # The telegram section comment disappears for some reason 3:
+ self.comment("telegram", "\nTelegram config")
+
+ self["version"] = 1
+ # Add newline before version
+ self.comment("version",
+ "\nThe version of the config. The bridge will read this and automatically "
+ "update the config if\nthe schema has changed. For the latest version, "
+ "check the example config.")
+
+ def check_updates(self):
+ if self.get("version", 0) == 0:
+ self.update_0_1()
+ else:
+ return
+ self.save()
+
+ def _get_permissions(self, key):
+ level = self["bridge.permissions"].get(key, "")
+ admin = level == "admin"
+ whitelisted = level == "full" or admin
+ relaybot = level == "relaybot" or whitelisted
+ return relaybot, whitelisted, admin
+
+ def get_permissions(self, mxid):
+ permissions = self["bridge.permissions"] or {}
+ if mxid in permissions:
+ return self._get_permissions(mxid)
+
+ homeserver = mxid[mxid.index(":") + 1:]
+ if homeserver in permissions:
+ return self._get_permissions(homeserver)
+
+ return self._get_permissions("*")
+
def generate_registration(self):
homeserver = self["homeserver.domain"]
diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py
index 25b2eb97..88c19c2f 100644
--- a/mautrix_telegram/matrix.py
+++ b/mautrix_telegram/matrix.py
@@ -119,7 +119,7 @@ class MatrixHandler:
if not portal:
return
- if not user.whitelisted:
+ if not user.relaybot_whitelisted:
await portal.main_intent.kick(room, user.mxid,
"You are not whitelisted on this Telegram bridge.")
return
@@ -169,7 +169,7 @@ class MatrixHandler:
is_command, text = self.is_command(message)
sender = await User.get_by_mxid(sender).ensure_started()
- if not sender.whitelisted:
+ if not sender.relaybot_whitelisted:
return
portal = Portal.get_by_mxid(room)
@@ -177,7 +177,7 @@ class MatrixHandler:
await portal.handle_matrix_message(sender, message, event_id)
return
- if message["msgtype"] != "m.text":
+ if not sender.whitelisted or message["msgtype"] != "m.text":
return
try:
diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py
index 81a6dcd1..0faf2d73 100644
--- a/mautrix_telegram/portal.py
+++ b/mautrix_telegram/portal.py
@@ -45,9 +45,9 @@ class Portal:
az = None
bot = None
bridge_notices = False
+ alias_template = None
mx_alias_regex = None
hs_domain = None
- mxid_regex = None
by_mxid = {}
by_tgid = {}
@@ -223,7 +223,7 @@ class Portal:
if self.peer_type == "channel" and entity.username:
public = True
- alias = self._get_room_alias(entity.username)
+ alias = self._get_alias_localpart(entity.username)
self.username = entity.username
else:
public = False
@@ -281,8 +281,17 @@ class Portal:
}
return levels
- def _get_room_alias(self, username=None):
- return self.alias_template.format(groupname=username or self.username)
+ @property
+ def alias(self):
+ if not self.username:
+ return None
+ return f"#{self._get_alias_localpart()}:{self.hs_domain}"
+
+ def _get_alias_localpart(self, username=None):
+ username = username or self.username
+ if not username:
+ return None
+ return self.alias_template.format(groupname=username)
async def sync_telegram_users(self, source, users):
allowed_tgids = set()
@@ -361,10 +370,10 @@ class Portal:
async def update_username(self, username):
if self.username != username:
if self.username:
- await self.main_intent.remove_room_alias(self._get_room_alias())
+ await self.main_intent.remove_room_alias(self._get_alias_localpart())
self.username = username or None
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_alias_localpart())
await self.main_intent.set_join_rule(self.mxid, "public")
else:
await self.main_intent.set_join_rule(self.mxid, "invite")
@@ -453,7 +462,7 @@ class Portal:
if p.Puppet.get_id_from_mxid(member) or member == self.main_intent.mxid:
continue
user = await u.User.get_by_mxid(member).ensure_started()
- if (has_bot and user.whitelisted) or user.has_full_access:
+ if (has_bot and user.relaybot_whitelisted) or user.has_full_access:
authenticated.append(user)
return authenticated
@@ -1156,7 +1165,7 @@ class Portal:
return None
@classmethod
- def get_by_entity(cls, entity, receiver_id=None):
+ def get_by_entity(cls, entity, receiver_id=None, create=True):
entity_type = type(entity)
if entity_type in {Chat, ChatFull}:
type_name = "chat"
@@ -1178,7 +1187,9 @@ class Portal:
id = entity.user_id
else:
raise ValueError(f"Unknown entity type {entity_type.__name__}")
- return cls.get_by_tgid(id, receiver_id if type_name == "user" else id, type_name)
+ return cls.get_by_tgid(id,
+ receiver_id if type_name == "user" else id,
+ type_name if create else None)
# endregion
diff --git a/mautrix_telegram/tgclient.py b/mautrix_telegram/tgclient.py
index a137ce9f..089cd7c0 100644
--- a/mautrix_telegram/tgclient.py
+++ b/mautrix_telegram/tgclient.py
@@ -22,6 +22,9 @@ from telethon.tl.types import *
class MautrixTelegramClient(TelegramClient):
+ def send_message_super(self, *args, **kwargs):
+ return super().send_message(*args, **kwargs)
+
async def send_message(self, entity, message, reply_to=None, entities=None, link_preview=True):
entity = await self.get_input_entity(entity)
diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py
index 25a62d64..f380ee4f 100644
--- a/mautrix_telegram/user.py
+++ b/mautrix_telegram/user.py
@@ -50,20 +50,14 @@ class User(AbstractUser):
self.command_status = None
- self.is_admin = self.mxid in config.get("bridge.admins", [])
-
- whitelist = config.get("bridge.whitelist", None) or [self.mxid]
- self.whitelisted = not whitelist or self.mxid in whitelist
- if not self.whitelisted:
- homeserver = self.mxid[self.mxid.index(":") + 1:]
- self.whitelisted = homeserver in whitelist
+ (self.relaybot_whitelisted,
+ self.whitelisted,
+ self.is_admin) = config.get_permissions(self.mxid)
self.by_mxid[mxid] = self
if tgid:
self.by_tgid[tgid] = self
- self._init_client()
-
@property
def name(self):
return self.mxid
diff --git a/setup.py b/setup.py
index 9693d67f..7907f4b4 100644
--- a/setup.py
+++ b/setup.py
@@ -27,9 +27,9 @@ setuptools.setup(
"python-magic>=0.4.15,<0.5",
],
dependency_links=[
- ("https://github.com/LonamiWebs/Telethon/tarball/6e854325a8e0e800a4f337257293d09006946162#egg=Telethon"
+ ("https://github.com/LonamiWebs/Telethon/tarball/7998fd59f709ae1cd959c5cc4ab107982307f4a6#egg=Telethon"
if sys.version_info >= (3, 6)
- else "https://github.com/tulir/Telethon/tarball/24dc21aea3305ef3bb8c7fcaef2025ae65d5c85e#egg=Telethon")
+ else "https://github.com/tulir/Telethon/tarball/ca08fe28800d74fd6c19fd6f473e12fbf2c258de#egg=Telethon")
],
classifiers=[