Bot API 5.7 and some new features (#834)

* Update API, added some new features

* Fixed unknown chat_action value

* Separate events from dispatcher messages

* Disabled cache for I18n LazyProxy

* Rework events isolation

* Added chat member status changed filter, update Bot API 5.7, other small changes

* Improve exceptions in chat member status filter

* Fixed tests, covered flags and events isolation modules

* Try to fix flake8 unused type ignore

* Fixed linter error

* Cover chat member updated filter

* Cover chat action sender

* Added docs for chat action util

* Try to fix tests for python <= 3.9

* Fixed headers

* Added docs for flags functionality

* Added docs for chat_member_updated filter

* Added change notes

* Update dependencies and fix mypy checks

* Bump version
This commit is contained in:
Alex Root Junior 2022-02-19 01:45:59 +02:00 committed by GitHub
parent ac7f2dc408
commit 7776cf9cf6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
77 changed files with 2485 additions and 502 deletions

View file

@ -0,0 +1,347 @@
import asyncio
import logging
import time
from asyncio import Event, Lock
from contextlib import suppress
from types import TracebackType
from typing import Any, Awaitable, Callable, Dict, Optional, Type, Union
from aiogram import BaseMiddleware, Bot
from aiogram.dispatcher.flags.getter import get_flag
from aiogram.types import Message, TelegramObject
logger = logging.getLogger(__name__)
DEFAULT_INTERVAL = 5.0
DEFAULT_INITIAL_SLEEP = 0.1
class ChatActionSender:
"""
This utility helps to automatically send chat action until long actions is done
to take acknowledge bot users the bot is doing something and not crashed.
Provides simply to use context manager.
Technically sender start background task with infinity loop which works
until action will be finished and sends the `chat action <https://core.telegram.org/bots/api#sendchataction>`_
every 5 seconds.
"""
def __init__(
self,
*,
chat_id: Union[str, int],
action: str = "typing",
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
bot: Optional[Bot] = None,
) -> None:
"""
:param chat_id: target chat id
:param action: chat action type
:param interval: interval between iterations
:param initial_sleep: sleep before first iteration
:param bot: instance of the bot, can be omitted from the context
"""
if bot is None:
bot = Bot.get_current(False)
self.chat_id = chat_id
self.action = action
self.interval = interval
self.initial_sleep = initial_sleep
self.bot = bot
self._lock = Lock()
self._close_event = Event()
self._closed_event = Event()
self._task: Optional[asyncio.Task[Any]] = None
@property
def running(self) -> bool:
return bool(self._task)
async def _wait(self, interval: float) -> None:
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(self._close_event.wait(), interval)
async def _worker(self) -> None:
logger.debug(
"Started chat action %r sender in chat_id=%s via bot id=%d",
self.action,
self.chat_id,
self.bot.id,
)
try:
counter = 0
await self._wait(self.initial_sleep)
while not self._close_event.is_set():
start = time.monotonic()
logger.debug(
"Sent chat action %r to chat_id=%s via bot %d (already sent actions %d)",
self.action,
self.chat_id,
self.bot.id,
counter,
)
await self.bot.send_chat_action(chat_id=self.chat_id, action=self.action)
counter += 1
interval = self.interval - (time.monotonic() - start)
await self._wait(interval)
finally:
logger.debug(
"Finished chat action %r sender in chat_id=%s via bot id=%d",
self.action,
self.chat_id,
self.bot.id,
)
self._closed_event.set()
async def _run(self) -> None:
async with self._lock:
self._close_event.clear()
self._closed_event.clear()
if self.running:
raise RuntimeError("Already running")
self._task = asyncio.create_task(self._worker())
async def _stop(self) -> None:
async with self._lock:
if not self.running:
return
if not self._close_event.is_set():
self._close_event.set()
await self._closed_event.wait()
self._task = None
async def __aenter__(self) -> "ChatActionSender":
await self._run()
return self
async def __aexit__(
self,
exc_type: Optional[Type[BaseException]],
exc_value: Optional[BaseException],
traceback: Optional[TracebackType],
) -> Any:
await self._stop()
@classmethod
def typing(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `typing` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="typing",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def upload_photo(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `upload_photo` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="upload_photo",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def record_video(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `record_video` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="record_video",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def upload_video(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `upload_video` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="upload_video",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def record_voice(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `record_voice` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="record_voice",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def upload_voice(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `upload_voice` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="upload_voice",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def upload_document(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `upload_document` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="upload_document",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def choose_sticker(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `choose_sticker` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="choose_sticker",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def find_location(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `find_location` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="find_location",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def record_video_note(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `record_video_note` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="record_video_note",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def upload_video_note(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `upload_video_note` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="upload_video_note",
interval=interval,
initial_sleep=initial_sleep,
)
class ChatActionMiddleware(BaseMiddleware):
"""
Helps to automatically use chat action sender for all message handlers
"""
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any],
) -> Any:
if not isinstance(event, Message):
return await handler(event, data)
bot = data["bot"]
chat_action = get_flag(data, "chat_action") or "typing"
kwargs = {}
if isinstance(chat_action, dict):
if initial_sleep := chat_action.get("initial_sleep"):
kwargs["initial_sleep"] = initial_sleep
if interval := chat_action.get("interval"):
kwargs["interval"] = interval
if action := chat_action.get("action"):
kwargs["action"] = action
elif isinstance(chat_action, bool):
kwargs["action"] = "typing"
else:
kwargs["action"] = chat_action
async with ChatActionSender(bot=bot, chat_id=event.chat.id, **kwargs):
return await handler(event, data)

View file

@ -16,7 +16,7 @@ def gettext(*args: Any, **kwargs: Any) -> str:
def lazy_gettext(*args: Any, **kwargs: Any) -> LazyProxy:
return LazyProxy(gettext, *args, **kwargs)
return LazyProxy(gettext, *args, **kwargs, enable_cache=False)
ngettext = gettext

View file

@ -118,4 +118,6 @@ class I18n(ContextInstanceMixin["I18n"]):
def lazy_gettext(
self, singular: str, plural: Optional[str] = None, n: int = 1, locale: Optional[str] = None
) -> LazyProxy:
return LazyProxy(self.gettext, singular=singular, plural=plural, n=n, locale=locale)
return LazyProxy(
self.gettext, singular=singular, plural=plural, n=n, locale=locale, enable_cache=False
)

View file

@ -2,10 +2,14 @@ from abc import ABC, abstractmethod
from typing import Any, Awaitable, Callable, Dict, Optional, Set, cast
try:
from babel import Locale
from babel import Locale, UnknownLocaleError
except ImportError: # pragma: no cover
Locale = None
class UnknownLocaleError(Exception): # type: ignore
pass
from aiogram import BaseMiddleware, Router
from aiogram.dispatcher.fsm.context import FSMContext
from aiogram.types import TelegramObject, User
@ -116,7 +120,11 @@ class SimpleI18nMiddleware(I18nMiddleware):
event_from_user: Optional[User] = data.get("event_from_user", None)
if event_from_user is None:
return self.i18n.default_locale
locale = Locale.parse(event_from_user.language_code, sep="-")
try:
locale = Locale.parse(event_from_user.language_code, sep="-")
except UnknownLocaleError:
return self.i18n.default_locale
if locale.language not in self.i18n.available_locales:
return self.i18n.default_locale
return cast(str, locale.language)

View file

@ -1,7 +1,7 @@
from __future__ import annotations
import contextvars
from typing import TYPE_CHECKING, Any, ClassVar, Dict, Generic, Optional, TypeVar, cast, overload
from typing import TYPE_CHECKING, Any, Dict, Generic, Optional, TypeVar, cast, overload
if TYPE_CHECKING:
from typing_extensions import Literal
@ -38,7 +38,7 @@ ContextInstance = TypeVar("ContextInstance")
class ContextInstanceMixin(Generic[ContextInstance]):
__context_instance: ClassVar[contextvars.ContextVar[ContextInstance]]
__context_instance: contextvars.ContextVar[ContextInstance]
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__()