Merge pull request #177 from tulir/provisioning-api

Add provisioning API
This commit is contained in:
Tulir Asokan
2018-07-15 15:38:07 +03:00
committed by GitHub
24 changed files with 1654 additions and 240 deletions
+13
View File
@@ -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]
+17 -7
View File
@@ -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)
+3 -3
View File
@@ -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)
+1 -1
View File
@@ -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"
+4 -3
View File
@@ -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],
+17 -13
View File
@@ -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.")
+7 -1
View File
@@ -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 {}
+21 -8
View File
@@ -14,17 +14,30 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
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
+2 -2
View File
@@ -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)
+2 -3
View File
@@ -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)")
+2 -3
View File
@@ -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"]
-184
View File
@@ -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 <https://www.gnu.org/licenses/>.
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)
+13 -8
View File
@@ -14,6 +14,7 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
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:
+1
View File
@@ -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
+53
View File
@@ -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 <https://www.gnu.org/licenses/>.
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
+2
View File
@@ -0,0 +1,2 @@
from .provisioning import ProvisioningAPI
from .public import PublicBridgeWebsite
+1
View File
@@ -0,0 +1 @@
from .auth_api import AuthAPI
+150
View File
@@ -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 <https://www.gnu.org/licenses/>.
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.")
@@ -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 <https://www.gnu.org/licenses/>.
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
+844
View File
@@ -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: <room|chat>_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_<error>
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: <field>_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 <action>.
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
+114
View File
@@ -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 <https://www.gnu.org/licenses/>.
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)

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

@@ -76,6 +76,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
management command first.
</p>
% endif
% elif state == "invalid-token":
<h1>Invalid or expired token</h1>
<div class="error">Please ask the bridge bot for a new login link.</div>
% else:
<h1>Log in to Telegram</h1>
% if error:
@@ -87,8 +90,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<form method="post">
<fieldset>
<label for="mxid">Matrix ID</label>
<input type="text" id="mxid" name="mxid" placeholder="Enter Matrix ID"
value="${mxid}"/>
<input type="text" id="mxid" name="mxid" disabled value="${mxid}"/>
% if state == "request":
<label for="value">Phone number</label>
<input type="tel" id="value" name="phone" placeholder="Enter phone number"/>
@@ -96,9 +98,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<button class="button-clear" type="button" onclick="switchToBotLogin()">
Use bot token
</button>
% elif state == "token":
% elif state == "bot_token":
<label for="value">Bot token</label>
<input type="text" id="value" name="token" placeholder="Enter bot API token"/>
<input type="text" id="value" name="bot_token" placeholder="Enter bot API token"/>
<button type="submit">Sign in</button>
% elif state == "code":
<label for="value">Phone code</label>