Merge pull request #79 from tulir/authless-relaybot-portals

Allow creating relaybot portals without any authenticated users
This commit is contained in:
Tulir Asokan
2018-02-23 18:21:35 +02:00
committed by GitHub
10 changed files with 214 additions and 39 deletions
+20 -10
View File
@@ -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
+1
View File
@@ -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()
+2
View File
@@ -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):
+56 -2
View File
@@ -15,6 +15,7 @@
# 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
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 <mxid>` to get an invite.")
async def handle_command_invite(self, portal, reply, mxid):
if len(mxid) == 0:
return await reply("Usage: `/invite <mxid>`")
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
+104 -4
View File
@@ -15,20 +15,22 @@
# 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 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"]
+3 -3
View File
@@ -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:
+20 -9
View File
@@ -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
+3
View File
@@ -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)
+3 -9
View File
@@ -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
+2 -2
View File
@@ -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=[