diff --git a/example-config.yaml b/example-config.yaml
index 4a19329f..5586aa8e 100644
--- a/example-config.yaml
+++ b/example-config.yaml
@@ -73,6 +73,11 @@ metrics:
enabled: false
listen_port: 8000
+# Manhole config.
+manhole:
+ enabled: false
+ path: /var/tmp/mautrix-telegram.manhole
+
# Bridge config
bridge:
# Localpart template of MXIDs for Telegram users.
diff --git a/mautrix_telegram/__main__.py b/mautrix_telegram/__main__.py
index 35714151..1a7e07d0 100644
--- a/mautrix_telegram/__main__.py
+++ b/mautrix_telegram/__main__.py
@@ -13,8 +13,8 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
+from typing import Optional
from itertools import chain
-import sys
from alchemysession import AlchemySessionContainer
@@ -23,6 +23,7 @@ from mautrix.bridge.db import Base
from .web.provisioning import ProvisioningAPI
from .web.public import PublicBridgeWebsite
+from .commands.manhole import ManholeState
from .abstract_user import init as init_abstract_user
from .bot import Bot, init as init_bot
from .config import Config
@@ -55,6 +56,7 @@ class TelegramBridge(Bridge):
config: Config
session_container: AlchemySessionContainer
bot: Bot
+ manhole: Optional[ManholeState]
def prepare_db(self) -> None:
super().prepare_db()
@@ -84,9 +86,10 @@ class TelegramBridge(Bridge):
def prepare_bridge(self) -> None:
self.bot = init_bot(self.config)
- context = Context(self.az, self.config, self.loop, self.session_container, self.bot)
+ context = Context(self.az, self.config, self.loop, self.session_container, self, self.bot)
self._prepare_website(context)
self.matrix = context.mx = MatrixHandler(context)
+ self.manhole = None
init_abstract_user(context)
init_formatter(context)
@@ -100,6 +103,9 @@ class TelegramBridge(Bridge):
for puppet in Puppet.by_custom_mxid.values():
puppet.stop()
self.shutdown_actions = (user.stop() for user in User.by_tgid.values())
+ if self.manhole:
+ self.manhole.close()
+ self.manhole = None
TelegramBridge().run()
diff --git a/mautrix_telegram/commands/__init__.py b/mautrix_telegram/commands/__init__.py
index b33fe63b..cbdd778c 100644
--- a/mautrix_telegram/commands/__init__.py
+++ b/mautrix_telegram/commands/__init__.py
@@ -1,7 +1,7 @@
from .handler import (command_handler, CommandHandler, CommandProcessor, CommandEvent,
SECTION_AUTH, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT,
SECTION_MISC, SECTION_ADMIN)
-from . import portal, telegram, clean_rooms, matrix_auth
+from . import portal, telegram, clean_rooms, matrix_auth, manhole
__all__ = ["command_handler", "CommandHandler", "CommandProcessor", "CommandEvent",
"SECTION_AUTH", "SECTION_MISC", "SECTION_ADMIN", "SECTION_CREATING_PORTALS",
diff --git a/mautrix_telegram/commands/handler.py b/mautrix_telegram/commands/handler.py
index 63a6f17b..f50e59e5 100644
--- a/mautrix_telegram/commands/handler.py
+++ b/mautrix_telegram/commands/handler.py
@@ -46,6 +46,7 @@ class CommandEvent(BaseCommandEvent):
is_portal: bool) -> None:
super().__init__(processor, room_id, event_id, sender, command, args, is_management,
is_portal)
+ self.bridge = processor.bridge
self.tgbot = processor.tgbot
self.config = processor.config
self.public_website = processor.public_website
@@ -113,6 +114,7 @@ class CommandProcessor(BaseCommandProcessor):
super().__init__(az=context.az, config=context.config, event_class=CommandEvent,
loop=context.loop)
self.tgbot = context.bot
+ self.bridge = context.bridge
self.az, self.config, self.loop, self.tgbot = context.core
self.public_website = context.public_website
self.command_prefix = self.config["bridge.command_prefix"]
diff --git a/mautrix_telegram/commands/manhole.py b/mautrix_telegram/commands/manhole.py
new file mode 100644
index 00000000..6b560da6
--- /dev/null
+++ b/mautrix_telegram/commands/manhole.py
@@ -0,0 +1,95 @@
+# mautrix-telegram - A Matrix-Telegram puppeting bridge
+# Copyright (C) 2019 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+from typing import Optional, Callable
+import asyncio
+import sys
+import os
+
+from attr import dataclass
+
+from telethon import __version__ as __telethon_version__
+
+from mautrix import __version__ as __mautrix_version__
+from mautrix.types import UserID
+from mautrix.errors import MatrixConnectionError
+from mautrix.util.manhole import start_manhole
+
+from .. import __version__
+from . import command_handler, CommandEvent, SECTION_ADMIN
+
+
+@dataclass
+class ManholeState:
+ server: Optional[asyncio.AbstractServer] = None
+ opened_by: Optional[UserID] = None
+ close: Optional[Callable[[], None]] = None
+
+
+@command_handler(needs_auth=False, needs_admin=True, help_section=SECTION_ADMIN,
+ help_text="Open a manhole into the bridge.")
+async def manhole(evt: CommandEvent) -> None:
+ if not evt.config["manhole.enabled"]:
+ await evt.reply("The manhole has been disabled in the config.")
+ return
+
+ if evt.bridge.manhole:
+ await evt.reply(f"There's an existing manhole opened by {evt.bridge.manhole.opened_by}")
+ return
+
+ from ..portal import Portal
+ from ..puppet import Puppet
+ from ..user import User
+ namespace = {
+ "bridge": evt.bridge,
+ "User": User,
+ "Portal": Portal,
+ "Puppet": Puppet,
+ }
+ banner = (f"Python {sys.version} on {sys.platform}\n"
+ f"mautrix-telegram {__version__} with mautrix-python {__mautrix_version__} "
+ f"and Telethon {__telethon_version__}\n\nManhole opened by {evt.sender.mxid}\n")
+ path = evt.config["manhole.path"]
+
+ evt.log.info(f"{evt.sender.mxid} opened a manhole.")
+ server, close = await start_manhole(path=path, banner=banner, namespace=namespace,
+ loop=evt.loop)
+ evt.bridge.manhole = ManholeState(server=server, opened_by=evt.sender.mxid, close=close)
+ await evt.reply(f"Opened manhole at unix://{path}")
+ await server.wait_closed()
+ evt.bridge.manhole = None
+ try:
+ os.unlink(path)
+ except FileNotFoundError:
+ pass
+ evt.log.info(f"{evt.sender.mxid}'s manhole was closed.")
+ try:
+ await evt.reply("Your manhole was closed.")
+ except (AttributeError, MatrixConnectionError) as e:
+ evt.log.warning(f"Failed to send manhole close notification: {e}")
+
+
+@command_handler(needs_auth=False, needs_admin=True, help_section=SECTION_ADMIN,
+ help_text="Close an open manhole.")
+async def close_manhole(evt: CommandEvent) -> None:
+ if not evt.bridge.manhole:
+ await evt.reply("There is no open manhole.")
+ return
+
+ opened_by = evt.bridge.manhole.opened_by
+ evt.bridge.manhole.close()
+ evt.bridge.manhole = None
+ if opened_by != evt.sender.mxid:
+ await evt.reply(f"Closed manhole opened by {opened_by}")
diff --git a/mautrix_telegram/config.py b/mautrix_telegram/config.py
index 65240713..c59a6e1c 100644
--- a/mautrix_telegram/config.py
+++ b/mautrix_telegram/config.py
@@ -74,6 +74,9 @@ class Config(BaseBridgeConfig):
copy("metrics.enabled")
copy("metrics.listen_port")
+ copy("manhole.enabled")
+ copy("manhole.path")
+
copy("bridge.username_template")
copy("bridge.alias_template")
copy("bridge.displayname_template")
diff --git a/mautrix_telegram/context.py b/mautrix_telegram/context.py
index 4566de3f..3ed15e4f 100644
--- a/mautrix_telegram/context.py
+++ b/mautrix_telegram/context.py
@@ -25,12 +25,14 @@ if TYPE_CHECKING:
from .config import Config
from .bot import Bot
from .matrix import MatrixHandler
+ from .__main__ import TelegramBridge
class Context:
az: AppService
config: 'Config'
loop: asyncio.AbstractEventLoop
+ bridge: 'TelegramBridge'
bot: Optional['Bot']
mx: Optional['MatrixHandler']
session_container: AlchemySessionContainer
@@ -38,10 +40,12 @@ class Context:
provisioning_api: Optional['ProvisioningAPI']
def __init__(self, az: AppService, config: 'Config', loop: asyncio.AbstractEventLoop,
- session_container: AlchemySessionContainer, bot: Optional['Bot']) -> None:
+ session_container: AlchemySessionContainer, bridge: 'TelegramBridge',
+ bot: Optional['Bot']) -> None:
self.az = az
self.config = config
self.loop = loop
+ self.bridge = bridge
self.bot = bot
self.mx = None
self.session_container = session_container
diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py
index c181d9bf..3d3ab7fa 100644
--- a/mautrix_telegram/user.py
+++ b/mautrix_telegram/user.py
@@ -27,6 +27,7 @@ from telethon.tl.functions.account import UpdateStatusRequest
from mautrix.client import Client
from mautrix.errors import MatrixRequestError
from mautrix.types import UserID
+from mautrix.bridge import BaseUser
from .types import TelegramID
from .db import User as DBUser
@@ -42,7 +43,7 @@ config: Optional['Config'] = None
SearchResult = NewType('SearchResult', Tuple['pu.Puppet', int])
-class User(AbstractUser):
+class User(AbstractUser, BaseUser):
log: logging.Logger = logging.getLogger("mau.user")
by_mxid: Dict[str, 'User'] = {}
by_tgid: Dict[int, 'User'] = {}
diff --git a/setup.py b/setup.py
index a8e824a8..8fb78995 100644
--- a/setup.py
+++ b/setup.py
@@ -32,7 +32,7 @@ setuptools.setup(
install_requires=[
"aiohttp>=3.0.1,<4",
- "mautrix>=0.4.0.dev53,<0.5",
+ "mautrix>=0.4.0.dev54,<0.5",
"SQLAlchemy>=1.2.3,<2",
"alembic>=1.0.0,<2",
"commonmark>=0.8.1,<1",