Add Arguments chat/user_id to CallbackContext And Example On Custom Webhook Setups (#3059)

Co-authored-by: Harshil <37377066+harshil21@users.noreply.github.com>
This commit is contained in:
Bibo-Joshi 2022-06-03 16:55:36 +02:00 committed by GitHub
parent 5f547f3725
commit 42276338b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 248 additions and 62 deletions

View file

@ -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) ### [`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. 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) ### [`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. This example showcases how PTBs "arbitrary callback data" feature can be used.

View file

@ -57,8 +57,8 @@ class ChatData:
class CustomContext(CallbackContext[ExtBot, dict, ChatData, dict]): class CustomContext(CallbackContext[ExtBot, dict, ChatData, dict]):
"""Custom class for context.""" """Custom class for context."""
def __init__(self, application: Application): def __init__(self, application: Application, chat_id: int = None, user_id: int = None):
super().__init__(application=application) super().__init__(application=application, chat_id=chat_id, user_id=user_id)
self._message_id: Optional[int] = None self._message_id: Optional[int] = None
@property @property

View file

@ -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=<your user id>&payload=<payload>")
text = (
f"To check if the bot is still running, call <code>{url}/healthcheck</code>.\n\n"
f"To post a custom update, call <code>{payload_url}</code>."
)
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 = "</code>\n• <code>".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• <code>{combined_payloads}</code>"
)
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())

View file

@ -61,9 +61,6 @@ disallow_incomplete_defs = True
disallow_untyped_decorators = True disallow_untyped_decorators = True
show_error_codes = 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 # 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 # cluttering the code with `# type: ignore`s or stuff like
# `if self.text is None: raise RuntimeError()` # `if self.text is None: raise RuntimeError()`
@ -76,3 +73,10 @@ warn_unused_ignores = False
[mypy-apscheduler.*] [mypy-apscheduler.*]
ignore_missing_imports = True 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

View file

@ -18,18 +18,7 @@
# along with this program. If not, see [http://www.gnu.org/licenses/]. # along with this program. If not, see [http://www.gnu.org/licenses/].
# pylint: disable=no-self-use # pylint: disable=no-self-use
"""This module contains the CallbackContext class.""" """This module contains the CallbackContext class."""
from typing import ( from typing import TYPE_CHECKING, Coroutine, Dict, Generic, List, Match, NoReturn, Optional, Type
TYPE_CHECKING,
Coroutine,
Dict,
Generic,
List,
Match,
NoReturn,
Optional,
Tuple,
Type,
)
from telegram._callbackquery import CallbackQuery from telegram._callbackquery import CallbackQuery
from telegram._update import Update from telegram._update import Update
@ -83,6 +72,14 @@ class CallbackContext(Generic[BT, UD, CD, BD]):
Args: Args:
application (:class:`telegram.ext.Application`): The application associated with this application (:class:`telegram.ext.Application`): The application associated with this
context. 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: Attributes:
coroutine (:term:`coroutine function`): Optional. Only present in error handlers if the coroutine (:term:`coroutine function`): Optional. Only present in error handlers if the
@ -109,8 +106,8 @@ class CallbackContext(Generic[BT, UD, CD, BD]):
__slots__ = ( __slots__ = (
"_application", "_application",
"_chat_id_and_data", "_chat_id",
"_user_id_and_data", "_user_id",
"args", "args",
"matches", "matches",
"error", "error",
@ -119,10 +116,15 @@ class CallbackContext(Generic[BT, UD, CD, BD]):
"__dict__", "__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._application = application
self._chat_id_and_data: Optional[Tuple[int, CD]] = None self._chat_id = chat_id
self._user_id_and_data: Optional[Tuple[int, UD]] = None self._user_id = user_id
self.args: Optional[List[str]] = None self.args: Optional[List[str]] = None
self.matches: Optional[List[Match]] = None self.matches: Optional[List[Match]] = None
self.error: Optional[Exception] = None self.error: Optional[Exception] = None
@ -159,8 +161,8 @@ class CallbackContext(Generic[BT, UD, CD, BD]):
<https://github.com/python-telegram-bot/python-telegram-bot/wiki/ <https://github.com/python-telegram-bot/python-telegram-bot/wiki/
Storing-bot,-user-and-chat-related-data#chat-migration>`_. Storing-bot,-user-and-chat-related-data#chat-migration>`_.
""" """
if self._chat_id_and_data: if self._chat_id is not None:
return self._chat_id_and_data[1] return self._application.chat_data[self._chat_id]
return None return None
@chat_data.setter @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`. For each update from the same user it will be the same :obj:`ContextTypes.user_data`.
Defaults to :obj:`dict`. Defaults to :obj:`dict`.
""" """
if self._user_id_and_data: if self._user_id is not None:
return self._user_id_and_data[1] return self._application.user_data[self._user_id]
return None return None
@user_data.setter @user_data.setter
@ -200,16 +202,14 @@ class CallbackContext(Generic[BT, UD, CD, BD]):
if self.application.persistence: if self.application.persistence:
if self.application.persistence.store_data.bot_data: if self.application.persistence.store_data.bot_data:
await self.application.persistence.refresh_bot_data(self.bot_data) await self.application.persistence.refresh_bot_data(self.bot_data)
if ( if self.application.persistence.store_data.chat_data and self._chat_id is not None:
self.application.persistence.store_data.chat_data await self.application.persistence.refresh_chat_data(
and self._chat_id_and_data is not None chat_id=self._chat_id, chat_data=self.chat_data
): )
await self.application.persistence.refresh_chat_data(*self._chat_id_and_data) if self.application.persistence.store_data.user_data and self._user_id is not None:
if ( await self.application.persistence.refresh_user_data(
self.application.persistence.store_data.user_data user_id=self._user_id, user_data=self.user_data
and self._user_id_and_data is not None )
):
await self.application.persistence.refresh_user_data(*self._user_id_and_data)
def drop_callback_data(self, callback_query: CallbackQuery) -> None: def drop_callback_data(self, callback_query: CallbackQuery) -> None:
""" """
@ -264,6 +264,12 @@ class CallbackContext(Generic[BT, UD, CD, BD]):
context. context.
job (:class:`telegram.ext.Job`, optional): The job associated with the error. 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 <BaseHandler.block>`.
.. versionadded:: 20.0 .. versionadded:: 20.0
Returns: Returns:
@ -295,23 +301,15 @@ class CallbackContext(Generic[BT, UD, CD, BD]):
Returns: Returns:
:class:`telegram.ext.CallbackContext` :class:`telegram.ext.CallbackContext`
""" """
self = cls(application) # type: ignore if isinstance(update, Update):
if update is not None and isinstance(update, Update):
chat = update.effective_chat chat = update.effective_chat
user = update.effective_user user = update.effective_user
if chat: chat_id = chat.id if chat else None
self._chat_id_and_data = ( user_id = user.id if user else None
chat.id,
application.chat_data[chat.id], return cls(application, chat_id=chat_id, user_id=user_id) # type: ignore
) return cls(application) # type: ignore
if user:
self._user_id_and_data = (
user.id,
application.user_data[user.id],
)
return self
@classmethod @classmethod
def from_job( def from_job(
@ -333,19 +331,8 @@ class CallbackContext(Generic[BT, UD, CD, BD]):
Returns: Returns:
:class:`telegram.ext.CallbackContext` :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 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 return self
def update(self, data: Dict[str, object]) -> None: def update(self, data: Dict[str, object]) -> None: