From ae99c2d7237021e2abb20d4b41a24e0b73028519 Mon Sep 17 00:00:00 2001 From: clarkzjw Date: Wed, 22 Feb 2023 14:01:16 -0800 Subject: bot: support Mastodon OAuth2 login test callback test mastodon callback test callback clean customwebhook example bot: test oauth login test callback url test callback --- bot.py | 121 +++++++++++++++++++++++++++++- callback.py | 15 ++++ command.py | 13 ++++ config.py | 10 ++- customwebhookexample.py | 193 ------------------------------------------------ mastodon.client_id | 2 + prompt/string.py | 3 +- toot.py | 18 +++-- 8 files changed, 171 insertions(+), 204 deletions(-) delete mode 100644 customwebhookexample.py create mode 100644 mastodon.client_id diff --git a/bot.py b/bot.py index e73fe1b..6198bc4 100644 --- a/bot.py +++ b/bot.py @@ -1,6 +1,26 @@ #!/usr/bin/env python +import asyncio import logging +from dataclasses import dataclass +from http import HTTPStatus +from config import BOT_TOKEN, TELEGRAM_WEBHOOK_URL, HEALTHCHECK_URL, FEDI_LOGIN_CALLBACK_URL, BOT_DOMAIN, BOT_PORT + +import uvicorn +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import PlainTextResponse, Response +from starlette.routing import Route + + +from telegram import Update +from telegram.ext import ( + Application, + CallbackContext, + ContextTypes, + ExtBot, + TypeHandler, +) from telegram.ext import ( Application, @@ -12,6 +32,7 @@ from telegram.ext import ( ) from callback import ( + callback_generate_fedi_login_url, callback_skip_media, callback_location_sharing, callback_manual_location, @@ -24,10 +45,12 @@ from callback import ( ) from command import ( start_command, + fedi_login_command, cancel_command, help_command ) from config import ( + FEDI_LOGIN, WAIT_LOCATION, LOCATION_SEARCH_KEYWORD, LOCATION_CONFIRMATION, @@ -36,21 +59,57 @@ from config import ( BOT_TOKEN ) +# Enable logging logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) logger = logging.getLogger(__name__) -def main() -> None: - application = Application.builder().token(BOT_TOKEN).build() +@dataclass +class FediLoginCallbackUpdate: + code: str + state: int + + +class FediLoginCallbackContext(CallbackContext[ExtBot, dict, dict, dict]): + @classmethod + def from_update( + cls, + update: object, + application: "Application", + ) -> "FediLoginCallbackContext": + if isinstance(update, FediLoginCallbackUpdate): + return cls(application=application, user_id=update.state) + return super().from_update(update, application) + + +async def process_oauth_login_callback(update: FediLoginCallbackUpdate, context: FediLoginCallbackContext) -> None: + combined_payloads = update.code + text = "Login success, your code is: {}".format(combined_payloads) + print(text) + print(update.state) + await context.bot.send_message(chat_id=update.state, text=text) + + +async def main() -> None: + context_types = ContextTypes(context=FediLoginCallbackContext) + # Here we set updater to None because we want our custom webhook server to handle the updates + # and hence we don't need an Updater instance + application = ( + Application.builder().token(BOT_TOKEN).context_types(context_types).build() + ) checkin_handler = ConversationHandler( entry_points=[ CommandHandler("start", start_command), + CommandHandler("login", fedi_login_command), MessageHandler(filters.LOCATION, callback_location_sharing), ], states={ + FEDI_LOGIN: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, callback_generate_fedi_login_url), + ], WAIT_LOCATION: [ MessageHandler(filters.LOCATION, callback_location_sharing), ], @@ -74,10 +133,64 @@ def main() -> None: allow_reentry=True, ) + # register handlers application.add_handler(CommandHandler("help", help_command)) application.add_handler(checkin_handler) - application.run_polling() + application.add_handler(TypeHandler(type=FediLoginCallbackUpdate, callback=process_oauth_login_callback)) + + # Pass webhook settings to telegram + await application.bot.set_webhook(url=f"{BOT_DOMAIN}{TELEGRAM_WEBHOOK_URL}") + + # Set up webserver + async def telegram_webhook(request: Request) -> Response: + """Handle incoming Telegram updates by putting them into the `update_queue`""" + await application.update_queue.put( + Update.de_json(data=await request.json(), bot=application.bot) + ) + return Response() + + async def fedi_oauth_login_callback(request: Request) -> PlainTextResponse: + """ + Handle incoming webhook updates by also putting them into the `update_queue` if + the required parameters were passed correctly. + """ + try: + code = request.query_params["code"] + state = int(request.query_params.get("state")) + except KeyError: + return PlainTextResponse( + status_code=HTTPStatus.BAD_REQUEST, + content="Mastodon callback request doesn't contain a valid OAuth code", + ) + + await application.update_queue.put(FediLoginCallbackUpdate(state=state, code=code)) + return PlainTextResponse("Thank you for login! Now you can close the browser") + + async def healthcheck(_: Request) -> PlainTextResponse: + return PlainTextResponse(content="OK") + + starlette_app = Starlette( + routes=[ + Route(TELEGRAM_WEBHOOK_URL, telegram_webhook, methods=["POST"]), + Route(HEALTHCHECK_URL, healthcheck, methods=["GET"]), + Route(FEDI_LOGIN_CALLBACK_URL, fedi_oauth_login_callback, methods=["POST", "GET"]), + ] + ) + webserver = uvicorn.Server( + config=uvicorn.Config( + app=starlette_app, + port=BOT_PORT, + use_colors=False, + host="127.0.0.1", + ) + ) + + # Run application and webserver together + async with application: + await application.start() + await webserver.serve() + await application.stop() if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/callback.py b/callback.py index 2c69fbd..e7a8308 100644 --- a/callback.py +++ b/callback.py @@ -57,6 +57,21 @@ async def process_media_group(context: CallbackContext): await context.bot.send_message(chat_id=chat_id, text=PROMPT_DONE, reply_markup=MAIN_MENU) +async def callback_generate_fedi_login_url(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + # generate fedi OAuth login url + home_instance = update.effective_message.text + mastodon_client = Mastodon(client_id=MASTODON_CLIENT_ID_FILE, api_base_url=home_instance) + oauth_url = mastodon_client.auth_request_url(redirect_uris="{}{}".format(BOT_DOMAIN, FEDI_LOGIN_CALLBACK_URL), + scopes=['write:media', 'write:statuses'], + state=update.effective_user.id) + + await update.message.reply_text(PROMPT_FEDI_LOGIN, + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Login", url=oauth_url)]]), + parse_mode=ParseMode.MARKDOWN) + + return FEDI_LOGIN + + async def callback_location_sharing(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: if update.message.venue is not None: context.user_data["fsq_id"] = update.message.venue.foursquare_id diff --git a/command.py b/command.py index 72e1820..837d3cd 100644 --- a/command.py +++ b/command.py @@ -4,6 +4,7 @@ from telegram.error import BadRequest from telegram.ext import ContextTypes, ConversationHandler from config import * +from mastodon import Mastodon async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: @@ -13,6 +14,18 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> i return WAIT_LOCATION +async def fedi_login_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + # generate fedi OAuth login url + + # mastodon_client = Mastodon(client_id=MASTODON_CLIENT_ID_FILE, api_base_url=TOOT_API_BASE_URL) + # oauth_url = mastodon_client.auth_request_url(redirect_uris="{}/{}".format(BOT_DOMAIN, FEDI_LOGIN_CALLBACK_URL), + # scopes=['write:media', 'write:statuses']) + # + # await update.message.reply_text(PROMPT_FEDI_LOGIN.format(oauth_url), parse_mode=ParseMode.MARKDOWN) + await update.message.reply_text(PROMPT_FEDI_LOGIN_WHERE_IS_INSTANCE, parse_mode=ParseMode.MARKDOWN) + return FEDI_LOGIN + + async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text(PROMPT_HELP) diff --git a/config.py b/config.py index a412784..7ae0c21 100644 --- a/config.py +++ b/config.py @@ -20,7 +20,7 @@ DEFAULT_TOOT_VISIBILITY = "private" MEDIA_GROUP_TIMEOUT = 3 -WAIT_LOCATION, LOCATION_SEARCH_KEYWORD, LOCATION_CONFIRMATION, ADD_MEDIA, ADD_COMMENT = range(5) +FEDI_LOGIN, WAIT_LOCATION, LOCATION_SEARCH_KEYWORD, LOCATION_CONFIRMATION, ADD_MEDIA, ADD_COMMENT = range(6) MAIN_MENU = ReplyKeyboardMarkup([ [KeyboardButton(text="Check-in here", request_location=True)], @@ -42,3 +42,11 @@ class MsgDict(TypedDict): KEY_TOOT_STATUS_ID = "toot_status_id" KEY_TOOT_STATUS_CONTENT = "toot_status_content" + +MASTODON_CLIENT_ID_FILE = "mastodon.client_id" + +TELEGRAM_WEBHOOK_URL = "/checkinbot/webhook" +HEALTHCHECK_URL = "/checkinbot/healthcheck" +FEDI_LOGIN_CALLBACK_URL = "/checkinbot/fedi_login_callback" +BOT_DOMAIN = "https://zjw.social" +BOT_PORT = 30010 diff --git a/customwebhookexample.py b/customwebhookexample.py deleted file mode 100644 index 328a327..0000000 --- a/customwebhookexample.py +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env python -# This program is dedicated to the public domain under the CC0 license. -# pylint: disable=import-error,wrong-import-position -""" -Simple example of a bot that uses a custom webhook setup and handles custom updates. -For the custom webhook setup, the libraries `starlette` and `uvicorn` are used. Please install -them as `pip install starlette~=0.20.0 uvicorn~=0.17.0`. -Note that any other `asyncio` based web server framework can be used for a custom webhook setup -just as well. - -Usage: -Set bot token, url, admin chat_id and port at the start of the `main` function. -You may also need to change the `listen` value in the uvicorn configuration to match your setup. -Press Ctrl-C on the command line or send a signal to the process to stop the bot. -""" -import asyncio -import html -import logging -from dataclasses import dataclass -from http import HTTPStatus -from config import BOT_TOKEN - -import uvicorn -from starlette.applications import Starlette -from starlette.requests import Request -from starlette.responses import PlainTextResponse, Response -from starlette.routing import Route - -from telegram import __version__ as TG_VER - -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] - -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) - -from telegram import Update -from telegram.constants import ParseMode -from telegram.ext import ( - Application, - CallbackContext, - CommandHandler, - ContextTypes, - ExtBot, - TypeHandler, -) - -# Enable logging -logging.basicConfig( - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO -) -logger = logging.getLogger(__name__) - - -@dataclass -class WebhookUpdate: - """Simple dataclass to wrap a custom update type""" - - user_id: int - payload: str - - -class CustomContext(CallbackContext[ExtBot, dict, dict, dict]): - """ - Custom CallbackContext class that makes `user_data` available for updates of type - `WebhookUpdate`. - """ - - @classmethod - def from_update( - cls, - update: object, - application: "Application", - ) -> "CustomContext": - if isinstance(update, WebhookUpdate): - return cls(application=application, user_id=update.user_id) - return super().from_update(update, application) - - -async def start(update: Update, context: CustomContext) -> None: - """Display a message with instructions on how to use this bot.""" - url = context.bot_data["url"] - payload_url = html.escape(f"{url}/submitpayload?user_id=&payload=") - text = ( - f"To check if the bot is still running, call {url}/healthcheck.\n\n" - f"To post a custom update, call {payload_url}." - ) - await update.message.reply_html(text=text) - - -async def webhook_update(update: WebhookUpdate, context: CustomContext) -> None: - """Callback that handles the custom updates.""" - chat_member = await context.bot.get_chat_member(chat_id=update.user_id, user_id=update.user_id) - payloads = context.user_data.setdefault("payloads", []) - payloads.append(update.payload) - combined_payloads = "\n• ".join(payloads) - text = ( - f"The user {chat_member.user.mention_html()} has sent a new payload. " - f"So far they have sent the following payloads: \n\n• {combined_payloads}" - ) - await context.bot.send_message( - chat_id=context.bot_data["admin_chat_id"], text=text, parse_mode=ParseMode.HTML - ) - - -async def main() -> None: - """Set up the application and a custom webserver.""" - url = "https://jinwei.me" - admin_chat_id = 123456 - port = 8000 - - context_types = ContextTypes(context=CustomContext) - # Here we set updater to None because we want our custom webhook server to handle the updates - # and hence we don't need an Updater instance - application = ( - Application.builder().token(BOT_TOKEN).updater(None).context_types(context_types).build() - ) - # save the values in `bot_data` such that we may easily access them in the callbacks - application.bot_data["url"] = url - application.bot_data["admin_chat_id"] = admin_chat_id - - # register handlers - application.add_handler(CommandHandler("start", start)) - application.add_handler(TypeHandler(type=WebhookUpdate, callback=webhook_update)) - - # Pass webhook settings to telegram - await application.bot.set_webhook(url=f"{url}/telegram") - - # Set up webserver - async def telegram(request: Request) -> Response: - """Handle incoming Telegram updates by putting them into the `update_queue`""" - await application.update_queue.put( - Update.de_json(data=await request.json(), bot=application.bot) - ) - return Response() - - async def custom_updates(request: Request) -> PlainTextResponse: - """ - Handle incoming webhook updates by also putting them into the `update_queue` if - the required parameters were passed correctly. - """ - try: - user_id = int(request.query_params["user_id"]) - payload = request.query_params["payload"] - except KeyError: - return PlainTextResponse( - status_code=HTTPStatus.BAD_REQUEST, - content="Please pass both `user_id` and `payload` as query parameters.", - ) - except ValueError: - return PlainTextResponse( - status_code=HTTPStatus.BAD_REQUEST, - content="The `user_id` must be a string!", - ) - - await application.update_queue.put(WebhookUpdate(user_id=user_id, payload=payload)) - return PlainTextResponse("Thank you for the submission! It's being forwarded.") - - async def health(_: Request) -> PlainTextResponse: - """For the health endpoint, reply with a simple plain text message.""" - return PlainTextResponse(content="The bot is still running fine :)") - - starlette_app = Starlette( - routes=[ - Route("/telegram", telegram, methods=["POST"]), - Route("/healthcheck", health, methods=["GET"]), - Route("/submitpayload", custom_updates, methods=["POST", "GET"]), - ] - ) - webserver = uvicorn.Server( - config=uvicorn.Config( - app=starlette_app, - port=port, - use_colors=False, - host="127.0.0.1", - ) - ) - - # Run application and webserver together - async with application: - await application.start() - await webserver.serve() - await application.stop() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/mastodon.client_id b/mastodon.client_id new file mode 100644 index 0000000..fbddf72 --- /dev/null +++ b/mastodon.client_id @@ -0,0 +1,2 @@ +wQwT0rHSiGbwet58B2QXFd1-vYvgCBQXjEcjt36UPLc +szIuZqxZsS-XJtLQZLv1_YgClUtUywHRBngbeb9zNXA \ No newline at end of file diff --git a/prompt/string.py b/prompt/string.py index 9a5a357..a7bf0d1 100644 --- a/prompt/string.py +++ b/prompt/string.py @@ -4,7 +4,8 @@ PROMPT_START = "Hello, this is `checkin.bot`. \n\n" \ "Aware of privacy concerns, this bot will not store your location data." \ "*Be safe and cautious when sharing your real time location on the web.* \n\n" \ "Start using this bot by sharing your location using Telegram context menu to it." - +PROMPT_FEDI_LOGIN_WHERE_IS_INSTANCE = "Where is your home instance in the Fediverse? (e.g. `https://mastodon.social`)" +PROMPT_FEDI_LOGIN = "Please login to your Fediverse account by clicking the link below:" PROMPT_CHOOSE_ACTION = "Use bot keyboard to choose an action" PROMPT_ADD_COMMENT = "You can continue adding comments, or press skip" PROMPT_ADD_MEDIA = "You can continue adding photos, or press skip" diff --git a/toot.py b/toot.py index e1995fd..659d232 100644 --- a/toot.py +++ b/toot.py @@ -1,12 +1,20 @@ from mastodon import Mastodon -from config import TOOT_API_BASE_URL, TOOT_CLIENT_SECRET, TOOT_ACCESS_TOKEN, TOOT_CLIENT_ID +from config import TOOT_API_BASE_URL, TOOT_CLIENT_SECRET, TOOT_ACCESS_TOKEN, TOOT_CLIENT_ID, MASTODON_CLIENT_ID_FILE ''' https://mastodonpy.readthedocs.io/en/stable/index.html ''' -mastodon_client = Mastodon(client_id=TOOT_CLIENT_ID, - client_secret=TOOT_CLIENT_SECRET, - api_base_url=TOOT_API_BASE_URL, - access_token=TOOT_ACCESS_TOKEN) +mastodon_client = Mastodon(client_id=MASTODON_CLIENT_ID_FILE, + api_base_url=TOOT_API_BASE_URL) + +url = mastodon_client.auth_request_url(redirect_uris="https://zjw.social/checkinbot/fedi_login_callback", scopes=['write:media', 'write:statuses']) +print(url) + +# mastodon_client.log_in( +# username="checkinbottest@jinwei.me", +# code='2RnDpj9lMGLWuIeppl-Cghy-iwSXzlJFWU6mQaKYD9o', +# # redirect_uri="urn:ietf:wg:oauth:2.0:oob", +# scopes=['write:media', 'write:statuses'] +# ) -- cgit v1.2.3