From 339c8ccec01b594de581aeeca552b380cb0b2549 Mon Sep 17 00:00:00 2001 From: Hinrich Mahler Date: Sun, 22 May 2022 00:26:10 +0200 Subject: [PATCH] v3.0 --- .github/dependabot.yml | 6 ++ .github/workflows/docs.yml | 34 +++++++++ .gitlab-ci.yml | 29 -------- .pre-commit-config.yaml | 81 +++++++++++---------- CHANGES.rst | 7 ++ README.rst | 4 ++ bot/commands.py | 67 ++++++++---------- bot/constants.py | 14 ++-- bot/deletesticker.py | 36 +++++----- bot/error.py | 26 +++---- bot/inline.py | 64 ++++++++--------- bot/setfallbackpicture.py | 38 +++++----- bot/settimezone.py | 49 ++++++------- bot/setup.py | 140 +++++++++++++++++++++---------------- bot/twitter.py | 66 +++++++++-------- bot/userdata.py | 58 +++++++-------- bot/utils.py | 44 ++++++------ docs/requirements-docs.txt | 6 +- docs/source/conf.py | 21 ++---- main.py | 39 ++++++----- pyproject.toml | 8 ++- requirements-dev.txt | 8 +-- requirements.txt | 10 +-- 23 files changed, 441 insertions(+), 414 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/docs.yml delete mode 100644 .gitlab-ci.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e5de18a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "monthly" \ No newline at end of file diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..34ccc28 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,34 @@ +name: GitHub Pages +on: + push: + branches: + - pages # For testing + - master + +jobs: + gh-pages: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Initialize submodules + run: + git submodule update --init --recursive + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.8' + + - name: Install dependencies + run: | + python -W ignore -m pip install --upgrade pip + python -W ignore -m pip install -r requirements.txt + python -W ignore -m pip install -r docs/requirements-docs.txt + - name: Build Docs + run: sphinx-build docs/source docs/build/html + + - name: Deploy + uses: JamesIves/github-pages-deploy-action@4.1.7 + with: + branch: gh-pages + folder: docs/build/html \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index d7716a3..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,29 +0,0 @@ -stages: - - pages - -pages: - stage: pages - - variables: - GIT_SUBMODULE_STRATEGY: recursive - - rules: - - if: '$CI_COMMIT_BRANCH == "master"' - changes: - - "**/*.rst" - - "**/*.py" - - image: python:3.7-slim - - script: - - apt-get update - - apt-get install gcc git -y - - python -W ignore -m pip install --upgrade pip - - python -W ignore -m pip install -r docs/requirements-docs.txt - - python -W ignore -m pip install -r requirements.txt - - sphinx-build docs/source docs/build/html - - mv docs/build/html public - - artifacts: - paths: - - public diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c573ac..40bf2a7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,51 +1,60 @@ # Make sure that -# * the revs specified here match requirements-dev.txt # * the additional_dependencies here match requirements.txt + +ci: + autoupdate_schedule: monthly + +default_language_version: + python: python3.7 + repos: -- repo: https://github.com/psf/black - rev: 20.8b1 + - repo: https://github.com/psf/black + rev: 22.3.0 hooks: - - id: black - args: - - --diff - - --check -- repo: https://gitlab.com/pycqa/flake8 - rev: 3.9.2 + - id: black + - repo: https://github.com/PyCQA/flake8 + rev: 4.0.1 hooks: - - id: flake8 - language: python -- repo: https://github.com/PyCQA/pylint - rev: v2.10.2 + - id: flake8 + - repo: https://github.com/PyCQA/pylint + rev: v2.13.8 hooks: - - id: pylint + - id: pylint files: ^(main|bot/\w*).py$ args: - --rcfile=setup.cfg + # run pylint across multiple cpu cores to speed it up- + - --jobs=0 # See https://pylint.pycqa.org/en/latest/user_guide/run.html?#parallel-execution to know more additional_dependencies: - - python-telegram-bot>=13.7,<14.0 - - Pillow==8.3.0 - - PyHyphen==3.0.1 - - pytz - - fuzzywuzzy==0.18.0 - - git+https://gitlab.com/HirschHeissIch/ptbstats.git@v1.3.1 - language: python -- repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.910 + - python-telegram-bot==20.0a0 + - Pillow==9.1.1 + - PyHyphen==3.0.1 + - pytz + - thefuzz==0.19.0 + - git+https://github.com/Bibo-Joshi/ptbstats.git@v2.0 + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.950 hooks: - - id: mypy + - id: mypy files: ^(main|bot/\w*).py$ - language: python additional_dependencies: - - python-telegram-bot>=13.7,<14.0 - - Pillow==8.3.0 - - PyHyphen==3.0.1 - - pytz - - fuzzywuzzy==0.18.0 - - git+https://gitlab.com/HirschHeissIch/ptbstats.git@v1.3.1 -- repo: https://github.com/asottile/pyupgrade - rev: v2.25.0 + - python-telegram-bot==20.0a0 + - Pillow==9.1.1 + - PyHyphen==3.0.1 + - pytz + - thefuzz==0.19.0 + - git+https://github.com/Bibo-Joshi/ptbstats.git@v2.0 + - repo: https://github.com/asottile/pyupgrade + rev: v2.32.0 hooks: - - id: pyupgrade - files: ^(main|bot/\w*).py$ + - id: pyupgrade args: - - --py36-plus \ No newline at end of file + - --py37-plus + - repo: https://github.com/pycqa/isort + rev: 5.10.1 + hooks: + - id: isort + name: isort + args: + - --diff + - --check \ No newline at end of file diff --git a/CHANGES.rst b/CHANGES.rst index 6432ebe..0a0526c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,13 @@ Changelog ========= +Version 3.0 +=========== +*Released 2022-05-22* + +* Upgrade ``python-telegram-bot`` to v20.0a0 introducing ``asyncio``. +* Upgrade ``ptbstats`` to v2.0, which is not backwards compatible. + Version 2.2.1 ============= *Released 2021-09-18* diff --git a/README.rst b/README.rst index 0daf7ee..36731c0 100644 --- a/README.rst +++ b/README.rst @@ -29,6 +29,10 @@ Twitter Status Bot :target: https://t.me/TwitterStatusBot :alt: Chat On Telegram +.. image:: https://results.pre-commit.ci/badge/github/Bibo-Joshi/twitter-status-bot/main.svg + :target: https://results.pre-commit.ci/latest/github/Bibo-Joshi/twitter-status-bot/main + :alt: pre-commit.ci status + Β»Twitter Status BotΒ« is a simple Telegram Bot that let's you create stickers looking like Tweets on the fly. You can find it at `@TwitterStatusBot`_. Inspired by this `sticker set`_ by `@AboutTheDot`_. diff --git a/bot/commands.py b/bot/commands.py index 85545e7..a64bf56 100644 --- a/bot/commands.py +++ b/bot/commands.py @@ -2,45 +2,38 @@ """Methods for simple commands.""" from typing import cast -from telegram import ( - Update, - InlineKeyboardMarkup, - InlineKeyboardButton, - Message, - User, - ChatAction, - Sticker, -) +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Message, Sticker, Update, User +from telegram.constants import ChatAction -from bot.utils import get_sticker_photo_stream from bot.constants import HOMEPAGE, LTR, RTL from bot.twitter import HyphenationError from bot.userdata import CCT, UserData +from bot.utils import get_sticker_photo_stream -def sticker_message(update: Update, context: CCT) -> None: +async def sticker_message(update: Update, context: CCT) -> None: """ Answers a text message by providing the requested sticker. Args: update: The Telegram update. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. """ msg = cast(Message, update.effective_message) user = cast(User, update.effective_user) - context.bot.send_chat_action(user.id, ChatAction.UPLOAD_PHOTO) - stream = get_sticker_photo_stream(cast(str, msg.text), user, context) - sticker = cast(Sticker, msg.reply_sticker(stream).sticker) + context.application.create_task(context.bot.send_chat_action(user.id, ChatAction.UPLOAD_PHOTO)) + stream = await get_sticker_photo_stream(cast(str, msg.text), user, context) + sticker = cast(Sticker, (await msg.reply_sticker(stream)).sticker) cast(UserData, context.user_data).sticker_file_ids[sticker.file_unique_id] = sticker.file_id -def info(update: Update, context: CCT) -> None: +async def info(update: Update, context: CCT) -> None: """ Returns some info about the bot. Args: update: The Telegram update. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. """ if context.args: text = str(HyphenationError()) @@ -74,19 +67,19 @@ def info(update: Update, context: CCT) -> None: "News Channel πŸ“£", url="https://t.me/BotChangelogs", ), - InlineKeyboardButton("Inline Mode ✍️", switch_inline_query=''), + InlineKeyboardButton("Inline Mode ✍️", switch_inline_query=""), ] ) - cast(Message, update.effective_message).reply_text(text, reply_markup=keyboard) + await cast(Message, update.effective_message).reply_text(text, reply_markup=keyboard) -def toggle_store_stickers(update: Update, context: CCT) -> None: +async def toggle_store_stickers(update: Update, context: CCT) -> None: """Toggles whether or not to store stickers for the user. Args: update: The Telegram update. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. """ user_data = cast(UserData, context.user_data) message = cast(Message, update.effective_message) @@ -98,60 +91,62 @@ def toggle_store_stickers(update: Update, context: CCT) -> None: text = "Sent stickers be stored. will from now." user_data.store_stickers = True - message.reply_text(text) + await message.reply_text(text) -def toggle_text_direction(update: Update, context: CCT) -> None: +async def toggle_text_direction(update: Update, context: CCT) -> None: """Toggles whether the user wants to use left-to-right or right-to-left text. Args: update: The Telegram update. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. """ user_data = cast(UserData, context.user_data) message = cast(Message, update.effective_message) mapping = {LTR: RTL, RTL: LTR} user_data.text_direction = mapping[user_data.text_direction] - description = 'left-to-right' if user_data.text_direction == LTR else 'right-to-left' + description = "left-to-right" if user_data.text_direction == LTR else "right-to-left" - message.reply_text(f'The sticker text will be set as {description} now.') + await message.reply_text(f"The sticker text will be set as {description} now.") -def show_fallback_picture(update: Update, context: CCT) -> None: +async def show_fallback_picture(update: Update, context: CCT) -> None: """Shows the users current fallback profile picture, if set. Args: update: The Telegram update. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. """ user_data = cast(UserData, context.user_data) message = cast(Message, update.effective_message) if not user_data.fallback_photo: - message.reply_text( + await message.reply_text( "You don't have a fallback picture set. Use /set_fallback_picture to set one." ) else: - message.reply_photo( + await message.reply_photo( user_data.fallback_photo.file_id, - caption='This is your current fallback profile picture. You can delete it with ' - '/delete_fallback_picture or set a new one with /set_fallback_picture.', + caption="This is your current fallback profile picture. You can delete it with " + "/delete_fallback_picture or set a new one with /set_fallback_picture.", ) -def delete_fallback_picture(update: Update, context: CCT) -> None: +async def delete_fallback_picture(update: Update, context: CCT) -> None: """Deletes the users current fallback profile picture, if set. Args: update: The Telegram update. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. """ user_data = cast(UserData, context.user_data) message = cast(Message, update.effective_message) if not user_data.fallback_photo: - message.reply_text( + await message.reply_text( "You don't have a fallback picture set. Use /set_fallback_picture to set one." ) else: user_data.update_fallback_photo(None) - message.reply_text('Fallback picture deleted. Use /set_fallback_picture to set a new one.') + await message.reply_text( + "Fallback picture deleted. Use /set_fallback_picture to set a new one." + ) diff --git a/bot/constants.py b/bot/constants.py index 2f6a09c..801a59d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -3,21 +3,21 @@ from configparser import ConfigParser from pathlib import Path -from PIL import Image, ImageFont from hyphen import Hyphenator +from PIL import Image, ImageFont config = ConfigParser() config.read("bot.ini") # A little trick to get the corrects paths both at runtime and when building the docs -PATH_PREFIX = '../' if Path('../headers').is_dir() else '' +PATH_PREFIX = "../" if Path("../headers").is_dir() else "" -ADMIN_KEY: str = 'ADMIN_KEY' +ADMIN_KEY: str = "ADMIN_KEY" """:obj:`str`: The admins chat id is stored under this key in ``bot_data``.""" -STICKER_CHAT_ID_KEY: str = 'STICKER_CHAT_ID_KEY' +STICKER_CHAT_ID_KEY: str = "STICKER_CHAT_ID_KEY" """:obj:`srt`: The name of the chat where stickers can be sent to get their file IDs is stored under this key in ``bot_data``.""" -REMOVE_KEYBOARD_KEY: str = 'REMOVE_KEYBOARD_KEY' +REMOVE_KEYBOARD_KEY: str = "REMOVE_KEYBOARD_KEY" """:obj:`str`: Store a message object in under this key in ``chat_data`` to remove its reply markup later on with :meth:`utils.remove_reply_markup`.""" HOMEPAGE: str = "https://hirschheissich.gitlab.io/twitter-status-bot/" @@ -63,7 +63,7 @@ """:class:`PIL.ImageFont.Font`: Font to use for small text in the body.""" HYPHENATOR = Hyphenator("en_US") """:class:`PyHyphen.Hyphenator`: A hyphenator to use to wrap text.""" -LTR = 'ltr' +LTR = "ltr" """:obj:`str`: Text direction left to right.""" -RTL: str = 'rtl' +RTL: str = "rtl" """:obj:`str`: Text direction right to let.""" diff --git a/bot/deletesticker.py b/bot/deletesticker.py index 4f91e96..4829473 100644 --- a/bot/deletesticker.py +++ b/bot/deletesticker.py @@ -1,23 +1,23 @@ #!/usr/bin/env python3 """Conversation for deleting stored stickers.""" -from typing import cast, Dict +from typing import Dict, cast -from telegram import Update, Message, InlineKeyboardMarkup, InlineKeyboardButton, Sticker -from telegram.ext import ConversationHandler, CommandHandler, MessageHandler, Filters +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Message, Sticker, Update +from telegram.ext import CommandHandler, ConversationHandler, MessageHandler, filters from bot.constants import REMOVE_KEYBOARD_KEY from bot.userdata import CCT, UserData -from bot.utils import TIMEOUT_HANDLER, FALLBACK_HANDLER, remove_reply_markup +from bot.utils import FALLBACK_HANDLER, TIMEOUT_HANDLER, remove_reply_markup STATE = 42 -def start(update: Update, context: CCT) -> int: +async def start(update: Update, context: CCT) -> int: """Starts the conversation and asks for the sticker that's to be deleted. Args: update: The Telegram update. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. Returns: int: The next state. @@ -25,30 +25,30 @@ def start(update: Update, context: CCT) -> int: user_data = cast(UserData, context.user_data) message = cast(Message, update.effective_message) if not user_data.store_stickers: - message.reply_text( + await message.reply_text( "I'm currently not storing any stickers for you. To activate that, " "use /toggle_store_stickers." ) return ConversationHandler.END if not user_data.sticker_file_ids: - message.reply_text("I don't have any stickers stored for you.") + await message.reply_text("I don't have any stickers stored for you.") return ConversationHandler.END - message = message.reply_text( + message = await message.reply_text( "Please press the button below and select the sticker that you want to delete.", reply_markup=InlineKeyboardMarkup.from_button( - InlineKeyboardButton(text='Click me πŸ‘†', switch_inline_query_current_chat='') + InlineKeyboardButton(text="Click me πŸ‘†", switch_inline_query_current_chat="") ), ) cast(Dict, context.chat_data)[REMOVE_KEYBOARD_KEY] = message return STATE -def handle_sticker(update: Update, context: CCT) -> int: +async def handle_sticker(update: Update, context: CCT) -> int: """Handles the sticker input and deletes the sticker if possible. Args: update: The Telegram update. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. Returns: int: The next state. @@ -59,20 +59,20 @@ def handle_sticker(update: Update, context: CCT) -> int: deleted = user_data.sticker_file_ids.pop(sticker.file_unique_id, None) if not deleted: - message.reply_text( - 'Sorry, this is not a sticker that you have sent before. Aborting operation.' + await message.reply_text( + "Sorry, this is not a sticker that you have sent before. Aborting operation." ) else: - message.reply_text('Sticker successfully deleted.') + await message.reply_text("Sticker successfully deleted.") - remove_reply_markup(context) + await remove_reply_markup(context) return ConversationHandler.END delete_sticker_conversation = ConversationHandler( - entry_points=[CommandHandler('delete_sticker', start)], + entry_points=[CommandHandler("delete_sticker", start)], states={ - STATE: [MessageHandler(Filters.sticker, handle_sticker)], + STATE: [MessageHandler(filters.Sticker.STATIC, handle_sticker)], ConversationHandler.TIMEOUT: [TIMEOUT_HANDLER], }, fallbacks=[FALLBACK_HANDLER], diff --git a/bot/error.py b/bot/error.py index 4f019ec..5ad85d8 100644 --- a/bot/error.py +++ b/bot/error.py @@ -7,45 +7,43 @@ from typing import cast from telegram import Update -from telegram.error import BadRequest, RetryAfter, Unauthorized -from telegram.utils.helpers import mention_html +from telegram.error import BadRequest, Forbidden, RetryAfter from bot.constants import ADMIN_KEY from bot.twitter import HyphenationError from bot.userdata import CCT - logger = logging.getLogger(__name__) -def hyphenation_error(update: object, context: CCT) -> None: +async def hyphenation_error(update: object, context: CCT) -> None: """Handles hyphenation errors by informing the triggering user about them. Args: update: The Telegram update. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. """ if not isinstance(context.error, HyphenationError) or not isinstance(update, Update): return if update.inline_query: - update.inline_query.answer( + await update.inline_query.answer( results=[], switch_pm_text="Click me! πŸ‘†", switch_pm_parameter="hyphenation_error", ) return if update.effective_message: - update.effective_message.reply_text(str(context.error)) + await update.effective_message.reply_text(str(context.error)) -def error(update: object, context: CCT) -> None: +async def error(update: object, context: CCT) -> None: """Informs the originator of the update that an error occurred and forwards the traceback to the admin. Args: update: The Telegram update. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. """ admin_id = cast(int, context.bot_data[ADMIN_KEY]) @@ -60,7 +58,7 @@ def error(update: object, context: CCT) -> None: if ( isinstance(context.error, BadRequest) and "Query is too old" in str(context.error) - ) or isinstance(context.error, Unauthorized): + ) or isinstance(context.error, Forbidden): return if isinstance(context.error, RetryAfter): @@ -70,7 +68,7 @@ def error(update: object, context: CCT) -> None: # Inform sender of update, that something went wrong if isinstance(update, Update) and update.effective_message: text = "Something went wrong 😟. I informed the admin πŸ€“." - update.effective_message.reply_text(text) + await update.effective_message.reply_text(text) # Get traceback tb_list = traceback.format_exception(None, context.error, context.error.__traceback__) @@ -80,9 +78,7 @@ def error(update: object, context: CCT) -> None: payload = "" if isinstance(update, Update): if update.effective_user: - payload += " with the user {}".format( - mention_html(update.effective_user.id, update.effective_user.first_name) - ) + payload += f" with the user {update.effective_user.mention_html()}" if update.effective_chat and update.effective_chat.username: payload += f" (@{html.escape(update.effective_chat.username)})" if update.poll: @@ -93,4 +89,4 @@ def error(update: object, context: CCT) -> None: ) # Send to admin - context.bot.send_message(admin_id, text) + await context.bot.send_message(admin_id, text) diff --git a/bot/inline.py b/bot/inline.py index c49d467..098e047 100644 --- a/bot/inline.py +++ b/bot/inline.py @@ -1,38 +1,38 @@ #!/usr/bin/env python3 """Methods for the inline mode.""" import logging -from threading import Event, Thread -from typing import cast, Dict, Any, Union +from asyncio import CancelledError, Event +from typing import Any, Dict, Union, cast from uuid import uuid4 from telegram import ( - Update, + ChosenInlineResult, InlineQuery, InlineQueryResultCachedSticker, - User, - ChosenInlineResult, Sticker, + Update, + User, ) from bot.constants import STICKER_CHAT_ID_KEY -from bot.utils import get_sticker_photo_stream from bot.userdata import CCT, UserData +from bot.utils import get_sticker_photo_stream logger = logging.getLogger(__name__) def _check_event(event: Event) -> None: if event.is_set(): - logger.debug('Sticker creation terminated because event was set') - raise RuntimeError('Sticker creation terminated because event was set') + logger.debug("Sticker creation terminated because event was set") + raise CancelledError("Sticker creation terminated because event was set") -def inline_thread(update: Update, context: CCT, event: Event) -> None: +async def inline_task(update: Update, context: CCT, event: Event) -> None: """Answers an inline query by providing the corresponding sticker. Args: update: The Telegram update. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. event: ``event.is_set()`` will be checked before the time consuming parts of the sticker creation and if the event is set, the creation will be terminated. """ @@ -52,13 +52,15 @@ def inline_thread(update: Update, context: CCT, event: Event) -> None: _check_event(event) # Build photo - photo_stream = get_sticker_photo_stream( + photo_stream = await get_sticker_photo_stream( inline_query.query, cast(User, update.effective_user), context ) _check_event(event) # Send it to the dumpster chat to get the chat_id - sticker = cast(Sticker, context.bot.send_sticker(sticker_chat_id, photo_stream).sticker) + sticker = cast( + Sticker, (await context.bot.send_sticker(sticker_chat_id, photo_stream)).sticker + ) file_unique_id, file_id = sticker.file_unique_id, sticker.file_id # Store the IDs so we can know which sticker was selected @@ -70,55 +72,53 @@ def inline_thread(update: Update, context: CCT, event: Event) -> None: _check_event(event) # Answer the inline query - inline_query.answer(**kwargs, is_personal=True, auto_pagination=True, cache_time=0) - except Exception as exc: # pylint: disable=W0703 - if 'Sticker creation terminated' not in str(exc): - context.dispatcher.dispatch_error(update=update, error=exc) + await inline_query.answer(**kwargs, is_personal=True, auto_pagination=True, cache_time=0) + except CancelledError: + print("I cancelled a task") + pass -def inline(update: Update, context: CCT) -> None: - """Answers an inline query by starting a thread running :meth:`inline_thread` and terminating - any existing such thread for the current user. +async def inline(update: Update, context: CCT) -> None: + """Answers an inline query by starting a task running :meth:`inline_task` and terminating + any existing such task for the current user. Args: update: The Telegram update. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. """ user_data = cast(UserData, context.user_data) inline_query = cast(InlineQuery, update.inline_query) query = inline_query.query if query: - thread = user_data.inline_query_thread - if thread and thread.is_alive(): + task = user_data.inline_query_task + if task and not task.done(): cast(Event, user_data.inline_query_event).set() - thread.join() event = user_data.inline_query_event = Event() - new_thread = Thread( - name=f'{cast(User, update.effective_user).id}{query}', - target=inline_thread, - kwargs={'update': update, 'context': context, 'event': event}, + new_task = context.application.create_task( + inline_task(update=update, context=context, event=event) ) - new_thread.start() user_data.inline_query_event = event - user_data.inline_query_thread = new_thread + user_data.inline_query_task = new_task else: file_ids = list(user_data.sticker_file_ids.values()) results = [ InlineQueryResultCachedSticker(id=f"tweet {i}", sticker_file_id=sticker_id) for i, sticker_id in enumerate(reversed(file_ids)) ] - inline_query.answer(results=results, is_personal=True, auto_pagination=True, cache_time=0) + await inline_query.answer( + results=results, is_personal=True, auto_pagination=True, cache_time=0 + ) -def handle_chosen_inline_result(update: Update, context: CCT) -> None: +async def handle_chosen_inline_result(update: Update, context: CCT) -> None: """ Appends the chosen sticker ID to the users corresponding list. Args: update: The Telegram update. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. """ user_data = cast(UserData, context.user_data) result_id = cast(ChosenInlineResult, update.chosen_inline_result).result_id diff --git a/bot/setfallbackpicture.py b/bot/setfallbackpicture.py index 6195e93..fb75614 100644 --- a/bot/setfallbackpicture.py +++ b/bot/setfallbackpicture.py @@ -1,46 +1,42 @@ #!/usr/bin/env python3 """Conversation for deleting stored stickers.""" -from typing import cast, List +from typing import List, cast -from telegram import ( - Update, - Message, - PhotoSize, -) -from telegram.ext import ConversationHandler, CommandHandler, MessageHandler, Filters +from telegram import Message, PhotoSize, Update +from telegram.ext import CommandHandler, ConversationHandler, MessageHandler, filters from bot.userdata import CCT, UserData -from bot.utils import TIMEOUT_HANDLER, FALLBACK_HANDLER +from bot.utils import FALLBACK_HANDLER, TIMEOUT_HANDLER STATE = 42 -def start(update: Update, _: CCT) -> int: +async def start(update: Update, _: CCT) -> int: """Starts the conversation and asks for the new picture.. Args: update: The Telegram update. - _: The callback context as provided by the dispatcher. + _: The callback context as provided by the application. Returns: int: The next state. """ message = cast(Message, update.effective_message) - message.reply_text( - 'Please send me the picture that you want to use as fallback. Make sure to send it as ' - 'photo (compressed) instead of as document (uncompressed).\n\nNote that this photo will ' + await message.reply_text( + "Please send me the picture that you want to use as fallback. Make sure to send it as " + "photo (compressed) instead of as document (uncompressed).\n\nNote that this photo will " "only be used if you don't have a profile picture or I can't see it due to your privacy " - 'settings.' + "settings." ) return STATE -def handle_picture(update: Update, context: CCT) -> int: +async def handle_picture(update: Update, context: CCT) -> int: """Handles the sticker input and deletes the sticker if possible. Args: update: The Telegram update. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. Returns: int: The next state. @@ -48,21 +44,23 @@ def handle_picture(update: Update, context: CCT) -> int: user_data = cast(UserData, context.user_data) message = cast(Message, update.effective_message) if message.document: - message.reply_text('Please send me the picture as compressed photo and not as document.') + await message.reply_text( + "Please send me the picture as compressed photo and not as document." + ) return STATE photos = cast(List[PhotoSize], message.photo) assert len(photos) > 0 user_data.update_fallback_photo(photos[-1]) - message.reply_text('Fallback picture set.') + await message.reply_text("Fallback picture set.") return ConversationHandler.END set_fallback_picture_conversation = ConversationHandler( - entry_points=[CommandHandler('set_fallback_picture', start)], + entry_points=[CommandHandler("set_fallback_picture", start)], states={ - STATE: [MessageHandler(Filters.photo, handle_picture)], + STATE: [MessageHandler(filters.PHOTO, handle_picture)], ConversationHandler.TIMEOUT: [TIMEOUT_HANDLER], }, fallbacks=[FALLBACK_HANDLER], diff --git a/bot/settimezone.py b/bot/settimezone.py index 87bc2b3..19df467 100644 --- a/bot/settimezone.py +++ b/bot/settimezone.py @@ -1,65 +1,66 @@ #!/usr/bin/env python3 """Conversation for deleting stored stickers.""" -from typing import cast, Dict -from fuzzywuzzy import fuzz +from typing import Dict, cast + import pytz from telegram import ( - Update, - Message, - InlineKeyboardMarkup, + Bot, InlineKeyboardButton, + InlineKeyboardMarkup, InlineQuery, InlineQueryResultArticle, InputTextMessageContent, - Bot, + Message, + Update, User, ) from telegram.ext import ( - ConversationHandler, + ChosenInlineResultHandler, CommandHandler, - MessageHandler, - Filters, + ConversationHandler, InlineQueryHandler, - ChosenInlineResultHandler, + MessageHandler, + filters, ) +from thefuzz import fuzz from bot.constants import REMOVE_KEYBOARD_KEY from bot.userdata import CCT, UserData -from bot.utils import TIMEOUT_HANDLER, FALLBACK_HANDLER, remove_reply_markup +from bot.utils import FALLBACK_HANDLER, TIMEOUT_HANDLER, remove_reply_markup STATE = 42 -def start(update: Update, context: CCT) -> int: +async def start(update: Update, context: CCT) -> int: """Starts the conversation and asks for the timezone. Args: update: The Telegram update. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. Returns: int: The next state. """ user_data = cast(UserData, context.user_data) message = cast(Message, update.effective_message) - message = message.reply_text( + message = await message.reply_text( f"Your current timezone is: {user_data.tzinfo}. Please press the button below to " "select a new timezone. You can scroll through the available options or type something to " "narrow down the options.", reply_markup=InlineKeyboardMarkup.from_button( - InlineKeyboardButton(text='Click me πŸ‘†', switch_inline_query_current_chat=''), + InlineKeyboardButton(text="Click me πŸ‘†", switch_inline_query_current_chat=""), ), ) cast(Dict, context.chat_data)[REMOVE_KEYBOARD_KEY] = message return STATE -def handle_inline_query(update: Update, _: CCT) -> int: +async def handle_inline_query(update: Update, _: CCT) -> int: """Handles the inline query to show appropriate timezones. Args: update: The Telegram update. - _: The callback context as provided by the dispatcher. + _: The callback context as provided by the application. Returns: int: The next state. @@ -73,7 +74,7 @@ def handle_inline_query(update: Update, _: CCT) -> int: else: timezones.sort() - inline_query.answer( + await inline_query.answer( results=[ InlineQueryResultArticle( id=tz, title=tz, input_message_content=InputTextMessageContent(tz) @@ -87,12 +88,12 @@ def handle_inline_query(update: Update, _: CCT) -> int: return STATE -def handle_timezone(update: Update, context: CCT) -> int: +async def handle_timezone(update: Update, context: CCT) -> int: """Handles the selected timezone. Args: update: The Telegram update. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. Returns: int: The next state. @@ -107,9 +108,9 @@ def handle_timezone(update: Update, context: CCT) -> int: message = cast(Message, update.effective_message) user_data.tzinfo = cast(str, message.text) - user.send_message(f'Your new timezone is {user_data.tzinfo}.') + await user.send_message(f"Your new timezone is {user_data.tzinfo}.") - remove_reply_markup(context) + await remove_reply_markup(context) return ConversationHandler.END @@ -123,13 +124,13 @@ def build_set_timezone_conversation(bot: Bot) -> ConversationHandler: The ConversationHandler """ return ConversationHandler( - entry_points=[CommandHandler('set_timezone', start)], + entry_points=[CommandHandler("set_timezone", start)], states={ STATE: [ InlineQueryHandler(handle_inline_query), # Use both CHRH and MH because we can't be sure which comes first … ChosenInlineResultHandler(handle_timezone), - MessageHandler(Filters.via_bot(bot.id), handle_timezone), + MessageHandler(filters.ViaBot(bot.id), handle_timezone), ], ConversationHandler.TIMEOUT: [TIMEOUT_HANDLER], }, diff --git a/bot/setup.py b/bot/setup.py index 8482dea..e77efa5 100644 --- a/bot/setup.py +++ b/bot/setup.py @@ -1,47 +1,49 @@ #!/usr/bin/env python3 -"""Methods for initializing the dispatcher.""" +"""Methods for initializing the application.""" import warnings from typing import Union -from ptbstats import set_dispatcher, register_stats, SimpleStats -from telegram import BotCommandScopeChat +from ptbstats import SimpleStats, register_stats, set_application +from telegram import BotCommandScopeChat, Update from telegram.ext import ( - Dispatcher, + Application, + ChosenInlineResultHandler, CommandHandler, - MessageHandler, - Filters, + ExtBot, InlineQueryHandler, - ChosenInlineResultHandler, + JobQueue, + MessageHandler, + filters, ) -from bot.constants import ADMIN_KEY, STICKER_CHAT_ID_KEY -from bot.deletesticker import delete_sticker_conversation -from bot.setfallbackpicture import set_fallback_picture_conversation -from bot.settimezone import build_set_timezone_conversation -from bot.utils import default_message from bot.commands import ( + delete_fallback_picture, info, + show_fallback_picture, sticker_message, toggle_store_stickers, - show_fallback_picture, - delete_fallback_picture, toggle_text_direction, ) -from bot.error import hyphenation_error, error -from bot.inline import inline, handle_chosen_inline_result +from bot.constants import ADMIN_KEY, STICKER_CHAT_ID_KEY +from bot.deletesticker import delete_sticker_conversation +from bot.error import error, hyphenation_error +from bot.inline import handle_chosen_inline_result, inline +from bot.setfallbackpicture import set_fallback_picture_conversation +from bot.settimezone import build_set_timezone_conversation from bot.userdata import CCT, UserData +from bot.utils import default_message # B/C we know what we're doing -warnings.filterwarnings('ignore', message="If 'per_", module='telegram.ext.conversationhandler') +warnings.filterwarnings("ignore", message="If 'per_", module="telegram.ext.conversationhandler") warnings.filterwarnings( - 'ignore', + "ignore", message=".*does not handle objects that can not be copied", - module='telegram.ext.basepersistence', + module="telegram.ext.basepersistence", ) -def setup_dispatcher( - dispatcher: Dispatcher[CCT, UserData, dict, dict], +async def setup_application( + application: Application[ExtBot, CCT, UserData, dict, dict, JobQueue], admin_id: int, sticker_chat_id: Union[str, int], ) -> None: @@ -49,67 +51,85 @@ def setup_dispatcher( Adds handlers and sets up ``bot_data``. Also sets the bot commands. Args: - dispatcher: The :class:`telegram.ext.Dispatcher`. + application: The :class:`telegram.ext.Application`. admin_id: The admins chat id. sticker_chat_id: The name of the chat where stickers can be sent to get their file IDs. """ + async with application: + await _setup_application( + application=application, admin_id=admin_id, sticker_chat_id=sticker_chat_id + ) + + +async def _setup_application( + application: Application[ExtBot, CCT, UserData, dict, dict, JobQueue], + admin_id: int, + sticker_chat_id: Union[str, int], +) -> None: # error handlers - dispatcher.add_error_handler(hyphenation_error) - dispatcher.add_error_handler(error) + application.add_error_handler(hyphenation_error) + application.add_error_handler(error) # Set up stats - set_dispatcher(dispatcher) - register_stats( - SimpleStats("ilq", lambda u: bool(u.chosen_inline_result)), - admin_id=admin_id, - ) + set_application(application) + + def check_inline_query(update: object) -> bool: + return isinstance(update, Update) and bool(update.chosen_inline_result) + + def check_text(update: object) -> bool: + return ( + isinstance(update, Update) + and bool(update.effective_message) + and bool( + (filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND).check_update(update) + ) + ) + + register_stats(SimpleStats("ilq", check_inline_query), admin_id=admin_id) register_stats( SimpleStats( "text", - lambda u: bool( - u.effective_message - and (Filters.chat_type.private & Filters.text & ~Filters.command)(u) - ), + check_text, ), admin_id=admin_id, ) # basic command handlers - dispatcher.add_handler(CommandHandler(["start", "help"], info)) - dispatcher.add_handler(CommandHandler('toggle_store_stickers', toggle_store_stickers)) - dispatcher.add_handler(CommandHandler('toggle_text_direction', toggle_text_direction)) - dispatcher.add_handler(delete_sticker_conversation) - dispatcher.add_handler(set_fallback_picture_conversation) - dispatcher.add_handler(build_set_timezone_conversation(dispatcher.bot)) - dispatcher.add_handler(CommandHandler('delete_fallback_picture', delete_fallback_picture)) - dispatcher.add_handler(CommandHandler('show_fallback_picture', show_fallback_picture)) + application.add_handler(CommandHandler(["start", "help"], info)) + application.add_handler(CommandHandler("toggle_store_stickers", toggle_store_stickers)) + application.add_handler(CommandHandler("toggle_text_direction", toggle_text_direction)) + application.add_handler(delete_sticker_conversation) + application.add_handler(set_fallback_picture_conversation) + application.add_handler(build_set_timezone_conversation(application.bot)) + application.add_handler(CommandHandler("delete_fallback_picture", delete_fallback_picture)) + application.add_handler(CommandHandler("show_fallback_picture", show_fallback_picture)) # functionality - dispatcher.add_handler( - MessageHandler(Filters.text & Filters.chat_type.private, sticker_message) + application.add_handler( + MessageHandler(filters.TEXT & filters.ChatType.PRIVATE, sticker_message) ) - dispatcher.add_handler( + application.add_handler( MessageHandler( - Filters.all & Filters.chat_type.private & ~Filters.via_bot(dispatcher.bot.id), + filters.ALL & filters.ChatType.PRIVATE & ~filters.ViaBot(application.bot.id), default_message, ) ) - dispatcher.add_handler(InlineQueryHandler(inline)) - dispatcher.add_handler(ChosenInlineResultHandler(handle_chosen_inline_result)) + application.add_handler(InlineQueryHandler(inline)) + application.add_handler(ChosenInlineResultHandler(handle_chosen_inline_result)) # Bot commands base_commands = [ ["help", "Displays a short info message about the Twitter Status Bot"], ["start", 'See "/help"'], - ['toggle_store_stickers', '(De)activates the saving of stickers'], - ['delete_sticker', 'Deletes one specific stored sticker'], - ['set_fallback_picture', 'Sets fallback profile picture'], - ['delete_fallback_picture', 'Deletes fallback profile picture'], - ['show_fallback_picture', 'Shows current fallback profile picture'], - ['set_timezone', 'Sets the timezone used for the stickers'], + ["toggle_store_stickers", "(De)activates the saving of stickers"], + ["delete_sticker", "Deletes one specific stored sticker"], + ["set_fallback_picture", "Sets fallback profile picture"], + ["delete_fallback_picture", "Deletes fallback profile picture"], + ["show_fallback_picture", "Shows current fallback profile picture"], + ["set_timezone", "Sets the timezone used for the stickers"], [ - 'toggle_text_direction', - 'Changes sticker text direction from right-to-left to left-to-right and vice versa', + "toggle_text_direction", + "Changes sticker text direction from right-to-left to left-to-right and vice versa", ], ] admin_commands = [ @@ -117,14 +137,14 @@ def setup_dispatcher( ["text", "Show Statistics for text requests"], ] - dispatcher.bot.set_my_commands(base_commands) - # For the admin, we show stats commands + await application.bot.set_my_commands(base_commands) - dispatcher.bot.set_my_commands( + # For the admin we show stats commands + await application.bot.set_my_commands( admin_commands + base_commands, scope=BotCommandScopeChat(chat_id=admin_id), ) # Bot data - dispatcher.bot_data[ADMIN_KEY] = admin_id - dispatcher.bot_data[STICKER_CHAT_ID_KEY] = sticker_chat_id + application.bot_data[ADMIN_KEY] = admin_id + application.bot_data[STICKER_CHAT_ID_KEY] = sticker_chat_id diff --git a/bot/twitter.py b/bot/twitter.py index 5500e43..11408e0 100644 --- a/bot/twitter.py +++ b/bot/twitter.py @@ -2,38 +2,37 @@ """Methods for creating the stickers.""" import datetime as dtm import logging +from asyncio import Event from io import BytesIO -from threading import Event -from typing import Union, cast, Optional +from typing import Optional, Union, cast import pytz +from PIL import Image, ImageDraw, ImageFilter, ImageFont, features from telegram import User -from PIL import Image, ImageDraw, ImageFont, ImageFilter, features -from textwrap2 import fill +from hyphen.textwrap2 import fill from bot.constants import ( - HEADER_TEMPLATE, - FOOTER_TEMPLATE, + BIG_TEXT_FONT, BODY_TEMPLATE, - VERIFIED_IMAGE, - TEXT_MAIN, - TEXT_SECONDARY, FALLBACK_PROFILE_PICTURE, - HEADERS_DIRECTORY, FOOTER_FONT, - USER_NAME_FONT, - USER_HANDLE_FONT, - BIG_TEXT_FONT, - SMALL_TEXT_FONT, + FOOTER_TEMPLATE, + HEADER_TEMPLATE, + HEADERS_DIRECTORY, HYPHENATOR, LTR, + SMALL_TEXT_FONT, + TEXT_MAIN, + TEXT_SECONDARY, + USER_HANDLE_FONT, + USER_NAME_FONT, + VERIFIED_IMAGE, ) from bot.userdata import CCT, UserData - logger = logging.getLogger(__name__) -TEXT_DIRECTION_SUPPORT = features.check('raqm') +TEXT_DIRECTION_SUPPORT = features.check("raqm") class HyphenationError(Exception): @@ -49,8 +48,8 @@ def __init__(self) -> None: def _check_event(event: Event = None) -> None: if event and event.is_set(): - logger.debug('Sticker creation terminated because event was set') - raise RuntimeError('Sticker creation terminated because event was set') + logger.debug("Sticker creation terminated because event was set") + raise RuntimeError("Sticker creation terminated because event was set") def mask_circle_transparent(image: Union[Image.Image, str]) -> Image.Image: @@ -201,7 +200,7 @@ def build_header( # pylint: disable=R0914 return background -def get_header( # pylint: disable = R0914 +async def get_header( # pylint: disable = R0914 user: User, context: CCT, event: Event = None ) -> Image.Image: """ @@ -226,11 +225,10 @@ def get_header( # pylint: disable = R0914 user_data = cast(UserData, context.user_data) _check_event(event) - profile_photos = bot.get_user_profile_photos(user.id, limit=1) - if profile_photos and profile_photos.total_count > 0: - photo = profile_photos.photos[0][0] - photo_file_id: Optional[str] = photo.file_id - photo_file_unique_id: Optional[str] = photo.file_unique_id + chat_photo = (await bot.get_chat(user.id)).photo + if chat_photo: + photo_file_id: Optional[str] = chat_photo.small_file_id + photo_file_unique_id: Optional[str] = chat_photo.small_file_unique_id else: photo_file_id = None photo_file_unique_id = None @@ -252,7 +250,7 @@ def get_header( # pylint: disable = R0914 except Exception: # pylint: disable=W0703 # If saving failed, we need to create a new one logger.debug( - 'Opening existing header for user %s failed. Building new header.', user.id + "Opening existing header for user %s failed. Building new header.", user.id ) else: drop_stored_stickers = True @@ -262,10 +260,10 @@ def get_header( # pylint: disable = R0914 file_unique_id = photo_file_unique_id or fallback_unique_id if file_id: _check_event(event) - photo_file = bot.get_file(file_id) + photo_file = await bot.get_file(file_id) picture_stream = BytesIO() _check_event(event) - photo_file.download(out=picture_stream) + await photo_file.download(out=picture_stream) picture_stream.seek(0) _check_event(event) user_picture = Image.open(picture_stream) @@ -295,11 +293,11 @@ def build_body(text: str, text_direction: str = LTR, event: Event = None) -> Ima max_chars_per_line = 26 max_pixels_per_line = 450 - kwargs = {'direction': text_direction} if TEXT_DIRECTION_SUPPORT else {} + kwargs = {"direction": text_direction} if TEXT_DIRECTION_SUPPORT else {} left = 27 if text_direction == LTR else 512 - 27 - kwargs['anchor'] = 'la' if text_direction == LTR else 'ra' - kwargs['align'] = 'left' if text_direction == LTR else 'right' - kwargs['fill'] = TEXT_MAIN + kwargs["anchor"] = "la" if text_direction == LTR else "ra" + kwargs["align"] = "left" if text_direction == LTR else "right" + kwargs["fill"] = TEXT_MAIN def single_line_text(position, text_, font, background_): # type: ignore _check_event(event) @@ -353,18 +351,18 @@ def multiline_text(position, text_, background_): # type: ignore return background -def build_sticker(text: str, user: User, context: CCT, event: Event = None) -> Image.Image: +async def build_sticker(text: str, user: User, context: CCT, event: Event = None) -> Image.Image: """Builds the sticker. Arguments: text: Text of the tweet. user: The user the sticker is generated for. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. event: Optional. If passed, ``event.is_set()`` will be checked before the time consuming parts of the sticker creation and if the event is set, the creation will be terminated. """ user_data = cast(UserData, context.user_data) - header = get_header(user, context, event=event) + header = await get_header(user, context, event=event) body = build_body(text, event=event, text_direction=user_data.text_direction) footer = build_footer(event=event, timezone=user_data.tzinfo) diff --git a/bot/userdata.py b/bot/userdata.py index d801b4e..23e2b77 100644 --- a/bot/userdata.py +++ b/bot/userdata.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 """A custom user data class.""" -from threading import Thread, Event -from typing import Optional, Dict, Tuple, Callable, Union +from asyncio import Event, Task +from typing import Callable, Dict, Optional, Tuple, Union -from telegram import User, PhotoSize -from telegram.ext import CallbackContext +from telegram import PhotoSize, User +from telegram.ext import CallbackContext, ExtBot from bot.constants import LTR @@ -37,28 +37,28 @@ class UserData: # pylint: disable=R0902 use. temp_file_ids: Temporarily saved file ids of stickers generated by the user. store_stickers: Whether or not this users wants to store stickers. Defaults to :obj:`True`. - inline_query_thread: Optional. A :class:`threading.Thread` which answers the current inline + inline_query_task: Optional. A :class:`asyncio.Task` which answers the current inline query. - inline_query_event: Optional. An :class:`threading.Event`. Setting this event will cause - :attr:`inline_query_thread` to terminate. + inline_query_event: Optional. An :class:`tasking.Event`. Setting this event will cause + :attr:`inline_query_task` to terminate. tzinfo: Users timezone. text_direction: Users preferred text direction. """ __slots__ = ( - 'user_id', - 'username', - 'first_name', - 'full_name', - 'photo_file_unique_id', - 'fallback_photo', - 'sticker_file_ids', - 'temp_file_ids', - 'store_stickers', - 'inline_query_thread', - 'inline_query_event', - 'tzinfo', - 'text_direction', + "user_id", + "username", + "first_name", + "full_name", + "photo_file_unique_id", + "fallback_photo", + "sticker_file_ids", + "temp_file_ids", + "store_stickers", + "inline_query_task", + "inline_query_event", + "tzinfo", + "text_direction", ) def __init__( # pylint: disable=R0913 @@ -72,7 +72,7 @@ def __init__( # pylint: disable=R0913 sticker_file_ids: Dict[str, str] = None, temp_file_ids: Dict[str, Tuple[str, str]] = None, store_stickers: Optional[bool] = True, - tzinfo: str = 'UTC', + tzinfo: str = "UTC", text_direction: str = LTR, ): self.user_id = user_id @@ -88,7 +88,7 @@ def __init__( # pylint: disable=R0913 self.sticker_file_ids: Dict[str, str] = sticker_file_ids or {} self.temp_file_ids: Dict[str, Tuple[str, str]] = temp_file_ids or {} - self.inline_query_thread: Optional[Thread] = None + self.inline_query_task: Optional[Task] = None self.inline_query_event: Optional[Event] = None def update_user_info(self, user: User, photo_file_unique_id: Optional[str]) -> None: @@ -115,11 +115,11 @@ def update_fallback_photo(self, fallback_photo: Optional[PhotoSize]) -> None: def __getattr__(self, item: str) -> Union[None, str]: # Only called when `self.item` raises AttributeError # But pickle calls it for some magic methods, too … - if item == 'tzinfo': - return 'UTC' - if item == 'text_direction': + if item == "tzinfo": + return "UTC" + if item == "text_direction": return LTR - if item.startswith('__') and item.endswith('__'): + if item.startswith("__") and item.endswith("__"): return super().__getattr__(item) # type: ignore[misc] # pylint: disable=E1101 return None @@ -150,10 +150,10 @@ def __getattr__(self, item: str) -> Union[None, str]: str, ] - # We override __reduce__ to amend for the unpickable thread & event + # We override __reduce__ to amend for the unpickable task & event def __reduce__( self, - ) -> Tuple[Callable[_CALLABLE_ARGS, 'UserData'], _INIT_ARGS]: # type: ignore[misc] + ) -> Tuple[Callable[_CALLABLE_ARGS, "UserData"], _INIT_ARGS]: # type: ignore[misc] return self.__class__, ( self.user_id, self.username, @@ -169,5 +169,5 @@ def __reduce__( ) -CCT = CallbackContext[UserData, dict, dict] +CCT = CallbackContext[ExtBot, UserData, dict, dict] # type: ignore[misc] """Type alias for :class:`telegram.ext.CallbackContext` with :class:`UserData` as user data.""" diff --git a/bot/utils.py b/bot/utils.py index af0e564..b009fc0 100644 --- a/bot/utils.py +++ b/bot/utils.py @@ -1,14 +1,10 @@ #!/usr/bin/env python3 """Utility methods for the bot functionality.""" +from asyncio import Event from io import BytesIO -from threading import Event from typing import cast -from telegram import ( - Update, - User, - Message, -) +from telegram import Message, Update, User from telegram.ext import ConversationHandler, TypeHandler from bot.constants import REMOVE_KEYBOARD_KEY @@ -16,14 +12,16 @@ from bot.userdata import CCT -def get_sticker_photo_stream(text: str, user: User, context: CCT, event: Event = None) -> BytesIO: +async def get_sticker_photo_stream( + text: str, user: User, context: CCT, event: Event = None +) -> BytesIO: """ Gives the sticker ID for the requested sticker. Args: text: The text to display on the tweet. user: The user the tweet is created for. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. event: Optional. If passed, ``event.is_set()`` will be checked before the time consuming parts of the sticker creation and if the event is set, the creation will be terminated. @@ -31,53 +29,53 @@ def get_sticker_photo_stream(text: str, user: User, context: CCT, event: Event = Tuple[str, str]: The stickers unique file ID and file ID """ sticker_stream = BytesIO() - sticker = build_sticker(text, user, context, event=event) + sticker = await build_sticker(text, user, context, event=event) sticker.save(sticker_stream, format="PNG") sticker_stream.seek(0) return sticker_stream -def default_message(update: Update, _: CCT) -> None: +async def default_message(update: Update, _: CCT) -> None: """ Answers any message with a note that it could not be parsed. Args: update: The Telegram update. - _: The callback context as provided by the dispatcher. + _: The callback context as provided by the application. """ - cast(Message, update.effective_message).reply_text( + await cast(Message, update.effective_message).reply_text( "Sorry, but I can only text messages. " 'Send "/help" for more information.' ) -def remove_reply_markup(context: CCT) -> None: +async def remove_reply_markup(context: CCT) -> None: """Removes the reply markup of the message stored in ``context.chat_data[REMOVE_KEYBOARD]``, if any. Args: - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. """ if not context.chat_data: return message = context.chat_data.get(REMOVE_KEYBOARD_KEY, None) if isinstance(message, Message): - message.edit_reply_markup(None) + await message.edit_reply_markup(None) -def conversation_timeout(update: Update, context: CCT) -> int: +async def conversation_timeout(update: Update, context: CCT) -> int: """Informs the user that the operation has timed out, calls :meth:`remove_reply_markup` and ends the conversation. Args: update: The Telegram update. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. Returns: int: :attr:`telegram.ext.ConversationHandler.END`. """ - cast(User, update.effective_user).send_message('Operation timed out. Aborting.') - remove_reply_markup(context) + await cast(User, update.effective_user).send_message("Operation timed out. Aborting.") + await remove_reply_markup(context) return ConversationHandler.END @@ -87,19 +85,19 @@ def conversation_timeout(update: Update, context: CCT) -> int: conversations.""" -def conversation_fallback(update: Update, context: CCT) -> int: +async def conversation_fallback(update: Update, context: CCT) -> int: """Informs the user that the input was invalid, calls :meth:`remove_reply_markup` and ends the conversation. Args: update: The Telegram update. - context: The callback context as provided by the dispatcher. + context: The callback context as provided by the application. Returns: int: The next state. """ - cast(User, update.effective_user).send_message('Invalid input. Aborting operation.') - remove_reply_markup(context) + await cast(User, update.effective_user).send_message("Invalid input. Aborting operation.") + await remove_reply_markup(context) return ConversationHandler.END diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 489fc1c..d8d6c8f 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,3 +1,3 @@ -sphinx==3.5.1 -sphinx_rtd_theme==0.5.1 -sphinx_autodoc_typehints==1.11.1 +sphinx==4.5.0 +furo==2022.4.7 +sphinx_autodoc_typehints==1.18.1 diff --git a/docs/source/conf.py b/docs/source/conf.py index 25ccdd5..fc09758 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -35,14 +35,13 @@ "sphinx.ext.intersphinx", "sphinx.ext.napoleon", "sphinx.ext.coverage", - "sphinx_rtd_theme", "sphinx_autodoc_typehints", ] # Use intersphinx to reference the python-telegram-bot docs intersphinx_mapping = { - "telegram": ("https://python-telegram-bot.readthedocs.io/en/latest/", None), - "https://docs.python.org/": None, + "telegram": ("https://docs.python-telegram-bot.org/en/v20.0a0/", None), + "https://docs.python.org/3/": None, } # Include special members in doc @@ -66,9 +65,9 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = "sphinx_rtd_theme" +html_theme = "furo" html_theme_options = { - "style_nav_header_background": "#177fbfff", + "navigation_with_keys": True, } # Add any paths that contain custom static files (such as style sheets) here, @@ -84,15 +83,3 @@ # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. html_favicon = "../../logo/TwitterStatusBot.ico" - - -# Link to the correct file -html_context = { - "display_gitlab": True, - "gitlab_host": "gitlab.com", - "gitlab_user": "HirschHeissIch", - "gitlab_repo": "twitter-status-bot", - "gitlab_version": "master", - "conf_py_path": "/docs/source/", - "source_suffix": ".rst", -} diff --git a/main.py b/main.py index 8f4b9ea..444cb2d 100644 --- a/main.py +++ b/main.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 """The script that runs the bot.""" +import asyncio import logging from configparser import ConfigParser -from telegram import ParseMode -from telegram.ext import Updater, Defaults, PicklePersistence, ContextTypes +from telegram.constants import ParseMode +from telegram.ext import Application, ContextTypes, Defaults, PersistenceInput, PicklePersistence -from bot.setup import setup_dispatcher +from bot.setup import setup_application from bot.userdata import UserData # Enable logging @@ -15,7 +16,7 @@ level=logging.INFO, filename="tsb.log", ) -aps_logger = logging.getLogger('apscheduler') +aps_logger = logging.getLogger("apscheduler") aps_logger.setLevel(logging.WARNING) logger = logging.getLogger(__name__) @@ -30,26 +31,30 @@ def main() -> None: admin_id = int(config["TwitterStatusBot"]["admins_chat_id"]) sticker_chat_id = config["TwitterStatusBot"]["sticker_chat_id"] - defaults = Defaults(parse_mode=ParseMode.HTML, disable_notification=True, run_async=True) + defaults = Defaults(parse_mode=ParseMode.HTML, disable_notification=True, block=False) context_types = ContextTypes(user_data=UserData) persistence = PicklePersistence( - "tsb.pickle", context_types=context_types, single_file=False, store_chat_data=False - ) - updater = Updater( - token, - defaults=defaults, - persistence=persistence, - workers=8, + "tsb.pickle", context_types=context_types, + single_file=False, + store_data=PersistenceInput(chat_data=False), + ) + application = ( + Application.builder() + .token(token) + .defaults(defaults) + .persistence(persistence) + .context_types(context_types) + .build() ) - # Get the dispatcher to register handlers - dispatcher = updater.dispatcher - setup_dispatcher(dispatcher, admin_id, sticker_chat_id) + # Get the application to register handlers + asyncio.get_event_loop().run_until_complete( + setup_application(application, admin_id, sticker_chat_id) + ) # Start the Bot - updater.start_polling(drop_pending_updates=True) - updater.idle() + application.run_polling(drop_pending_updates=True, close_loop=False) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index ec2c29d..2c3128f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,7 @@ [tool.black] line-length = 99 -target-version = ['py36'] -skip-string-normalization = true -include = '^/.*\.py$' \ No newline at end of file +target-version = ['py37', 'py38', 'py39', 'py310'] + +[tool.isort] # black config +profile = "black" +line_length = 99 \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index a0acebb..f9b7748 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,8 +1,4 @@ pre-commit # Make sure that the versions specified here match the pre-commit settings! -black==20.8b1 -flake8==3.9.2 -pylint==2.10.2 -mypy==0.910 -types-pytz -pyupgrade==2.25.0 \ No newline at end of file +black==22.3.0 +isort==5.10.1 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 70df1d4..0619670 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -python-telegram-bot>=13.7,<14.0 -Pillow==8.3.0 -PyHyphen==3.0.1 +python-telegram-bot==20.0a0 +Pillow==9.1.1 +PyHyphen==4.0.3 pytz -fuzzywuzzy==0.18.0 -git+https://gitlab.com/HirschHeissIch/ptbstats.git@v1.3.1 \ No newline at end of file +thefuzz==0.19.0 +git+https://github.com/Bibo-Joshi/ptbstats.git@v2.0 \ No newline at end of file