From 2b5426fda394877f80e08d3442f4f34337d9e8f4 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 14 Jul 2018 18:57:46 +0300 Subject: [PATCH] Add portal info and user chat list endpoints --- mautrix_telegram/abstract_user.py | 4 +- mautrix_telegram/user.py | 15 +- mautrix_telegram/web/provisioning/__init__.py | 207 +++++++++++----- mautrix_telegram/web/provisioning/spec.yaml | 228 ++++++++++++++++-- 4 files changed, 362 insertions(+), 92 deletions(-) diff --git a/mautrix_telegram/abstract_user.py b/mautrix_telegram/abstract_user.py index d15e9e66..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)) diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index da62fad8..917cbf73 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,18 +39,18 @@ 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, @@ -255,7 +256,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( diff --git a/mautrix_telegram/web/provisioning/__init__.py b/mautrix_telegram/web/provisioning/__init__.py index a792720b..becee3a2 100644 --- a/mautrix_telegram/web/provisioning/__init__.py +++ b/mautrix_telegram/web/provisioning/__init__.py @@ -15,10 +15,14 @@ # 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 logging import json +from telethon.utils import get_peer_id + from ...user import User +from ...portal import Portal from ..common import AuthAPI @@ -29,17 +33,117 @@ class ProvisioningAPI(AuthAPI): super().__init__(loop) self.secret = config["appservice.provisioning.shared_secret"] - self.app = web.Application(loop=loop) + self.app = web.Application(loop=loop, middlewares=[self.error_middleware]) - auth_prefix = "/auth/{mxid:@[^:]*:.+}" - self.app.router.add_route("GET", f"{auth_prefix}/get_me", self.get_me) - self.app.router.add_route("POST", f"{auth_prefix}/send_bot_token", self.send_bot_token) - self.app.router.add_route("POST", f"{auth_prefix}/request_code", self.request_code) - self.app.router.add_route("POST", f"{auth_prefix}/send_code", self.send_code) - self.app.router.add_route("POST", f"{auth_prefix}/send_password", self.send_password) + portal_prefix = "/portal/{mxid:![^/]+}" + self.app.router.add_route("GET", f"{portal_prefix}", self.get_portal) + # 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_me) + self.app.router.add_route("GET", f"{user_prefix}/chats", self.get_chats) + + self.app.router.add_route("POST", f"{user_prefix}/send_bot_token", self.send_bot_token) + self.app.router.add_route("POST", f"{user_prefix}/request_code", self.request_code) + self.app.router.add_route("POST", f"{user_prefix}/send_code", self.send_code) + self.app.router.add_route("POST", f"{user_prefix}/send_password", self.send_password) + + async def get_portal(self, request: web.Request) -> web.Response: + mxid = request.match_info["mxid"] + portal = Portal.get_by_mxid(mxid) + if not portal: + return self.get_error_response(404, "room_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_me(self, request: web.Request) -> web.Response: + data, user, err = await self.get_user_request_info(request, require_logged_in=True) + if err is not None: + return err + + me = await user.client.get_me() + return web.json_response({ + "username": me.username, + "first_name": me.first_name, + "last_name": me.last_name, + "phone": me.phone, + "is_bot": me.bot, + }) + + async def get_chats(self, request: web.Request) -> web.Response: + data, user, err = await self.get_user_request_info(request, require_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", "")) + + @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=""): + errcode="") -> web.Response: if username: resp = { "state": "logged-in", @@ -58,7 +162,36 @@ class ProvisioningAPI(AuthAPI): } return web.json_response(resp, status=status) - async def get_request_info(self, request: web.Request, get_data=True, fail_on_logged_in=True): + def check_authorization(self, request: web.Request) -> bool: + return request.headers.get("Authorization", "") == f"Bearer {self.secret}" + + @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, require_logged_in: bool = False + ) -> Tuple[Optional[User], Optional[web.Response]]: + user = await User.get_by_mxid(mxid).ensure_started(even_if_no_session=True) + if not user.puppet_whitelisted: + return user, self.get_login_response(error="You are not whitelisted.", + errcode="mxid_not_whitelisted", status=403) + logged_in = await user.is_logged_in() + if not require_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 require_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, require_logged_in: bool = False + ) -> (Tuple[Optional[dict], + Optional[User], + Optional[web.Response]]): auth = request.headers.get("Authorization", "") if auth != f"Bearer {self.secret}": return None, None, self.get_login_response(error="Shared secret is not valid.", @@ -66,61 +199,13 @@ class ProvisioningAPI(AuthAPI): status=401) data = None - if get_data: - try: - data = await request.json() - except json.JSONDecodeError: - pass + if 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 = await User.get_by_mxid(mxid).ensure_started(even_if_no_session=True) - if not user.puppet_whitelisted: - return None, user, self.get_login_response(error="You are not whitelisted.", - errcode="mxid_not_whitelisted", status=403) - elif fail_on_logged_in and await user.is_logged_in(): - return None, user, self.get_login_response(username=user.username, status=409) - return data, user, None + user, err = await self.get_user(mxid, require_logged_in) - async def get_me(self, request: web.Request): - data, user, err = await self.get_request_info(request, get_data=False, - fail_on_logged_in=False) - if err is not None: - return err - if not await user.is_logged_in(): - return self.get_login_response(status=403, error="You are not logged in.", - errcode="not_logged_in") - me = await user.client.get_me() - return web.json_response({ - "username": me.username, - "first_name": me.first_name, - "last_name": me.last_name, - "phone": me.phone, - "is_bot": me.bot, - }) - - async def send_bot_token(self, request: web.Request): - data, user, err = await self.get_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): - data, user, err = await self.get_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): - data, user, err = await self.get_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): - data, user, err = await self.get_request_info(request) - if err is not None: - return err - return await self.post_login_password(user, data.get("password", "")) + return data, user, err diff --git a/mautrix_telegram/web/provisioning/spec.yaml b/mautrix_telegram/web/provisioning/spec.yaml index c0b5e3a6..d3bd2ba8 100644 --- a/mautrix_telegram/web/provisioning/spec.yaml +++ b/mautrix_telegram/web/provisioning/spec.yaml @@ -4,16 +4,12 @@ info: title: mautrix-telegram provisioning version: 0.3.0 description: The provisioning API for mautrix-telegram. - contact: - name: Tulir Asokan - email: tulir@maunium.net - url: https://maunium.net license: name: AGPLv3 url: https://github.com/tulir/mautrix-telegram/blob/master/LICENSE externalDocs: - description: Provisioning API wiki page on GitHub. + description: Provisioning API wiki page on GitHub url: https://github.com/tulir/mautrix-telegram/wiki/Provisioning-API basePath: /_matrix/provision @@ -23,14 +19,141 @@ consumes: [application/json] produces: [application/json] tags: +- name: User info - name: Authentication +- name: Bridging paths: - /auth/{mxid}/get_me: + /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/MissingMXIDError" + 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 + /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 + format: int32 + responses: + 400: + $ref: "#/responses/MissingMXIDError" + 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" + /portal/{room_id}/create: + post: + operationId: create_portal + summary: Create a new Telegram chat for the given room + tags: [Bridging] + responses: + 400: + $ref: "#/responses/MissingMXIDError" + 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 + /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/MissingMXIDError" + 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 + + /user/{user_id}: get: operationId: get_me summary: Get the info of the Telegram user the given Matrix user is logged in as - tags: [Authentication] + tags: [User info] responses: 200: description: User is logged in @@ -56,22 +179,48 @@ paths: 500: $ref: "#/responses/UnknownError" parameters: - - name: mxid + - 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 + /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/MissingMXIDError" + 403: + description: User is not logged in or not whitelisted schema: type: object + title: Error properties: - token: + errcode: type: string - description: The access token of the bot to log in as - example: "297900271:IXjeGEcAN61zHnjPgkWnYWyvVp9K4ulHBEv" - /auth/{mxid}/send_bot_token: + 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 @@ -107,7 +256,7 @@ paths: 500: $ref: "#/responses/UnknownError" parameters: - - name: mxid + - name: user_id in: path description: The Matrix ID of the user who to log in as required: true @@ -122,7 +271,7 @@ paths: type: string description: The access token of the bot to log in as example: "297900271:IXjeGEcAN61zHnjPgkWnYWyvVp9K4ulHBEv" - /auth/{mxid}/request_code: + /user/{user_id}/login/request_code: post: operationId: post_login_phone summary: Request a phone code from Telegram @@ -214,7 +363,7 @@ paths: 500: $ref: "#/responses/UnknownError" parameters: - - name: mxid + - name: user_id in: path description: The Matrix ID of the user who to log in as required: true @@ -229,7 +378,7 @@ paths: type: string description: The phone number to log in as. example: "+123456789" - /auth/{mxid}/send_code: + /user/{user_id}/login/send_code: post: operationId: post_login_code summary: Send the login code @@ -281,7 +430,7 @@ paths: 500: $ref: "#/responses/UnknownError" parameters: - - name: mxid + - name: user_id in: path description: The Matrix ID of the user who to log in as required: true @@ -297,7 +446,7 @@ paths: description: The phone code from Telegram. format: int32 example: 123456 - /auth/{mxid}/send_password: + /user/{user_id}/login/send_password: post: operationId: post_login_password summary: Send the two-factor auth password @@ -346,7 +495,7 @@ paths: 500: $ref: "#/responses/UnknownError" parameters: - - name: mxid + - name: user_id in: path description: The Matrix ID of the user who to log in as required: true @@ -451,6 +600,41 @@ definitions: 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