diff --git a/example-config.yaml b/example-config.yaml
index 27c64991..69c832f3 100644
--- a/example-config.yaml
+++ b/example-config.yaml
@@ -33,6 +33,17 @@ appservice:
# implicitly.
external: https://example.com/public
+ # Provisioning API part of the web server for automated portal creation and fetching information.
+ # Used by things like Dimension (https://dimension.t2bot.io/).
+ provisioning:
+ # Whether or not the provisioning API should be enabled.
+ enabled: true
+ # The prefix to use in the provisioning API endpoints.
+ prefix: /_matrix/provision/v1
+ # The shared secret to authorize users of the API.
+ # If you leave the default token, a random token will be generated and saved at startup.
+ shared_secret: "Very secret shared secret"
+
# The unique ID of this appservice.
id: telegram
# Username of the appservice bot.
@@ -228,6 +239,8 @@ logging:
level: DEBUG
telethon:
level: DEBUG
+ aiohttp:
+ level: INFO
root:
level: DEBUG
handlers: [file, console]
diff --git a/mautrix_telegram/__main__.py b/mautrix_telegram/__main__.py
index 0d06f101..430696eb 100644
--- a/mautrix_telegram/__main__.py
+++ b/mautrix_telegram/__main__.py
@@ -38,7 +38,8 @@ from .bot import init as init_bot
from .portal import init as init_portal
from .puppet import init as init_puppet
from .formatter import init as init_formatter
-from .public import PublicBridgeWebsite
+from .web.public import PublicBridgeWebsite
+from .web.provisioning import ProvisioningAPI
from .context import Context
from .sqlstatestore import SQLStateStore
@@ -75,9 +76,9 @@ db_factory = orm.sessionmaker(bind=db_engine)
db_session = orm.scoping.scoped_session(db_factory)
Base.metadata.bind = db_engine
-telethon_session_container = AlchemySessionContainer(engine=db_engine, session=db_session,
- table_base=Base, table_prefix="telethon_",
- manage_tables=False)
+session_container = AlchemySessionContainer(engine=db_engine, session=db_session,
+ table_base=Base, table_prefix="telethon_",
+ manage_tables=False)
loop = asyncio.get_event_loop()
@@ -87,11 +88,20 @@ appserv = AppService(config["homeserver.address"], config["homeserver.domain"],
config["appservice.bot_username"], log="mau.as", loop=loop,
verify_ssl=config["homeserver.verify_ssl"], state_store=state_store)
-context = Context(appserv, db_session, config, loop, None, None, telethon_session_container)
+public_website = None
+provisioning_api = None
if config["appservice.public.enabled"]:
- public = PublicBridgeWebsite(loop)
- appserv.app.add_subapp(config.get("appservice.public.prefix", "/public"), public.app)
+ public_website = PublicBridgeWebsite(loop)
+ appserv.app.add_subapp(config["appservice.public.prefix"] or "/public", public_website.app)
+
+if config["appservice.provisioning.enabled"]:
+ provisioning_api = ProvisioningAPI(config, appserv, loop)
+ appserv.app.add_subapp(config["appservice.provisioning.prefix"] or "/_matrix/provisioning",
+ provisioning_api.app)
+
+context = Context(appserv, db_session, config, loop, None, None, session_container, public_website,
+ provisioning_api)
with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start:
init_db(db_session)
diff --git a/mautrix_telegram/abstract_user.py b/mautrix_telegram/abstract_user.py
index dd1fe03b..1095e993 100644
--- a/mautrix_telegram/abstract_user.py
+++ b/mautrix_telegram/abstract_user.py
@@ -96,9 +96,9 @@ class AbstractUser:
except Exception:
self.log.exception("Failed to handle Telegram update")
- async def _get_dialogs(self, limit=None):
+ async def get_dialogs(self, limit=None) -> List[Union[Chat, Channel]]:
if self.is_bot:
- return
+ return []
dialogs = await self.client.get_dialogs(limit=limit)
return [dialog.entity for dialog in dialogs if (
not isinstance(dialog.entity, (User, ChatForbidden, ChannelForbidden))
@@ -328,5 +328,5 @@ class AbstractUser:
def init(context):
global config, MAX_DELETIONS
AbstractUser.az, AbstractUser.db, config, AbstractUser.loop, _ = context
- AbstractUser.session_container = context.telethon_session_container
+ AbstractUser.session_container = context.session_container
MAX_DELETIONS = config.get("bridge.max_telegram_delete", 10)
diff --git a/mautrix_telegram/commands/auth.py b/mautrix_telegram/commands/auth.py
index 4c6b135c..67c7ab64 100644
--- a/mautrix_telegram/commands/auth.py
+++ b/mautrix_telegram/commands/auth.py
@@ -114,7 +114,7 @@ async def login(evt: CommandEvent):
if evt.config["appservice.public.enabled"]:
prefix = evt.config["appservice.public.external"]
- url = f"{prefix}/login?mxid={evt.sender.mxid}"
+ url = f"{prefix}/login?token={evt.public_website.make_token(evt.sender.mxid)}"
if evt.config.get("bridge.allow_matrix_login", True):
return await evt.reply(
"This bridge instance allows you to log in inside or outside Matrix.\n\n"
diff --git a/mautrix_telegram/commands/handler.py b/mautrix_telegram/commands/handler.py
index 7bf4323d..53a71a4b 100644
--- a/mautrix_telegram/commands/handler.py
+++ b/mautrix_telegram/commands/handler.py
@@ -22,8 +22,7 @@ import logging
from telethon.errors import FloodWaitError
from ..util import format_duration
-from ..context import Context
-from .. import user as u
+from .. import user as u, context as c
command_handlers = {} # type: Dict[str, CommandHandler]
@@ -45,6 +44,7 @@ class CommandEvent:
self.loop = processor.loop
self.tgbot = processor.tgbot
self.config = processor.config
+ self.public_website = processor.public_website
self.command_prefix = processor.command_prefix
self.room_id = room
self.sender = sender
@@ -131,8 +131,9 @@ def command_handler(_func: Optional[Callable[[CommandEvent], None]] = None, *, n
class CommandProcessor:
log = logging.getLogger("mau.commands")
- def __init__(self, context: Context):
+ def __init__(self, context: c.Context):
self.az, self.db, self.config, self.loop, self.tgbot = context
+ self.public_website = context.public_website
self.command_prefix = self.config["bridge.command_prefix"]
async def handle(self, room: str, sender: u.User, command: str, args: List[str],
diff --git a/mautrix_telegram/commands/portal.py b/mautrix_telegram/commands/portal.py
index c87ade32..38998224 100644
--- a/mautrix_telegram/commands/portal.py
+++ b/mautrix_telegram/commands/portal.py
@@ -19,10 +19,11 @@ import asyncio
from telethon.errors import *
from telethon.tl.types import ChatForbidden, ChannelForbidden
-from mautrix_appservice import MatrixRequestError
+from mautrix_appservice import MatrixRequestError, IntentAPI
from .. import portal as po, user as u
-from . import command_handler, CommandEvent, SECTION_ADMIN, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT
+from . import (command_handler, CommandEvent,
+ SECTION_ADMIN, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT)
@command_handler(needs_admin=True, needs_auth=False, name="set-pl",
@@ -65,7 +66,7 @@ async def invite_link(evt: CommandEvent):
return await evt.reply("You don't have the permission to create an invite link.")
-async def _has_access_to(room: str, intent, sender: u.User, event: str, default: int = 50):
+async def user_has_power_level(room: str, intent, sender: u.User, event: str, default: int = 50):
if sender.is_admin:
return True
# Make sure the state store contains the power levels.
@@ -87,7 +88,7 @@ async def _get_portal_and_check_permission(evt: CommandEvent, permission: str,
that_this = "This" if room_id == evt.room_id else "That"
return await evt.reply(f"{that_this} is not a portal room."), False
- if not await _has_access_to(portal.mxid, evt.az.intent, evt.sender, permission):
+ if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, permission):
action = action or f"{permission.replace('_', ' ')}s"
return await evt.reply(f"You do not have the permissions to {action} that portal."), False
return portal, True
@@ -116,7 +117,7 @@ def _get_portal_murder_function(action: str, room_id: str, function: Callable, c
"Only works for group chats; to delete a private chat portal, simply "
"leave the room.")
async def delete_portal(evt: CommandEvent):
- portal, ok = await _get_portal_and_check_permission(evt, "delete_portal")
+ portal, ok = await _get_portal_and_check_permission(evt, "unbridge")
if not ok:
return
@@ -137,7 +138,7 @@ async def delete_portal(evt: CommandEvent):
help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Remove puppets from the current portal room and forget the portal.")
async def unbridge(evt: CommandEvent):
- portal, ok = await _get_portal_and_check_permission(evt, "unbridge_room")
+ portal, ok = await _get_portal_and_check_permission(evt, "unbridge")
if not ok:
return
@@ -166,8 +167,8 @@ async def bridge(evt: CommandEvent):
if portal:
return await evt.reply(f"{that_this} room is already a portal room.")
- if not await _has_access_to(room_id, evt.az.intent, evt.sender, "bridge"):
- return await evt.reply("You do not have the permissions to bridge that room.")
+ if not await user_has_power_level(room_id, evt.az.intent, evt.sender, "bridge"):
+ return await evt.reply(f"You do not have the permissions to bridge {that_this} room.")
# The /id bot command provides the prefixed ID, so we assume
tgid = evt.args[0]
@@ -192,7 +193,7 @@ async def bridge(evt: CommandEvent):
has_portal_message = (
"That Telegram chat already has a portal at "
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}). ")
- if not await _has_access_to(portal.mxid, evt.az.intent, evt.sender, "unbridge"):
+ if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"):
return await evt.reply(f"{has_portal_message}"
"Additionally, you do not have the permissions to unbridge "
"that room.")
@@ -291,7 +292,7 @@ async def confirm_bridge(evt: CommandEvent):
direct = False
portal.mxid = bridge_to_mxid
- portal.title, portal.about, levels = await _get_initial_state(evt)
+ portal.title, portal.about, levels = await get_initial_state(evt.az.intent, evt.room_id)
portal.photo_id = ""
portal.save()
@@ -301,8 +302,8 @@ async def confirm_bridge(evt: CommandEvent):
return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.")
-async def _get_initial_state(evt: CommandEvent):
- state = await evt.az.intent.get_room_state(evt.room_id)
+async def get_initial_state(intent: IntentAPI, room_id: str):
+ state = await intent.get_room_state(room_id)
title = None
about = None
levels = None
@@ -336,7 +337,10 @@ async def create(evt: CommandEvent):
if po.Portal.get_by_mxid(evt.room_id):
return await evt.reply("This is already a portal room.")
- title, about, levels = await _get_initial_state(evt)
+ if not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"):
+ return await evt.reply("You do not have the permissions to bridge this room.")
+
+ title, about, levels = await get_initial_state(evt.az.intent, evt.room_id)
if not title:
return await evt.reply("Please set a title before creating a Telegram chat.")
diff --git a/mautrix_telegram/config.py b/mautrix_telegram/config.py
index fb2e14a2..77cc6bfb 100644
--- a/mautrix_telegram/config.py
+++ b/mautrix_telegram/config.py
@@ -159,6 +159,12 @@ class Config(DictWithRecursion):
copy("appservice.public.prefix")
copy("appservice.public.external")
+ copy("appservice.provisioning.enabled")
+ copy("appservice.provisioning.prefix")
+ copy("appservice.provisioning.shared_secret")
+ if base["appservice.provisioning.shared_secret"] == "Very secret shared secret":
+ base["appservice.provisioning.shared_secret"] = self._new_token()
+
copy("appservice.id")
copy("appservice.bot_username")
copy("appservice.bot_displayname")
@@ -244,7 +250,7 @@ class Config(DictWithRecursion):
puppeting = level == "full" or admin
user = level == "user" or puppeting
relaybot = level == "relaybot" or user
- return relaybot, user, puppeting, admin
+ return relaybot, user, puppeting, admin, level
def get_permissions(self, mxid):
permissions = self["bridge.permissions"] or {}
diff --git a/mautrix_telegram/context.py b/mautrix_telegram/context.py
index 530f1eed..ad48d7e4 100644
--- a/mautrix_telegram/context.py
+++ b/mautrix_telegram/context.py
@@ -14,17 +14,30 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
+import asyncio
+
+from sqlalchemy.orm import scoped_session
+from alchemysession import AlchemySessionContainer
+from mautrix_appservice import AppService
class Context:
- def __init__(self, az, db, config, loop, bot, mx, telethon_session_container):
- self.az = az
- self.db = db
- self.config = config
- self.loop = loop
- self.bot = bot
- self.mx = mx
- self.telethon_session_container = telethon_session_container
+ def __init__(self, az, db, config, loop, bot, mx, session_container, public_website,
+ provisioning_api):
+ from .web import PublicBridgeWebsite, ProvisioningAPI
+ from .config import Config
+ from .bot import Bot
+ from .matrix import MatrixHandler
+
+ self.az = az # type: AppService
+ self.db = db # type: scoped_session
+ self.config = config # type: Config
+ self.loop = loop # type: asyncio.AbstractEventLoop
+ self.bot = bot # type: Bot
+ self.mx = mx # type: MatrixHandler
+ self.session_container = session_container # type: AlchemySessionContainer
+ self.public_website = public_website # type: PublicBridgeWebsite
+ self.provisioning_api = provisioning_api # type: ProvisioningAPI
def __iter__(self):
yield self.az
diff --git a/mautrix_telegram/formatter/__init__.py b/mautrix_telegram/formatter/__init__.py
index 7cb102f7..51802ebb 100644
--- a/mautrix_telegram/formatter/__init__.py
+++ b/mautrix_telegram/formatter/__init__.py
@@ -1,9 +1,9 @@
from .from_matrix import (matrix_reply_to_telegram, matrix_to_telegram, matrix_text_to_telegram,
init_mx)
from .from_telegram import (telegram_reply_to_matrix, telegram_to_matrix, init_tg)
-from ..context import Context
+from .. import context as c
-def init(context: Context):
+def init(context: c.Context):
init_mx(context)
init_tg(context)
diff --git a/mautrix_telegram/formatter/from_matrix.py b/mautrix_telegram/formatter/from_matrix.py
index 145177fc..f98d3ad5 100644
--- a/mautrix_telegram/formatter/from_matrix.py
+++ b/mautrix_telegram/formatter/from_matrix.py
@@ -27,8 +27,7 @@ from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, M
MessageEntityItalic, MessageEntityCode, MessageEntityPre,
MessageEntityBotCommand, TypeMessageEntity)
-from ..context import Context
-from .. import user as u, puppet as pu, portal as po
+from .. import user as u, puppet as pu, portal as po, context as c
from ..db import Message as DBMessage
from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
trim_reply_fallback_text, html_to_unicode)
@@ -352,7 +351,7 @@ def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[str], st
return entities, replacer
-def init_mx(context: Context):
+def init_mx(context: c.Context):
global plain_mention_regex, should_bridge_plaintext_highlights
config = context.config
dn_template = config.get("bridge.displayname_template", "{displayname} (Telegram)")
diff --git a/mautrix_telegram/formatter/from_telegram.py b/mautrix_telegram/formatter/from_telegram.py
index 3e9992e4..70a13a55 100644
--- a/mautrix_telegram/formatter/from_telegram.py
+++ b/mautrix_telegram/formatter/from_telegram.py
@@ -33,8 +33,7 @@ from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName,
from mautrix_appservice import MatrixRequestError
from mautrix_appservice.intent_api import IntentAPI
-from .. import user as u, puppet as pu, portal as po
-from ..context import Context
+from .. import user as u, puppet as pu, portal as po, context as c
from ..db import Message as DBMessage
from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
trim_reply_fallback_text, unicode_to_html)
@@ -321,6 +320,6 @@ def _parse_url(html: List[str], entity_text: str, url: str) -> bool:
return False
-def init_tg(context: Context):
+def init_tg(context: c.Context):
global should_highlight_edits
should_highlight_edits = htmldiff and context.config["bridge.highlight_edits"]
diff --git a/mautrix_telegram/public/__init__.py b/mautrix_telegram/public/__init__.py
deleted file mode 100644
index 6a463f2e..00000000
--- a/mautrix_telegram/public/__init__.py
+++ /dev/null
@@ -1,184 +0,0 @@
-# -*- 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 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 aiohttp import web
-from mako.template import Template
-import asyncio
-import pkg_resources
-import logging
-
-from telethon.errors import *
-
-from ..user import User
-from ..commands.auth import enter_password
-from ..util import format_duration
-
-
-class PublicBridgeWebsite:
- log = logging.getLogger("mau.public")
-
- def __init__(self, loop):
- self.loop = loop
-
- self.login = Template(
- pkg_resources.resource_string("mautrix_telegram", "public/login.html.mako"))
-
- self.app = web.Application(loop=loop)
- self.app.router.add_route("GET", "/login", self.get_login)
- self.app.router.add_route("POST", "/login", self.post_login)
- self.app.router.add_static("/",
- pkg_resources.resource_filename("mautrix_telegram", "public/"))
-
- async def get_login(self, request):
- user = (User.get_by_mxid(request.rel_url.query["mxid"], create=False)
- if "mxid" in request.rel_url.query else None)
- state = "token" if request.rel_url.query.get("mode", "") == "bot" else "request"
- if not user:
- return self.render_login(
- mxid=request.rel_url.query["mxid"] if "mxid" in request.rel_url.query else None,
- state=state)
- elif not user.puppet_whitelisted:
- return self.render_login(mxid=user.mxid, error="You are not whitelisted.", status=403)
- await user.ensure_started()
- if not await user.is_logged_in():
- return self.render_login(mxid=user.mxid, state=state)
-
- return self.render_login(mxid=user.mxid, username=user.username)
-
- def render_login(self, status=200, username="", state="", error="", message="", mxid=""):
- return web.Response(status=status, content_type="text/html",
- text=self.login.render(username=username, state=state, error=error,
- message=message, mxid=mxid))
-
- async def post_login_token(self, user, token):
- try:
- user_info = await user.client.sign_in(bot_token=token)
- asyncio.ensure_future(user.post_login(user_info), loop=self.loop)
- if user.command_status and user.command_status["action"] == "Login":
- user.command_status = None
- return self.render_login(mxid=user.mxid, state="logged-in", status=200,
- username=user_info.username)
- except Exception:
- self.log.exception("Error sending bot token")
- return self.render_login(mxid=user.mxid, state="token", status=500,
- error="Internal server error while sending token.")
-
- async def post_login_phone(self, user, phone):
- try:
- await user.client.sign_in(phone or "+123")
- return self.render_login(mxid=user.mxid, state="code", status=200,
- message="Code requested successfully.")
- except PhoneNumberInvalidError:
- return self.render_login(mxid=user.mxid, state="request", status=400,
- error="Invalid phone number.")
- except PhoneNumberUnoccupiedError:
- return self.render_login(mxid=user.mxid, state="request", status=404,
- error="That phone number has not been registered.")
- except PhoneNumberFloodError:
- return self.render_login(
- mxid=user.mxid, state="request", status=429,
- error="Your phone number has been temporarily blocked for flooding. "
- "The ban is usually applied for around a day.")
- except FloodWaitError as e:
- return self.render_login(
- mxid=user.mxid, state="request", status=429,
- error="Your phone number has been temporarily blocked for flooding. "
- f"Please wait for {format_duration(e.seconds)} before trying again.")
- except PhoneNumberBannedError:
- return self.render_login(mxid=user.mxid, state="request", status=401,
- error="Your phone number is banned from Telegram.")
- except PhoneNumberAppSignupForbiddenError:
- return self.render_login(mxid=user.mxid, state="request", status=401,
- error="You have disabled 3rd party apps on your account.")
- except Exception:
- self.log.exception("Error requesting phone code")
- return self.render_login(mxid=user.mxid, state="request", status=500,
- error="Internal server error while requesting code.")
-
- async def post_login_code(self, user, code, password_in_data):
- try:
- user_info = await user.client.sign_in(code=code)
- asyncio.ensure_future(user.post_login(user_info), loop=self.loop)
- if user.command_status and user.command_status["action"] == "Login":
- user.command_status = None
- return self.render_login(mxid=user.mxid, state="logged-in", status=200,
- username=user_info.username)
- except PhoneCodeInvalidError:
- return self.render_login(mxid=user.mxid, state="code", status=403,
- error="Incorrect phone code.")
- except PhoneCodeExpiredError:
- return self.render_login(mxid=user.mxid, state="code", status=403,
- error="Phone code expired.")
- except SessionPasswordNeededError:
- if not password_in_data:
- if user.command_status and user.command_status["action"] == "Login":
- user.command_status = {
- "next": enter_password,
- "action": "Login (password entry)",
- }
- return self.render_login(
- mxid=user.mxid, state="password", status=200,
- message="Code accepted, but you have 2-factor authentication is enabled.")
- return None
- except Exception:
- self.log.exception("Error sending phone code")
- return self.render_login(mxid=user.mxid, state="code", status=500,
- error="Internal server error while sending code.")
-
- async def post_login_password(self, user, password):
- try:
- user_info = await user.client.sign_in(password=password)
- asyncio.ensure_future(user.post_login(user_info), loop=self.loop)
- if user.command_status and user.command_status["action"] == "Login (password entry)":
- user.command_status = None
- return self.render_login(mxid=user.mxid, state="logged-in", status=200,
- username=user_info.username)
- except (PasswordHashInvalidError, PasswordEmptyError):
- return self.render_login(mxid=user.mxid, state="password", status=400,
- error="Incorrect password.")
- except Exception:
- self.log.exception("Error sending password")
- return self.render_login(mxid=user.mxid, state="password", status=500,
- error="Internal server error while sending password.")
-
- async def post_login(self, request):
- data = await request.post()
- if "mxid" not in data:
- return self.render_login(error="Please enter your Matrix ID.", status=400)
-
- user = await User.get_by_mxid(data["mxid"]).ensure_started()
- if not user.puppet_whitelisted:
- return self.render_login(mxid=user.mxid, error="You are not whitelisted.", status=403)
- elif await user.is_logged_in():
- return self.render_login(mxid=user.mxid, username=user.username)
-
- await user.ensure_started(even_if_no_session=True)
-
- if "phone" in data:
- return await self.post_login_phone(user, data["phone"])
- elif "token" in data:
- return await self.post_login_token(user, data["token"])
- elif "code" in data:
- resp = await self.post_login_code(user, data["code"],
- password_in_data="password" in data)
- if resp or "password" not in data:
- return resp
- elif "password" not in data:
- return self.render_login(error="No data given.", status=400)
-
- if "password" in data:
- return await self.post_login_password(user, data["password"])
- return self.render_login(error="This should never happen.", status=500)
diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py
index da62fad8..ea0b92e3 100644
--- a/mautrix_telegram/user.py
+++ b/mautrix_telegram/user.py
@@ -14,6 +14,7 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
+from typing import Dict
import logging
import asyncio
import re
@@ -38,23 +39,24 @@ class User(AbstractUser):
def __init__(self, mxid, tgid=None, username=None, db_contacts=None, saved_contacts=0,
is_bot=False, db_portals=None, db_instance=None):
super().__init__()
- self.mxid = mxid
- self.tgid = tgid
- self.is_bot = is_bot
- self.username = username
+ self.mxid = mxid # type: str
+ self.tgid = tgid # type: int
+ self.is_bot = is_bot # type: bool
+ self.username = username # type: str
self.contacts = []
self.saved_contacts = saved_contacts
self.db_contacts = db_contacts
- self.portals = {}
+ self.portals = {} # type: Dict[str, po.Portal]
self.db_portals = db_portals
self._db_instance = db_instance
- self.command_status = None
+ self.command_status = None # type: dict
(self.relaybot_whitelisted,
self.whitelisted,
self.puppet_whitelisted,
- self.is_admin) = config.get_permissions(self.mxid)
+ self.is_admin,
+ self.permissions) = config.get_permissions(self.mxid)
self.by_mxid[mxid] = self
if tgid:
@@ -255,7 +257,7 @@ class User(AbstractUser):
async def sync_dialogs(self, synchronous_create=False):
creators = []
- for entity in await self._get_dialogs(limit=30):
+ for entity in await self.get_dialogs(limit=30):
portal = po.Portal.get_by_entity(entity)
self.portals[portal.tgid_full] = portal
creators.append(
@@ -308,6 +310,9 @@ class User(AbstractUser):
@classmethod
def get_by_mxid(cls, mxid, create=True):
+ if not mxid:
+ raise ValueError("Matrix ID can't be empty")
+
try:
return cls.by_mxid[mxid]
except KeyError:
diff --git a/mautrix_telegram/util/__init__.py b/mautrix_telegram/util/__init__.py
index 7d431396..99cdee2a 100644
--- a/mautrix_telegram/util/__init__.py
+++ b/mautrix_telegram/util/__init__.py
@@ -1,2 +1,3 @@
from .file_transfer import transfer_file_to_matrix, convert_image
from .format_duration import format_duration
+from .signed_token import sign_token, verify_token
diff --git a/mautrix_telegram/util/signed_token.py b/mautrix_telegram/util/signed_token.py
new file mode 100644
index 00000000..13281012
--- /dev/null
+++ b/mautrix_telegram/util/signed_token.py
@@ -0,0 +1,53 @@
+# -*- 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 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
+import json
+import base64
+import hashlib
+
+
+def _get_checksum(key: str, payload: bytes) -> str:
+ hasher = hashlib.sha256()
+ hasher.update(payload)
+ hasher.update(key.encode("utf-8"))
+ checksum = hasher.hexdigest()
+ return checksum
+
+
+def sign_token(key: str, payload: dict) -> str:
+ payload = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8"))
+ checksum = _get_checksum(key, payload)
+ return f"{checksum}:{payload.decode('utf-8')}"
+
+
+def verify_token(key: str, data: str) -> Optional[dict]:
+ if not data:
+ return None
+
+ try:
+ checksum, payload = data.split(":", 1)
+ except ValueError:
+ return None
+
+ if checksum != _get_checksum(key, payload.encode("utf-8")):
+ return None
+
+ payload = base64.urlsafe_b64decode(payload).decode("utf-8")
+ try:
+ return json.loads(payload)
+ except json.JSONDecodeError:
+ return None
diff --git a/mautrix_telegram/web/__init__.py b/mautrix_telegram/web/__init__.py
new file mode 100644
index 00000000..002510e8
--- /dev/null
+++ b/mautrix_telegram/web/__init__.py
@@ -0,0 +1,2 @@
+from .provisioning import ProvisioningAPI
+from .public import PublicBridgeWebsite
diff --git a/mautrix_telegram/web/common/__init__.py b/mautrix_telegram/web/common/__init__.py
new file mode 100644
index 00000000..ccb0d922
--- /dev/null
+++ b/mautrix_telegram/web/common/__init__.py
@@ -0,0 +1 @@
+from .auth_api import AuthAPI
diff --git a/mautrix_telegram/web/common/auth_api.py b/mautrix_telegram/web/common/auth_api.py
new file mode 100644
index 00000000..70b66136
--- /dev/null
+++ b/mautrix_telegram/web/common/auth_api.py
@@ -0,0 +1,150 @@
+# -*- 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 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 abc import abstractmethod
+import abc
+import asyncio
+import logging
+
+from telethon.errors import *
+
+from ...commands.auth import enter_password
+from ...util import format_duration
+
+
+class AuthAPI(abc.ABC):
+ log = logging.getLogger("mau.web.auth")
+
+ def __init__(self, loop):
+ self.loop = loop # type: asyncio.AbstractEventLoop
+
+ @abstractmethod
+ def get_login_response(self, status=200, state="", username="", mxid="", message="", error="",
+ errcode=""):
+ raise NotImplementedError()
+
+ async def post_login_phone(self, user, phone):
+ try:
+ await user.client.sign_in(phone or "+123")
+ return self.get_login_response(mxid=user.mxid, state="code", status=200,
+ message="Code requested successfully.")
+ except PhoneNumberInvalidError:
+ return self.get_login_response(mxid=user.mxid, state="request", status=400,
+ errcode="phone_number_invalid",
+ error="Invalid phone number.")
+ except PhoneNumberBannedError:
+ return self.get_login_response(mxid=user.mxid, state="request", status=403,
+ errcode="phone_number_banned",
+ error="Your phone number is banned from Telegram.")
+ except PhoneNumberAppSignupForbiddenError:
+ return self.get_login_response(mxid=user.mxid, state="request", status=403,
+ errcode="phone_number_app_signup_forbidden",
+ error="You have disabled 3rd party apps on your account.")
+ except PhoneNumberUnoccupiedError:
+ return self.get_login_response(mxid=user.mxid, state="request", status=404,
+ errcode="phone_number_unoccupied",
+ error="That phone number has not been registered.")
+ except PhoneNumberFloodError:
+ return self.get_login_response(
+ mxid=user.mxid, state="request", status=429, errcode="phone_number_flood",
+ error="Your phone number has been temporarily blocked for flooding. "
+ "The ban is usually applied for around a day.")
+ except FloodWaitError as e:
+ return self.get_login_response(
+ mxid=user.mxid, state="request", status=429, errcode="flood_wait",
+ error="Your phone number has been temporarily blocked for flooding. "
+ f"Please wait for {format_duration(e.seconds)} before trying again.")
+ except Exception:
+ self.log.exception("Error requesting phone code")
+ return self.get_login_response(mxid=user.mxid, state="request", status=500,
+ errcode="unknown_error",
+ error="Internal server error while requesting code.")
+
+ async def post_login_token(self, user, token):
+ try:
+ user_info = await user.client.sign_in(bot_token=token)
+ asyncio.ensure_future(user.post_login(user_info), loop=self.loop)
+ if user.command_status and user.command_status["action"] == "Login":
+ user.command_status = None
+ return self.get_login_response(mxid=user.mxid, state="logged-in", status=200,
+ username=user_info.username)
+ except AccessTokenInvalidError:
+ return self.get_login_response(mxid=user.mxid, state="token", status=401,
+ errcode="bot_token_invalid",
+ error="Bot token invalid.")
+ except AccessTokenExpiredError:
+ return self.get_login_response(mxid=user.mxid, state="token", status=403,
+ errcode="bot_token_expired",
+ error="Bot token expired.")
+ except Exception:
+ self.log.exception("Error sending bot token")
+ return self.get_login_response(mxid=user.mxid, state="token", status=500,
+ error="Internal server error while sending token.")
+
+ async def post_login_code(self, user, code, password_in_data):
+ try:
+ user_info = await user.client.sign_in(code=code)
+ asyncio.ensure_future(user.post_login(user_info), loop=self.loop)
+ if user.command_status and user.command_status["action"] == "Login":
+ user.command_status = None
+ return self.get_login_response(mxid=user.mxid, state="logged-in", status=200,
+ username=user_info.username)
+ except PhoneCodeInvalidError:
+ return self.get_login_response(mxid=user.mxid, state="code", status=401,
+ errcode="phone_code_invalid",
+ error="Incorrect phone code.")
+ except PhoneCodeExpiredError:
+ return self.get_login_response(mxid=user.mxid, state="code", status=403,
+ errcode="phone_code_expired",
+ error="Phone code expired.")
+ except SessionPasswordNeededError:
+ if not password_in_data:
+ if user.command_status and user.command_status["action"] == "Login":
+ user.command_status = {
+ "next": enter_password,
+ "action": "Login (password entry)",
+ }
+ return self.get_login_response(
+ mxid=user.mxid, state="password", status=202,
+ message="Code accepted, but you have 2-factor authentication is enabled.")
+ return None
+ except Exception:
+ self.log.exception("Error sending phone code")
+ return self.get_login_response(mxid=user.mxid, state="code", status=500,
+ errcode="unknown_error",
+ error="Internal server error while sending code.")
+
+ async def post_login_password(self, user, password):
+ try:
+ user_info = await user.client.sign_in(password=password)
+ asyncio.ensure_future(user.post_login(user_info), loop=self.loop)
+ if user.command_status and user.command_status["action"] == "Login (password entry)":
+ user.command_status = None
+ return self.get_login_response(mxid=user.mxid, state="logged-in", status=200,
+ username=user_info.username)
+ except PasswordEmptyError:
+ return self.get_login_response(mxid=user.mxid, state="password", status=400,
+ errcode="password_empty",
+ error="Empty password.")
+ except PasswordHashInvalidError:
+ return self.get_login_response(mxid=user.mxid, state="password", status=401,
+ errcode="password_invalid",
+ error="Incorrect password.")
+ except Exception:
+ self.log.exception("Error sending password")
+ return self.get_login_response(mxid=user.mxid, state="password", status=500,
+ errcode="unknown_error",
+ error="Internal server error while sending password.")
diff --git a/mautrix_telegram/web/provisioning/__init__.py b/mautrix_telegram/web/provisioning/__init__.py
new file mode 100644
index 00000000..63bb208d
--- /dev/null
+++ b/mautrix_telegram/web/provisioning/__init__.py
@@ -0,0 +1,381 @@
+# -*- 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 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 aiohttp import web
+from typing import Tuple, Optional, Callable, Awaitable
+import asyncio
+import logging
+import json
+
+from telethon.utils import get_peer_id, resolve_id
+from mautrix_appservice import AppService, MatrixRequestError, IntentError
+
+from ...user import User
+from ...portal import Portal
+from ...commands.portal import user_has_power_level, get_initial_state
+from ...config import Config
+from ..common import AuthAPI
+
+
+class ProvisioningAPI(AuthAPI):
+ log = logging.getLogger("mau.web.provisioning")
+
+ def __init__(self, config: Config, az: AppService, loop: asyncio.AbstractEventLoop):
+ super().__init__(loop)
+ self.secret = config["appservice.provisioning.shared_secret"]
+ self.az = az
+
+ self.app = web.Application(loop=loop, middlewares=[self.error_middleware])
+
+ portal_prefix = "/portal/{mxid:![^/]+}"
+ self.app.router.add_route("GET", f"{portal_prefix}", self.get_portal_by_mxid)
+ self.app.router.add_route("GET", "/portal/{tgid:-[0-9]+}", self.get_portal_by_tgid)
+ self.app.router.add_route("POST", portal_prefix + "/connect/{chat_id:[0-9]+}",
+ self.connect_chat)
+ self.app.router.add_route("POST", f"{portal_prefix}/create", self.create_chat)
+ self.app.router.add_route("POST", f"{portal_prefix}/disconnect", self.disconnect_chat)
+
+ user_prefix = "/user/{mxid:@[^:]*:[^/]+}"
+ self.app.router.add_route("GET", f"{user_prefix}", self.get_user_info)
+ self.app.router.add_route("GET", f"{user_prefix}/chats", self.get_chats)
+
+ self.app.router.add_route("POST", f"{user_prefix}/logout", self.logout)
+ self.app.router.add_route("POST", f"{user_prefix}/login/bot_token", self.send_bot_token)
+ self.app.router.add_route("POST", f"{user_prefix}/login/request_code", self.request_code)
+ self.app.router.add_route("POST", f"{user_prefix}/login/send_code", self.send_code)
+ self.app.router.add_route("POST", f"{user_prefix}/login/send_password", self.send_password)
+
+ async def get_portal_by_mxid(self, request: web.Request) -> web.Response:
+ err = self.check_authorization(request)
+ if err is not None:
+ return err
+
+ mxid = request.match_info["mxid"]
+ portal = Portal.get_by_mxid(mxid)
+ if not portal:
+ return self.get_error_response(404, "portal_not_found",
+ "Portal with given Matrix ID not found.")
+ return web.json_response({
+ "mxid": portal.mxid,
+ "chat_id": get_peer_id(portal.peer),
+ "peer_type": portal.peer_type,
+ "title": portal.title,
+ "about": portal.about,
+ "username": portal.username,
+ "megagroup": portal.megagroup,
+ })
+
+ async def get_portal_by_tgid(self, request: web.Request) -> web.Response:
+ err = self.check_authorization(request)
+ if err is not None:
+ return err
+
+ try:
+ tgid, _ = resolve_id(int(request.match_info["tgid"]))
+ except ValueError:
+ return self.get_error_response(400, "tgid_invalid",
+ "Given chat ID is not valid.")
+ portal = Portal.get_by_tgid(tgid)
+ if not portal:
+ return self.get_error_response(404, "portal_not_found",
+ "Portal to given Telegram chat not found.")
+ return web.json_response({
+ "mxid": portal.mxid,
+ "chat_id": get_peer_id(portal.peer),
+ "peer_type": portal.peer_type,
+ "title": portal.title,
+ "about": portal.about,
+ "username": portal.username,
+ "megagroup": portal.megagroup,
+ })
+
+ async def connect_chat(self, request: web.Request) -> web.Response:
+ err = self.check_authorization(request)
+ if err is not None:
+ return err
+
+ return self.get_error_response(501, "not_implemented",
+ "Connecting existing Matrix rooms to existing Telegram "
+ "chats via the provisioning API is not yet implemented.")
+
+ async def create_chat(self, request: web.Request) -> web.Response:
+ err = self.check_authorization(request)
+ if err is not None:
+ return err
+
+ data = await self.get_data(request)
+ if not data:
+ return self.get_error_response(400, "json_invalid", "Invalid JSON.")
+
+ room_id = request.match_info["mxid"]
+ if Portal.get_by_mxid(room_id):
+ return self.get_error_response(409, "room_already_bridged",
+ "Room is already bridged to another Telegram chat.")
+
+ user, err = await self.get_user(request.query.get("user_id", None), expect_logged_in=None,
+ require_puppeting=False)
+ if err is not None:
+ return err
+ elif not await user.is_logged_in() or user.is_bot:
+ return self.get_error_response(403, "not_logged_in_real_account",
+ "You are not logged in with a real account.")
+ elif not await user_has_power_level(room_id, self.az.intent, user, "bridge"):
+ return self.get_error_response(403, "not_enough_permissions",
+ "You do not have the permissions to bridge that room.")
+
+ try:
+ title, about, _ = await get_initial_state(self.az.intent, room_id)
+ except (MatrixRequestError, IntentError):
+ return self.get_error_response(403, "bot_not_in_room",
+ "The bridge bot is not in the given room.")
+
+ about = data.get("about", about)
+
+ title = data.get("title", title)
+ if len(title) == 0:
+ return self.get_error_response(400, "body_value_invalid", "Title can not be empty.")
+
+ type = data.get("type", "")
+ if type not in ("group", "chat", "supergroup", "channel"):
+ return self.get_error_response(400, "body_value_invalid",
+ "Given chat type is not valid.")
+
+ supergroup = type == "supergroup"
+ type = {
+ "supergroup": "channel",
+ "channel": "channel",
+ "chat": "chat",
+ "group": "chat",
+ }[type]
+
+ portal = Portal(tgid=None, mxid=room_id, title=title, about=about, peer_type=type)
+ try:
+ await portal.create_telegram_chat(user, supergroup=supergroup)
+ except ValueError as e:
+ portal.delete()
+ return self.get_error_response(500, "unknown_error", e.args[0])
+
+ return web.json_response({
+ "chat_id": portal.tgid,
+ })
+
+ async def disconnect_chat(self, request: web.Request) -> web.Response:
+ err = self.check_authorization(request)
+ if err is not None:
+ return err
+
+ portal = Portal.get_by_mxid(request.match_info["mxid"])
+ if not portal or not portal.tgid:
+ return self.get_error_response(404, "portal_not_found",
+ "Room is not a portal.")
+
+ user, err = await self.get_user(request.query.get("user_id", None), expect_logged_in=None,
+ require_puppeting=False, require_user=False)
+ if err is not None:
+ return err
+ elif user and not await user_has_power_level(portal.mxid, self.az.intent, user, "unbridge"):
+ return self.get_error_response(403, "not_enough_permissions",
+ "You do not have the permissions to unbridge that room.")
+
+ delete = request.query.get("delete", "").lower() in ("true", "t", "1", "yes", "y")
+ sync = request.query.get("delete", "").lower() in ("true", "t", "1", "yes", "y")
+
+ coro = portal.cleanup_and_delete() if delete else portal.unbridge()
+ if sync:
+ try:
+ await coro
+ except Exception:
+ self.log.exception("Failed to disconnect chat")
+ return self.get_error_response(500, "exception", "Failed to disconnect chat")
+ else:
+ asyncio.ensure_future(coro, loop=self.loop)
+ return web.json_response({}, status=200 if sync else 202)
+
+ async def get_user_info(self, request: web.Request) -> web.Response:
+ data, user, err = await self.get_user_request_info(request, expect_logged_in=None,
+ require_puppeting=False)
+ if err is not None:
+ return err
+
+ user_data = None
+ if await user.is_logged_in():
+ me = await user.client.get_me()
+ await user.update_info(me)
+ user_data = {
+ "id": user.tgid,
+ "username": user.username,
+ "first_name": me.first_name,
+ "last_name": me.last_name,
+ "phone": me.phone,
+ "is_bot": user.is_bot,
+ }
+ return web.json_response({
+ "telegram": user_data,
+ "mxid": user.mxid,
+ "permissions": user.permissions,
+ })
+
+ async def get_chats(self, request: web.Request) -> web.Response:
+ data, user, err = await self.get_user_request_info(request, expect_logged_in=True)
+ if err is not None:
+ return err
+
+ if not user.is_bot:
+ chats = await user.get_dialogs()
+ return web.json_response([{
+ "id": get_peer_id(chat),
+ "title": chat.title,
+ } for chat in chats])
+ else:
+ return web.json_response([{
+ "id": get_peer_id(chat.peer),
+ "title": chat.title,
+ } for chat in user.portals.values() if chat.tgid])
+
+ async def send_bot_token(self, request: web.Request) -> web.Response:
+ data, user, err = await self.get_user_request_info(request)
+ if err is not None:
+ return err
+ return await self.post_login_token(user, data.get("token", ""))
+
+ async def request_code(self, request: web.Request) -> web.Response:
+ data, user, err = await self.get_user_request_info(request)
+ if err is not None:
+ return err
+ return await self.post_login_phone(user, data.get("phone", ""))
+
+ async def send_code(self, request: web.Request) -> web.Response:
+ data, user, err = await self.get_user_request_info(request)
+ if err is not None:
+ return err
+ return await self.post_login_code(user, data.get("code", 0), password_in_data=False)
+
+ async def send_password(self, request: web.Request) -> web.Response:
+ data, user, err = await self.get_user_request_info(request)
+ if err is not None:
+ return err
+ return await self.post_login_password(user, data.get("password", ""))
+
+ async def logout(self, request: web.Request) -> web.Response:
+ _, user, err = await self.get_user_request_info(request, expect_logged_in=True,
+ require_puppeting=False,
+ want_data=False)
+ if err is not None:
+ return err
+ await user.log_out()
+
+ @staticmethod
+ async def error_middleware(_, handler) -> Callable[[web.Request], Awaitable[web.Response]]:
+ async def middleware_handler(request: web.Request) -> web.Response:
+ try:
+ return await handler(request)
+ except web.HTTPException as ex:
+ return web.json_response({
+ "error": f"Unhandled HTTP {ex.status}",
+ "errcode": f"unhandled_http_{ex.status}",
+ }, status=ex.status)
+
+ return middleware_handler
+
+ @staticmethod
+ def get_error_response(status=200, errcode="", error="") -> web.Response:
+ return web.json_response({
+ "error": error,
+ "errcode": errcode,
+ }, status=status)
+
+ def get_login_response(self, status=200, state="", username="", mxid="", message="", error="",
+ errcode="") -> web.Response:
+ if username:
+ resp = {
+ "state": "logged-in",
+ "username": username,
+ }
+ elif message:
+ resp = {
+ "state": state,
+ "message": message,
+ }
+ else:
+ resp = {
+ "error": error,
+ "errcode": errcode,
+ }
+ if state:
+ resp["state"] = state
+ return web.json_response(resp, status=status)
+
+ def check_authorization(self, request: web.Request) -> Optional[web.Response]:
+ auth = request.headers.get("Authorization", "")
+ if auth != f"Bearer {self.secret}":
+ return self.get_error_response(error="Shared secret is not valid.",
+ errcode="shared_secret_invalid",
+ status=401)
+ return None
+
+ @staticmethod
+ async def get_data(request: web.Request) -> Optional[dict]:
+ try:
+ return await request.json()
+ except json.JSONDecodeError:
+ return None
+
+ async def get_user(self, mxid: str, expect_logged_in: Optional[bool] = False,
+ require_puppeting: bool = True, require_user: bool = True
+ ) -> Tuple[Optional[User], Optional[web.Response]]:
+ if not mxid:
+ if not require_user:
+ return None, None
+ return None, self.get_login_response(error="User ID not given.",
+ errcode="mxid_empty", status=400)
+
+ user = await User.get_by_mxid(mxid).ensure_started(even_if_no_session=True)
+ if require_puppeting and not user.puppet_whitelisted:
+ return user, self.get_login_response(error="You are not whitelisted.",
+ errcode="mxid_not_whitelisted", status=403)
+ if expect_logged_in is not None:
+ logged_in = await user.is_logged_in()
+ if not expect_logged_in and logged_in:
+ return user, self.get_login_response(username=user.username, status=409,
+ error="You are already logged in.",
+ errcode="already_logged_in")
+ elif expect_logged_in and not logged_in:
+ return user, self.get_login_response(status=403, error="You are not logged in.",
+ errcode="not_logged_in")
+ return user, None
+
+ async def get_user_request_info(self, request: web.Request,
+ expect_logged_in: Optional[bool] = False,
+ require_puppeting: bool = False,
+ want_data: bool = True,
+ ) -> (Tuple[Optional[dict],
+ Optional[User],
+ Optional[web.Response]]):
+ err = self.check_authorization(request)
+ if err is not None:
+ return err
+
+ data = None
+ if want_data and (request.method == "POST" or request.method == "PUT"):
+ data = await self.get_data(request)
+ if not data:
+ return None, None, self.get_login_response(error="Invalid JSON.",
+ errcode="json_invalid", status=400)
+
+ mxid = request.match_info["mxid"]
+ user, err = await self.get_user(mxid, expect_logged_in, require_puppeting)
+
+ return data, user, err
diff --git a/mautrix_telegram/web/provisioning/spec.yaml b/mautrix_telegram/web/provisioning/spec.yaml
new file mode 100644
index 00000000..4c1c44c4
--- /dev/null
+++ b/mautrix_telegram/web/provisioning/spec.yaml
@@ -0,0 +1,844 @@
+swagger: "2.0"
+
+info:
+ title: Mautrix-Telegram provisioning
+ version: 0.3.0
+ description: The provisioning API for Mautrix-Telegram, the Matrix-Telegram puppeting/relaybot bridge.
+ license:
+ name: AGPLv3
+ url: https://github.com/tulir/mautrix-telegram/blob/master/LICENSE
+
+externalDocs:
+ description: Provisioning API wiki page on GitHub
+ url: https://github.com/tulir/mautrix-telegram/wiki/Provisioning-API
+
+basePath: /_matrix/provision/v1
+
+schemes: [https]
+consumes: [application/json]
+produces: [application/json]
+
+tags:
+- name: User info
+- name: Authentication
+- name: Bridging
+
+paths:
+ /portal/{room_id}:
+ get:
+ operationId: get_portal
+ summary: Get the bridging status and info of the connected Telegram chat
+ tags: [Bridging]
+ responses:
+ 200:
+ description: Room is bridged
+ schema:
+ $ref: "#/definitions/PortalInfo"
+ 400:
+ $ref: "#/responses/BadRequest"
+ 404:
+ description: Unknown portal
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ enum:
+ - portal_not_found
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ parameters:
+ - name: room_id
+ in: path
+ description: The Matrix ID of the room whose bridging status to get
+ required: true
+ type: string
+ pattern: "![^/]+"
+ /portal/{chat_id}:
+ get:
+ operationId: get_portal_by_tgid
+ summary: Get the bridging status and info of the connected Telegram chat
+ tags: [Bridging]
+ responses:
+ 200:
+ description: Chat is bridged
+ schema:
+ $ref: "#/definitions/PortalInfo"
+ 400:
+ description: Invalid Telegram chat ID
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ enum:
+ - tgid_invalid
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ 404:
+ description: Unknown portal
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ enum:
+ - portal_not_found
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ parameters:
+ - name: chat_id
+ in: path
+ description: The Matrix ID of the room whose bridging status to get
+ required: true
+ type: integer
+ pattern: "-[0-9]+"
+ /portal/{room_id}/connect/{chat_id}:
+ post:
+ operationId: connect_portal
+ summary: Connect an existing Telegram chat to the given room
+ tags: [Bridging]
+ parameters:
+ - name: room_id
+ in: path
+ description: The Matrix ID of the room to which the Telegram chat should be connected
+ required: true
+ type: string
+ - name: chat_id
+ in: path
+ description: The ID of the Telegram chat to connect
+ required: true
+ type: integer
+ pattern: "-[0-9]+"
+ - name: force
+ in: query
+ description: Set to force bridging by unbridging or deleting existing portal rooms.
+ required: false
+ type: string
+ enum:
+ - delete
+ - unbridge
+ - name: user_id
+ in: query
+ description: Optional Matrix user ID to check if the user has permissions to do the bridging.
+ required: false
+ type: string
+ responses:
+ 400:
+ $ref: "#/responses/BadRequest"
+ 401:
+ $ref: "#/responses/PermissionError"
+ 409:
+ description: Matrix room or Telegram chat is already bridged
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ example: _already_bridged
+ enum:
+ - room_already_bridged
+ - chat_already_bridged
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ /portal/{room_id}/create:
+ post:
+ operationId: create_portal
+ summary: Create a new Telegram chat for the given room
+ tags: [Bridging]
+ responses:
+ 200:
+ description: Telegram chat created
+ schema:
+ type: object
+ properties:
+ chat_id:
+ type: integer
+ 400:
+ $ref: "#/responses/BadRequest"
+ 401:
+ $ref: "#/responses/PermissionError"
+ 403:
+ description: "Given user isn't logged in with a real account or doesn't have permission to bridge the room, or the bridge bot is not in the room"
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ enum:
+ - not_logged_in_real_account
+ - not_enough_permissions
+ - bot_not_in_room
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ 409:
+ description: Room is already bridged
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ enum:
+ - room_already_bridged
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ parameters:
+ - name: room_id
+ in: path
+ description: The Matrix ID of the room whose bridging status to get
+ required: true
+ type: string
+ - name: body
+ in: body
+ required: true
+ schema:
+ type: object
+ required: [type]
+ properties:
+ type:
+ description: The type of chat to create
+ type: string
+ example: supergroup
+ enum:
+ - chat
+ - supergroup
+ - channel
+ title:
+ description: Title for the new chat
+ type: string
+ example: Mautrix-Telegram Bridge
+ about:
+ description: About text for the new chat
+ type: string
+ example: Discussion about mautrix-telegram
+ - name: user_id
+ in: query
+ description: Matrix user to create the chat as.
+ required: true
+ type: string
+ /portal/{room_id}/disconnect:
+ post:
+ operationId: disconnect_portal
+ summary: Disconnect the Telegram chat from the room
+ tags: [Bridging]
+ responses:
+ 202:
+ description: Room unbridging initiated
+ 400:
+ $ref: "#/responses/BadRequest"
+ 401:
+ $ref: "#/responses/PermissionError"
+ 404:
+ description: Unknown portal
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ enum:
+ - portal_not_found
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ parameters:
+ - name: room_id
+ in: path
+ description: The Matrix ID of the room whose bridging status to get
+ required: true
+ type: string
+ - name: user_id
+ in: query
+ description: Optional Matrix user ID to check if the user has permissions to do the bridging.
+ required: false
+ type: string
+ - name: delete
+ in: query
+ description: Whether or not to delete the room completely (kick all users instead of just Telegram puppets)
+ required: false
+ type: boolean
+ default: false
+ - name: sync
+ in: query
+ description: Whether or not to wait for the unbridging to be completed before responding. **Could cause timeouts in large rooms**
+ required: false
+ type: boolean
+ default: false
+
+ /user/{user_id}:
+ get:
+ operationId: get_me
+ summary: Get the info of the Telegram user the given Matrix user is logged in as
+ tags: [User info]
+ responses:
+ 200:
+ description: User found
+ schema:
+ $ref: "#/definitions/UserInfo"
+ 400:
+ $ref: "#/responses/BadRequest"
+ 403:
+ $ref: "#/responses/NotWhitelistedError"
+ 500:
+ $ref: "#/responses/UnknownError"
+ parameters:
+ - name: user_id
+ in: path
+ description: The Matrix ID of the user who to log in as
+ required: true
+ type: string
+ /user/{user_id}/chats:
+ get:
+ operationId: get_chats
+ summary: Get the list of Telegram chats the given Matrix user has access to
+ tags: [User info]
+ responses:
+ 200:
+ description: User is logged in
+ schema:
+ $ref: "#/definitions/UserChats"
+ 400:
+ $ref: "#/responses/BadRequest"
+ 403:
+ description: User is not logged in or not whitelisted
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ enum:
+ - not_logged_in
+ - mxid_not_whitelisted
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ 500:
+ $ref: "#/responses/UnknownError"
+ parameters:
+ - name: user_id
+ in: path
+ description: The Matrix ID of the user who to log in as
+ required: true
+ type: string
+
+ /user/{user_id}/login/bot_token:
+ post:
+ operationId: post_bot_token
+ summary: Log in with a bot token
+ tags: [Authentication]
+ responses:
+ 200:
+ description: Login successful
+ schema:
+ $ref: "#/definitions/AuthSuccess"
+ 400:
+ $ref: "#/responses/BadRequest"
+ 401:
+ description: Invalid or expired bot token or invalid shared secret
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ example: bot_token_
+ enum:
+ - bot_token_invalid
+ - bot_token_expired
+ - shared_secret_invalid
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ 403:
+ $ref: "#/responses/NotWhitelistedError"
+ 409:
+ $ref: "#/responses/AlreadyLoggedInError"
+ 500:
+ $ref: "#/responses/UnknownError"
+ parameters:
+ - name: user_id
+ in: path
+ description: The Matrix ID of the user who to log in as
+ required: true
+ type: string
+ - name: body
+ in: body
+ required: true
+ schema:
+ type: object
+ properties:
+ token:
+ type: string
+ description: The access token of the bot to log in as
+ example: "297900271:IXjeGEcAN61zHnjPgkWnYWyvVp9K4ulHBEv"
+ /user/{user_id}/login/request_code:
+ post:
+ operationId: post_login_phone
+ summary: Request a phone code from Telegram
+ tags: [Authentication]
+ responses:
+ 200:
+ description: Code requested successfully
+ schema:
+ $ref: "#/definitions/AuthSuccess"
+ 400:
+ description: Invalid phone number or JSON
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ example: machine_readable_error
+ enum:
+ - phone_number_invalid
+ - json_invalid
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ 401:
+ description: Invalid shared secret
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ enum:
+ - shared_secret_invalid
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ 403:
+ description: Matrix ID is not whitelisted or phone number is banned or has forbidden 3rd party apps
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ example: machine_readable_error
+ enum:
+ - mxid_not_whitelisted
+ - phone_number_banned
+ - phone_number_app_signup_forbidden
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ 404:
+ description: Unregistered phone number
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ enum:
+ - phone_number_unoccupied
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ 409:
+ $ref: "#/responses/AlreadyLoggedInError"
+ 429:
+ description: Phone number has been temporarily blocked for flooding
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ enum:
+ - flood_wait
+ - phone_number_flood
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ 500:
+ $ref: "#/responses/UnknownError"
+ parameters:
+ - name: user_id
+ in: path
+ description: The Matrix ID of the user who to log in as
+ required: true
+ type: string
+ - name: body
+ in: body
+ required: true
+ schema:
+ type: object
+ properties:
+ phone:
+ type: string
+ description: The phone number to log in as.
+ example: "+123456789"
+ /user/{user_id}/login/send_code:
+ post:
+ operationId: post_login_code
+ summary: Send the login code
+ tags: [Authentication]
+ responses:
+ 200:
+ description: Login successful
+ schema:
+ $ref: "#/definitions/AuthSuccess"
+ 202:
+ description: Correct code, but two-factor authentication is enabled
+ schema:
+ $ref: "#/definitions/AuthSuccess"
+ 400:
+ $ref: "#/responses/BadRequest"
+ 401:
+ description: Invalid phone code or shared secret
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ enum:
+ - phone_code_invalid
+ - shared_secret_invalid
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ 403:
+ description: Matrix ID not whitelisted or phone code expired
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ example: machine_readable_error
+ enum:
+ - mxid_not_whitelisted
+ - phone_code_expired
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ 409:
+ $ref: "#/responses/AlreadyLoggedInError"
+ 500:
+ $ref: "#/responses/UnknownError"
+ parameters:
+ - name: user_id
+ in: path
+ description: The Matrix ID of the user who to log in as
+ required: true
+ type: string
+ - name: body
+ in: body
+ required: true
+ schema:
+ type: object
+ properties:
+ code:
+ type: integer
+ description: The phone code from Telegram.
+ format: int32
+ example: 123456
+ /user/{user_id}/login/send_password:
+ post:
+ operationId: post_login_password
+ summary: Send the two-factor auth password
+ tags: [Authentication]
+ responses:
+ 200:
+ description: Login successful
+ schema:
+ $ref: "#/definitions/AuthSuccess"
+ 400:
+ description: Missing password or invalid JSON
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ example: _empty
+ enum:
+ - password_empty
+ - json_invalid
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ 401:
+ description: Incorrect password or invalid shared secret
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ enum:
+ - password_invalid
+ - shared_secret_invalid
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ 403:
+ $ref: "#/responses/NotWhitelistedError"
+ 409:
+ $ref: "#/responses/AlreadyLoggedInError"
+ 500:
+ $ref: "#/responses/UnknownError"
+ parameters:
+ - name: user_id
+ in: path
+ description: The Matrix ID of the user who to log in as
+ required: true
+ type: string
+ - name: body
+ in: body
+ required: true
+ schema:
+ type: object
+ properties:
+ password:
+ type: string
+ description: The two-factor auth password
+ format: password
+ example: hunter2
+ /user/{user_id}/logout:
+ post:
+ operationId: logout
+ summary: Log out
+ tags: [Authentication]
+ responses:
+ 200:
+ description: Logout successful
+ 403:
+ description: User was not logged in
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ enum:
+ - not_logged_in
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ 500:
+ $ref: "#/responses/UnknownError"
+ parameters:
+ - name: user_id
+ in: path
+ description: The Matrix ID of the user who to log out as
+ required: true
+ type: string
+
+responses:
+ NotWhitelistedError:
+ description: Matrix ID not whitelisted for puppeting
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ enum:
+ - mxid_not_whitelisted
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ AlreadyLoggedInError:
+ description: The Matrix user is already logged in
+ schema:
+ type: object
+ properties:
+ state:
+ type: string
+ enum:
+ - logged-in
+ username:
+ type: string
+ description: The Telegram username the user is logged in as.
+ BadRequest:
+ description: Invalid JSON.
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ enum:
+ - json_invalid
+ - mxid_empty
+ - body_value_missing
+ - body_value_invalid
+ error:
+ $ref: "#/definitions/HumanReadableError"
+ UnknownError:
+ description: Unknown error
+ schema:
+ type: object
+ title: UnknownError
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ enum:
+ - unknown_error
+ - unhandled_error
+ error:
+ type: string
+ title: Error
+ description: A human-readable description of the error
+ example: Internal server error while .
+ PermissionError:
+ description: The given Matrix user doesn't have the permissions to do that.
+ schema:
+ type: object
+ title: Error
+ properties:
+ errcode:
+ type: string
+ title: Error code
+ description: A machine-readable error code
+ example: not_enough_permissions
+ enum:
+ - not_enough_permissions
+ error:
+ $ref: "#/definitions/HumanReadableError"
+
+definitions:
+ UserInfo:
+ type: object
+ properties:
+ mxid:
+ type: string
+ example: "@usern:example.com"
+ permissions:
+ type: string
+ example: user
+ enum:
+ - none
+ - relaybot
+ - user
+ - full
+ - admin
+ telegram:
+ type: object
+ properties:
+ id:
+ type: integer
+ example: 123456789
+ username:
+ type: string
+ example: username
+ first_name:
+ type: string
+ example: Usern
+ last_name:
+ type: string
+ example: A.
+ phone:
+ type: string
+ example: +123456789
+ is_bot:
+ type: boolean
+ example: false
+ UserChats:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: integer
+ example: -123456789
+ description: A bot API style chat ID.
+ title:
+ type: string
+
+ PortalInfo:
+ type: object
+ properties:
+ mxid:
+ type: string
+ example: "!foo:example.com"
+ chat_id:
+ type: integer
+ example: -100123456789
+ peer_type:
+ type: string
+ enum:
+ - user
+ - chat
+ - channel
+ megagroup:
+ type: boolean
+ username:
+ type: string
+ title:
+ type: string
+ about:
+ type: string
+
+ AuthSuccess:
+ type: object
+ properties:
+ state:
+ type: string
+ description: The state/next step after the successful operation.
+ enum:
+ - code
+ - request
+ - password
+ - token
+ - logged-in
+ username:
+ type: string
+ description: The Telegram username the user is logged in as. Only applicable if state=logged-in
+
+ HumanReadableError:
+ type: string
+ description: A human-readable description of the error
+ example: A human-readable description of the error
+
+security:
+ - Bearer: []
+securityDefinitions:
+ Bearer:
+ description: Required authentication for all endpoints
+ name: Authorization
+ in: header
+ type: apiKey
diff --git a/mautrix_telegram/web/public/__init__.py b/mautrix_telegram/web/public/__init__.py
new file mode 100644
index 00000000..fb5f6de7
--- /dev/null
+++ b/mautrix_telegram/web/public/__init__.py
@@ -0,0 +1,114 @@
+# -*- 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 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 aiohttp import web
+from mako.template import Template
+import pkg_resources
+import logging
+import random
+import string
+import time
+
+from ...util import sign_token, verify_token
+from ...user import User
+from ..common import AuthAPI
+
+
+class PublicBridgeWebsite(AuthAPI):
+ log = logging.getLogger("mau.web.public")
+
+ def __init__(self, loop):
+ super().__init__(loop)
+ self.secret_key = "".join(
+ random.choice(string.ascii_lowercase + string.digits) for _ in range(64))
+
+ self.login = Template(
+ pkg_resources.resource_string("mautrix_telegram", "web/public/login.html.mako"))
+
+ self.app = web.Application(loop=loop)
+ self.app.router.add_route("GET", "/login", self.get_login)
+ self.app.router.add_route("POST", "/login", self.post_login)
+ self.app.router.add_static("/", pkg_resources.resource_filename("mautrix_telegram",
+ "web/public/"))
+
+ def make_token(self, mxid, expires_in=900):
+ return sign_token(self.secret_key, {
+ "mxid": mxid,
+ "expiry": int(time.time()) + expires_in,
+ })
+
+ def verify_token(self, token):
+ token = verify_token(self.secret_key, token)
+ if token and token.get("expiry", 0) > int(time.time()):
+ return token.get("mxid", None)
+ return None
+
+ async def get_login(self, request):
+ state = "bot_token" if request.rel_url.query.get("mode", "") == "bot" else "request"
+
+ mxid = self.verify_token(request.rel_url.query.get("token", None))
+ if not mxid:
+ return self.get_login_response(status=401, state="invalid-token")
+ user = User.get_by_mxid(mxid, create=False) if mxid else None
+
+ if not user:
+ return self.get_login_response(mxid=mxid, state=state)
+ elif not user.puppet_whitelisted:
+ return self.get_login_response(mxid=user.mxid, error="You are not whitelisted.",
+ status=403)
+ await user.ensure_started()
+ if not await user.is_logged_in():
+ return self.get_login_response(mxid=user.mxid, state=state)
+
+ return self.get_login_response(mxid=user.mxid, username=user.username)
+
+ def get_login_response(self, status=200, state="", username="", mxid="", message="", error="",
+ errcode=""):
+ return web.Response(status=status, content_type="text/html",
+ text=self.login.render(username=username, state=state, error=error,
+ message=message, mxid=mxid))
+
+ async def post_login(self, request):
+ mxid = self.verify_token(request.rel_url.query.get("token", None))
+ if not mxid:
+ return self.get_login_response(status=401, state="invalid-token")
+
+ data = await request.post()
+
+ user = await User.get_by_mxid(mxid).ensure_started(even_if_no_session=True)
+ if not user.puppet_whitelisted:
+ return self.get_login_response(mxid=user.mxid, error="You are not whitelisted.",
+ status=403)
+ elif await user.is_logged_in():
+ return self.get_login_response(mxid=user.mxid, username=user.username)
+
+ await user.ensure_started(even_if_no_session=True)
+
+ if "phone" in data:
+ return await self.post_login_phone(user, data["phone"])
+ elif "bot_token" in data:
+ return await self.post_login_token(user, data["bot_token"])
+ elif "code" in data:
+ resp = await self.post_login_code(user, data["code"],
+ password_in_data="password" in data)
+ if resp or "password" not in data:
+ return resp
+ elif "password" not in data:
+ return self.get_login_response(error="No data given.", status=400)
+
+ if "password" in data:
+ return await self.post_login_password(user, data["password"])
+ return self.get_login_response(error="This should never happen.", status=500)
diff --git a/mautrix_telegram/public/favicon.png b/mautrix_telegram/web/public/favicon.png
similarity index 100%
rename from mautrix_telegram/public/favicon.png
rename to mautrix_telegram/web/public/favicon.png
diff --git a/mautrix_telegram/public/login.css b/mautrix_telegram/web/public/login.css
similarity index 100%
rename from mautrix_telegram/public/login.css
rename to mautrix_telegram/web/public/login.css
diff --git a/mautrix_telegram/public/login.html.mako b/mautrix_telegram/web/public/login.html.mako
similarity index 92%
rename from mautrix_telegram/public/login.html.mako
rename to mautrix_telegram/web/public/login.html.mako
index 8c03cbdc..f00b6a69 100644
--- a/mautrix_telegram/public/login.html.mako
+++ b/mautrix_telegram/web/public/login.html.mako
@@ -76,6 +76,9 @@ along with this program. If not, see .
management command first.
% endif
+ % elif state == "invalid-token":
+ Invalid or expired token
+ Please ask the bridge bot for a new login link.
% else:
Log in to Telegram
% if error:
@@ -87,8 +90,7 @@ along with this program. If not, see .