From 42276338b12706e671d9d7644dc30f35ffb5e9e6 Mon Sep 17 00:00:00 2001 From: Bibo-Joshi <22366557+Bibo-Joshi@users.noreply.github.com> Date: Fri, 3 Jun 2022 16:55:36 +0200 Subject: [PATCH] Add Arguments `chat/user_id` to `CallbackContext` And Example On Custom Webhook Setups (#3059) Co-authored-by: Harshil <37377066+harshil21@users.noreply.github.com> --- examples/README.md | 3 + examples/contexttypesbot.py | 4 +- examples/customwebhookbot.py | 192 +++++++++++++++++++++++++++++++ setup.cfg | 10 +- telegram/ext/_callbackcontext.py | 101 +++++++--------- 5 files changed, 248 insertions(+), 62 deletions(-) create mode 100644 examples/customwebhookbot.py diff --git a/examples/README.md b/examples/README.md index 74806cb78..697bb0cc1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -61,6 +61,9 @@ Uses the [`iro.js`](https://iro.js.org) JavaScript library to showcase a user in ### [`contexttypesbot.py`](https://github.com/python-telegram-bot/python-telegram-bot/blob/master/examples/contexttypesbot.py) This example showcases how `telegram.ext.ContextTypes` can be used to customize the `context` argument of handler and job callbacks. +### [`customwebhookbot.py`](customwebhookbot.py) +This example showcases how a custom webhook setup can be used in combination with `telegram.ext.Application`. + ### [`arbitrarycallbackdatabot.py`](https://github.com/python-telegram-bot/python-telegram-bot/blob/master/examples/arbitrarycallbackdatabot.py) This example showcases how PTBs "arbitrary callback data" feature can be used. diff --git a/examples/contexttypesbot.py b/examples/contexttypesbot.py index a25c3185c..ee28fad1e 100644 --- a/examples/contexttypesbot.py +++ b/examples/contexttypesbot.py @@ -57,8 +57,8 @@ class ChatData: class CustomContext(CallbackContext[ExtBot, dict, ChatData, dict]): """Custom class for context.""" - def __init__(self, application: Application): - super().__init__(application=application) + def __init__(self, application: Application, chat_id: int = None, user_id: int = None): + super().__init__(application=application, chat_id=chat_id, user_id=user_id) self._message_id: Optional[int] = None @property diff --git a/examples/customwebhookbot.py b/examples/customwebhookbot.py new file mode 100644 index 000000000..bc7538301 --- /dev/null +++ b/examples/customwebhookbot.py @@ -0,0 +1,192 @@ +#!/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 + +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://github.com/python-telegram-bot/python-telegram-bot/tree/v{TG_VER}/examples" + ) + +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://domain.tld" + 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("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/setup.cfg b/setup.cfg index 0592b113e..8c5dd156c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -61,9 +61,6 @@ disallow_incomplete_defs = True disallow_untyped_decorators = True show_error_codes = True -[mypy-telegram.vendor.*] -ignore_errors = True - # For some files, it's easier to just disable strict-optional all together instead of # cluttering the code with `# type: ignore`s or stuff like # `if self.text is None: raise RuntimeError()` @@ -76,3 +73,10 @@ warn_unused_ignores = False [mypy-apscheduler.*] ignore_missing_imports = True + +# uvicorn and starlette are only used for the `customwebhookbot.py` example +# let's just ignore type checking for them for now +[mypy-uvicorn.*] +ignore_missing_imports = True +[mypy-starlette.*] +ignore_missing_imports = True diff --git a/telegram/ext/_callbackcontext.py b/telegram/ext/_callbackcontext.py index f27739cda..1ec003c6f 100644 --- a/telegram/ext/_callbackcontext.py +++ b/telegram/ext/_callbackcontext.py @@ -18,18 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. # pylint: disable=no-self-use """This module contains the CallbackContext class.""" -from typing import ( - TYPE_CHECKING, - Coroutine, - Dict, - Generic, - List, - Match, - NoReturn, - Optional, - Tuple, - Type, -) +from typing import TYPE_CHECKING, Coroutine, Dict, Generic, List, Match, NoReturn, Optional, Type from telegram._callbackquery import CallbackQuery from telegram._update import Update @@ -83,6 +72,14 @@ class CallbackContext(Generic[BT, UD, CD, BD]): Args: application (:class:`telegram.ext.Application`): The application associated with this context. + chat_id (:obj:`int`, optional): The ID of the chat associated with this object. Used + to provide :attr:`chat_data`. + + .. versionadded:: 20.0 + user_id (:obj:`int`, optional): The ID of the user associated with this object. Used + to provide :attr:`user_data`. + + .. versionadded:: 20.0 Attributes: coroutine (:term:`coroutine function`): Optional. Only present in error handlers if the @@ -109,8 +106,8 @@ class CallbackContext(Generic[BT, UD, CD, BD]): __slots__ = ( "_application", - "_chat_id_and_data", - "_user_id_and_data", + "_chat_id", + "_user_id", "args", "matches", "error", @@ -119,10 +116,15 @@ class CallbackContext(Generic[BT, UD, CD, BD]): "__dict__", ) - def __init__(self: "CCT", application: "Application[BT, CCT, UD, CD, BD, Any]"): + def __init__( + self: "CCT", + application: "Application[BT, CCT, UD, CD, BD, Any]", + chat_id: int = None, + user_id: int = None, + ): self._application = application - self._chat_id_and_data: Optional[Tuple[int, CD]] = None - self._user_id_and_data: Optional[Tuple[int, UD]] = None + self._chat_id = chat_id + self._user_id = user_id self.args: Optional[List[str]] = None self.matches: Optional[List[Match]] = None self.error: Optional[Exception] = None @@ -159,8 +161,8 @@ class CallbackContext(Generic[BT, UD, CD, BD]): `_. """ - if self._chat_id_and_data: - return self._chat_id_and_data[1] + if self._chat_id is not None: + return self._application.chat_data[self._chat_id] return None @chat_data.setter @@ -175,8 +177,8 @@ class CallbackContext(Generic[BT, UD, CD, BD]): For each update from the same user it will be the same :obj:`ContextTypes.user_data`. Defaults to :obj:`dict`. """ - if self._user_id_and_data: - return self._user_id_and_data[1] + if self._user_id is not None: + return self._application.user_data[self._user_id] return None @user_data.setter @@ -200,16 +202,14 @@ class CallbackContext(Generic[BT, UD, CD, BD]): if self.application.persistence: if self.application.persistence.store_data.bot_data: await self.application.persistence.refresh_bot_data(self.bot_data) - if ( - self.application.persistence.store_data.chat_data - and self._chat_id_and_data is not None - ): - await self.application.persistence.refresh_chat_data(*self._chat_id_and_data) - if ( - self.application.persistence.store_data.user_data - and self._user_id_and_data is not None - ): - await self.application.persistence.refresh_user_data(*self._user_id_and_data) + if self.application.persistence.store_data.chat_data and self._chat_id is not None: + await self.application.persistence.refresh_chat_data( + chat_id=self._chat_id, chat_data=self.chat_data + ) + if self.application.persistence.store_data.user_data and self._user_id is not None: + await self.application.persistence.refresh_user_data( + user_id=self._user_id, user_data=self.user_data + ) def drop_callback_data(self, callback_query: CallbackQuery) -> None: """ @@ -264,6 +264,12 @@ class CallbackContext(Generic[BT, UD, CD, BD]): context. job (:class:`telegram.ext.Job`, optional): The job associated with the error. + .. versionadded:: 20.0 + coroutine (:term:`coroutine function`, optional): The coroutine function associated + with this error if the error was caused by a coroutine run with + :meth:`Application.create_task` or a handler callback with + :attr:`block=False `. + .. versionadded:: 20.0 Returns: @@ -295,23 +301,15 @@ class CallbackContext(Generic[BT, UD, CD, BD]): Returns: :class:`telegram.ext.CallbackContext` """ - self = cls(application) # type: ignore - - if update is not None and isinstance(update, Update): + if isinstance(update, Update): chat = update.effective_chat user = update.effective_user - if chat: - self._chat_id_and_data = ( - chat.id, - application.chat_data[chat.id], - ) - if user: - self._user_id_and_data = ( - user.id, - application.user_data[user.id], - ) - return self + chat_id = chat.id if chat else None + user_id = user.id if user else None + + return cls(application, chat_id=chat_id, user_id=user_id) # type: ignore + return cls(application) # type: ignore @classmethod def from_job( @@ -333,19 +331,8 @@ class CallbackContext(Generic[BT, UD, CD, BD]): Returns: :class:`telegram.ext.CallbackContext` """ - self = cls(application) # type: ignore + self = cls(application, chat_id=job.chat_id, user_id=job.user_id) # type: ignore self.job = job - - if job.chat_id: - self._chat_id_and_data = ( - job.chat_id, - application.chat_data[job.chat_id], - ) - if job.user_id: - self._user_id_and_data = ( - job.user_id, - application.user_data[job.user_id], - ) return self def update(self, data: Dict[str, object]) -> None: