diff --git a/mautrix_telegram/__main__.py b/mautrix_telegram/__main__.py index 1969437b..bd31ab9d 100644 --- a/mautrix_telegram/__main__.py +++ b/mautrix_telegram/__main__.py @@ -93,7 +93,7 @@ if config["appservice.public.enabled"]: appserv.app.add_subapp(config["appservice.public.prefix"] or "/public", public_website.app) if config["appservice.provisioning.enabled"]: - provisioning_api = ProvisioningAPI(config, loop) + provisioning_api = ProvisioningAPI(config, appserv, loop) appserv.app.add_subapp(config["appservice.provisioning.prefix"] or "/_matrix/provisioning", provisioning_api.app) diff --git a/mautrix_telegram/commands/portal.py b/mautrix_telegram/commands/portal.py index c87ade32..e9e29547 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 @@ -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/user.py b/mautrix_telegram/user.py index 1163e503..ea0b92e3 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -310,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/web/common/auth_api.py b/mautrix_telegram/web/common/auth_api.py index 2ea50bca..14d29963 100644 --- a/mautrix_telegram/web/common/auth_api.py +++ b/mautrix_telegram/web/common/auth_api.py @@ -21,8 +21,8 @@ import logging from telethon.errors import * -from mautrix_telegram.commands.auth import enter_password -from mautrix_telegram.util import format_duration +from ...commands.auth import enter_password +from ...util import format_duration class AuthAPI(abc.ABC): @@ -70,7 +70,7 @@ class AuthAPI(abc.ABC): except Exception: self.log.exception("Error requesting phone code") return self.get_login_response(mxid=user.mxid, state="request", status=500, - errcode="exception", + errcode="unknown_error", error="Internal server error while requesting code.") async def post_login_token(self, user, token): @@ -124,7 +124,7 @@ class AuthAPI(abc.ABC): except Exception: self.log.exception("Error sending phone code") return self.get_login_response(mxid=user.mxid, state="code", status=500, - errcode="exception", + errcode="unknown_error", error="Internal server error while sending code.") async def post_login_password(self, user, password): @@ -146,5 +146,5 @@ class AuthAPI(abc.ABC): except Exception: self.log.exception("Error sending password") return self.get_login_response(mxid=user.mxid, state="password", status=500, - errcode="exception", + 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 index e3fa8347..dc640c93 100644 --- a/mautrix_telegram/web/provisioning/__init__.py +++ b/mautrix_telegram/web/provisioning/__init__.py @@ -16,32 +16,37 @@ # 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, loop): + 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) + 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) @@ -88,6 +93,69 @@ class ProvisioningAPI(AuthAPI): "megagroup": portal.megagroup, }) + async def connect_chat(self, request: web.Request) -> web.Response: + return web.Response(status=501) + + async def create_chat(self, request: web.Request) -> web.Response: + 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: + return web.Response(status=501) + 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) @@ -187,10 +255,11 @@ class ProvisioningAPI(AuthAPI): } else: resp = { - "state": state, "error": error, "errcode": errcode, } + if state: + resp["state"] = state return web.json_response(resp, status=status) def check_authorization(self, request: web.Request) -> bool: @@ -206,6 +275,10 @@ class ProvisioningAPI(AuthAPI): async def get_user(self, mxid: str, expect_logged_in: Optional[bool] = False, require_puppeting: bool = True, ) -> Tuple[Optional[User], Optional[web.Response]]: + if not mxid: + 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.", diff --git a/mautrix_telegram/web/provisioning/spec.yaml b/mautrix_telegram/web/provisioning/spec.yaml index 775821b9..b107d47c 100644 --- a/mautrix_telegram/web/provisioning/spec.yaml +++ b/mautrix_telegram/web/provisioning/spec.yaml @@ -35,7 +35,7 @@ paths: schema: $ref: "#/definitions/PortalInfo" 400: - $ref: "#/responses/MissingMXIDError" + $ref: "#/responses/BadRequest" 404: description: Unknown portal schema: @@ -127,9 +127,16 @@ paths: 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/MissingMXIDError" + $ref: "#/responses/BadRequest" + 401: + $ref: "#/responses/PermissionError" 409: description: Matrix room or Telegram chat is already bridged schema: @@ -152,8 +159,33 @@ paths: 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/MissingMXIDError" + $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: @@ -174,6 +206,34 @@ paths: 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 @@ -183,7 +243,9 @@ paths: 202: description: Room unbridging initiated 400: - $ref: "#/responses/MissingMXIDError" + $ref: "#/responses/BadRequest" + 401: + $ref: "#/responses/PermissionError" 404: description: Unknown portal schema: @@ -204,6 +266,11 @@ paths: 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 /user/{user_id}: get: @@ -216,7 +283,7 @@ paths: schema: $ref: "#/definitions/UserInfo" 400: - $ref: "#/responses/MissingMXIDError" + $ref: "#/responses/BadRequest" 403: $ref: "#/responses/NotWhitelistedError" 500: @@ -238,7 +305,7 @@ paths: schema: $ref: "#/definitions/UserChats" 400: - $ref: "#/responses/MissingMXIDError" + $ref: "#/responses/BadRequest" 403: description: User is not logged in or not whitelisted schema: @@ -274,7 +341,7 @@ paths: schema: $ref: "#/definitions/AuthSuccess" 400: - $ref: "#/responses/MissingMXIDError" + $ref: "#/responses/BadRequest" 401: description: Invalid or expired bot token or invalid shared secret schema: @@ -325,7 +392,7 @@ paths: schema: $ref: "#/definitions/AuthSuccess" 400: - description: Invalid phone number or JSON or missing Matrix ID + description: Invalid phone number or JSON schema: type: object title: Error @@ -337,7 +404,6 @@ paths: example: machine_readable_error enum: - phone_number_invalid - - mxid_empty - json_invalid error: $ref: "#/definitions/HumanReadableError" @@ -436,7 +502,7 @@ paths: schema: $ref: "#/definitions/AuthSuccess" 400: - $ref: "#/responses/MissingMXIDError" + $ref: "#/responses/BadRequest" 401: description: Invalid phone code or shared secret schema: @@ -500,7 +566,7 @@ paths: schema: $ref: "#/definitions/AuthSuccess" 400: - description: Missing password or Matrix ID or invalid JSON + description: Missing password or invalid JSON schema: type: object title: Error @@ -512,7 +578,6 @@ paths: example: _empty enum: - password_empty - - mxid_empty - json_invalid error: $ref: "#/definitions/HumanReadableError" @@ -582,8 +647,8 @@ responses: username: type: string description: The Telegram username the user is logged in as. - MissingMXIDError: - description: Missing Matrix ID or invalid JSON. + BadRequest: + description: Invalid JSON. schema: type: object title: Error @@ -593,8 +658,10 @@ responses: title: Error code description: A machine-readable error code enum: - - mxid_empty - json_invalid + - mxid_empty + - body_value_missing + - body_value_invalid error: $ref: "#/definitions/HumanReadableError" UnknownError: @@ -608,23 +675,30 @@ responses: title: Error code description: A machine-readable error code enum: - - exception + - 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: - - Internal server error while requesting code. - - Internal server error while sending code. - - Internal server error while sending password. - - Internal server error while sending token. + - not_enough_permissions + error: + $ref: "#/definitions/HumanReadableError" definitions: - HumanReadableError: - type: string - description: A human-readable description of the error - example: A human-readable description of the error UserInfo: type: object properties: @@ -713,6 +787,10 @@ definitions: 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: []