2022-06-03 16:55:36 +02:00
|
|
|
#!/usr/bin/env python
|
|
|
|
# This program is dedicated to the public domain under the CC0 license.
|
2023-08-21 18:47:05 +02:00
|
|
|
# pylint: disable=import-error,unused-argument
|
2022-06-03 16:55:36 +02:00
|
|
|
"""
|
|
|
|
Simple example of a bot that uses a custom webhook setup and handles custom updates.
|
2023-08-16 21:15:32 +02:00
|
|
|
For the custom webhook setup, the libraries `flask`, `asgiref` and `uvicorn` are used. Please
|
|
|
|
install them as `pip install flask[async]~=2.3.2 uvicorn~=0.23.2 asgiref~=3.7.2`.
|
2022-06-03 16:55:36 +02:00
|
|
|
Note that any other `asyncio` based web server framework can be used for a custom webhook setup
|
|
|
|
just as well.
|
|
|
|
|
|
|
|
Usage:
|
2023-08-16 21:15:32 +02:00
|
|
|
Set bot Token, URL, admin CHAT_ID and PORT after the imports.
|
2022-06-03 16:55:36 +02:00
|
|
|
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
|
2023-08-16 21:15:32 +02:00
|
|
|
from asgiref.wsgi import WsgiToAsgi
|
|
|
|
from flask import Flask, Response, abort, make_response, request
|
2022-06-03 16:55:36 +02:00
|
|
|
|
|
|
|
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
|
|
|
|
)
|
2023-06-07 22:32:04 +02:00
|
|
|
# set higher logging level for httpx to avoid all GET and POST requests being logged
|
|
|
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
|
|
|
2022-06-03 16:55:36 +02:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
2023-08-16 21:15:32 +02:00
|
|
|
# Define configuration constants
|
|
|
|
URL = "https://domain.tld"
|
|
|
|
ADMIN_CHAT_ID = 123456
|
|
|
|
PORT = 8000
|
|
|
|
TOKEN = "123:ABC" # nosec B105
|
|
|
|
|
2022-06-03 16:55:36 +02:00
|
|
|
|
|
|
|
@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."""
|
2023-08-16 21:15:32 +02:00
|
|
|
payload_url = html.escape(f"{URL}/submitpayload?user_id=<your user id>&payload=<payload>")
|
2022-06-03 16:55:36 +02:00
|
|
|
text = (
|
2023-08-16 21:15:32 +02:00
|
|
|
f"To check if the bot is still running, call <code>{URL}/healthcheck</code>.\n\n"
|
2022-06-03 16:55:36 +02:00
|
|
|
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:
|
2023-08-16 21:15:32 +02:00
|
|
|
"""Handle custom updates."""
|
2022-06-03 16:55:36 +02:00
|
|
|
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>"
|
|
|
|
)
|
2023-08-16 21:15:32 +02:00
|
|
|
await context.bot.send_message(chat_id=ADMIN_CHAT_ID, text=text, parse_mode=ParseMode.HTML)
|
2022-06-03 16:55:36 +02:00
|
|
|
|
|
|
|
|
|
|
|
async def main() -> None:
|
2023-08-16 21:15:32 +02:00
|
|
|
"""Set up PTB application and a web application for handling the incoming requests."""
|
2022-06-03 16:55:36 +02:00
|
|
|
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 = (
|
2023-08-16 21:15:32 +02:00
|
|
|
Application.builder().token(TOKEN).updater(None).context_types(context_types).build()
|
2022-06-03 16:55:36 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
# register handlers
|
|
|
|
application.add_handler(CommandHandler("start", start))
|
|
|
|
application.add_handler(TypeHandler(type=WebhookUpdate, callback=webhook_update))
|
|
|
|
|
|
|
|
# Pass webhook settings to telegram
|
2023-08-16 21:15:32 +02:00
|
|
|
await application.bot.set_webhook(url=f"{URL}/telegram", allowed_updates=Update.ALL_TYPES)
|
2022-06-03 16:55:36 +02:00
|
|
|
|
|
|
|
# Set up webserver
|
2023-08-16 21:15:32 +02:00
|
|
|
flask_app = Flask(__name__)
|
|
|
|
|
|
|
|
@flask_app.post("/telegram") # type: ignore[misc]
|
|
|
|
async def telegram() -> Response:
|
2022-06-03 16:55:36 +02:00
|
|
|
"""Handle incoming Telegram updates by putting them into the `update_queue`"""
|
2023-08-16 21:15:32 +02:00
|
|
|
await application.update_queue.put(Update.de_json(data=request.json, bot=application.bot))
|
|
|
|
return Response(status=HTTPStatus.OK)
|
2022-06-03 16:55:36 +02:00
|
|
|
|
2023-08-16 21:15:32 +02:00
|
|
|
@flask_app.route("/submitpayload", methods=["GET", "POST"]) # type: ignore[misc]
|
|
|
|
async def custom_updates() -> Response:
|
2022-06-03 16:55:36 +02:00
|
|
|
"""
|
|
|
|
Handle incoming webhook updates by also putting them into the `update_queue` if
|
|
|
|
the required parameters were passed correctly.
|
|
|
|
"""
|
|
|
|
try:
|
2023-08-16 21:15:32 +02:00
|
|
|
user_id = int(request.args["user_id"])
|
|
|
|
payload = request.args["payload"]
|
2022-06-03 16:55:36 +02:00
|
|
|
except KeyError:
|
2023-08-16 21:15:32 +02:00
|
|
|
abort(
|
|
|
|
HTTPStatus.BAD_REQUEST,
|
|
|
|
"Please pass both `user_id` and `payload` as query parameters.",
|
2022-06-03 16:55:36 +02:00
|
|
|
)
|
|
|
|
except ValueError:
|
2023-08-16 21:15:32 +02:00
|
|
|
abort(HTTPStatus.BAD_REQUEST, "The `user_id` must be a string!")
|
2022-06-03 16:55:36 +02:00
|
|
|
|
|
|
|
await application.update_queue.put(WebhookUpdate(user_id=user_id, payload=payload))
|
2023-08-16 21:15:32 +02:00
|
|
|
return Response(status=HTTPStatus.OK)
|
2022-06-03 16:55:36 +02:00
|
|
|
|
2023-08-16 21:15:32 +02:00
|
|
|
@flask_app.get("/healthcheck") # type: ignore[misc]
|
|
|
|
async def health() -> Response:
|
2022-06-03 16:55:36 +02:00
|
|
|
"""For the health endpoint, reply with a simple plain text message."""
|
2023-08-16 21:15:32 +02:00
|
|
|
response = make_response("The bot is still running fine :)", HTTPStatus.OK)
|
|
|
|
response.mimetype = "text/plain"
|
|
|
|
return response
|
|
|
|
|
2022-06-03 16:55:36 +02:00
|
|
|
webserver = uvicorn.Server(
|
|
|
|
config=uvicorn.Config(
|
2023-08-16 21:15:32 +02:00
|
|
|
app=WsgiToAsgi(flask_app),
|
|
|
|
port=PORT,
|
2022-06-03 16:55:36 +02:00
|
|
|
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())
|