diff --git a/README.md b/README.md index 74be4c16..603f3920 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![PyPi status](https://img.shields.io/pypi/status/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![Downloads](https://img.shields.io/pypi/dm/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![Supported python versions](https://img.shields.io/pypi/pyversions/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) -[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-6.3-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api) +[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-6.4-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api) [![Documentation Status](https://img.shields.io/readthedocs/aiogram?style=flat-square)](http://docs.aiogram.dev/en/latest/?badge=latest) [![Github issues](https://img.shields.io/github/issues/aiogram/aiogram.svg?style=flat-square)](https://github.com/aiogram/aiogram/issues) [![MIT License](https://img.shields.io/pypi/l/aiogram.svg?style=flat-square)](https://opensource.org/licenses/MIT) diff --git a/README.rst b/README.rst index a7983ce9..0d28a539 100644 --- a/README.rst +++ b/README.rst @@ -21,7 +21,7 @@ aiogram :target: https://pypi.python.org/pypi/aiogram :alt: Supported python versions -.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-6.3-blue.svg?style=flat-square&logo=telegram +.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-6.4-blue.svg?style=flat-square&logo=telegram :target: https://core.telegram.org/bots/api :alt: Telegram Bot API diff --git a/aiogram/__init__.py b/aiogram/__init__.py index aa40e62f..9efb25a1 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -43,5 +43,5 @@ __all__ = ( 'utils', ) -__version__ = '2.23.1' -__api_version__ = '6.3' +__version__ = '2.25.1' +__api_version__ = '6.5' diff --git a/aiogram/bot/api.py b/aiogram/bot/api.py index 29311d73..ef1d24be 100644 --- a/aiogram/bot/api.py +++ b/aiogram/bot/api.py @@ -259,6 +259,11 @@ class Methods(Helper): REOPEN_FORUM_TOPIC = Item() # reopenForumTopic DELETE_FORUM_TOPIC = Item() # deleteForumTopic UNPIN_ALL_FORUM_TOPIC_MESSAGES = Item() # unpinAllForumTopicMessages + EDIT_GENERAL_FORUM_TOPIC = Item() # editGeneralForumTopic + CLOSE_GENERAL_FORUM_TOPIC = Item() # closeGeneralForumTopic + REOPEN_GENERAL_FORUM_TOPIC = Item() # reopenGeneralForumTopic + HIDE_GENERAL_FORUM_TOPIC = Item() # hideGeneralForumTopic + UNHIDE_GENERAL_FORUM_TOPIC = Item() # unhideGeneralForumTopic ANSWER_CALLBACK_QUERY = Item() # answerCallbackQuery SET_MY_COMMANDS = Item() # setMyCommands DELETE_MY_COMMANDS = Item() # deleteMyCommands diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index aabbbfbe..54aad263 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -498,6 +498,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None] = None, + has_spoiler: typing.Optional[base.Boolean] = None, ) -> types.Message: """ Use this method to send photos. @@ -544,6 +545,9 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None]` + :param has_spoiler: Pass True if the photo needs to be covered with a spoiler animation + :type has_spoiler: :obj:`typing.Optional[base.Boolean]` + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ @@ -776,6 +780,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None] = None, + has_spoiler: typing.Optional[base.Boolean] = None, ) -> types.Message: """ Use this method to send video files, Telegram clients support mp4 videos @@ -838,6 +843,9 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None]` + :param has_spoiler: Pass True if the video needs to be covered with a spoiler animation + :type has_spoiler: :obj:`typing.Optional[base.Boolean]` + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ @@ -875,6 +883,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply], None] = None, + has_spoiler: typing.Optional[base.Boolean] = None, ) -> types.Message: """ Use this method to send animation files (GIF or H.264/MPEG-4 AVC video without sound). @@ -940,6 +949,9 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :type reply_markup: :obj:`typing.Union[typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply], None]` + :param has_spoiler: Pass True if the animation needs to be covered with a spoiler animation + :type has_spoiler: :obj:`typing.Optional[base.Boolean]` + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ @@ -1711,7 +1723,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): return types.Message(**result) async def send_chat_action(self, chat_id: typing.Union[base.Integer, base.String], - action: base.String) -> base.Boolean: + action: base.String, message_thread_id: typing.Optional[base.Integer] = None) -> base.Boolean: """ Use this method when you need to tell the user that something is happening on the bot's side. The status is set for 5 seconds or @@ -1743,6 +1755,9 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): `upload_video_note` for video notes. :type action: :obj:`base.String` + :param message_thread_id: Unique identifier for the target message thread; supergroups only + :type message_thread_id: :obj:`typing.Optional[base.Integer]` + :return: Returns True on success :rtype: :obj:`base.Boolean` """ @@ -1885,16 +1900,20 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): return await self.request(api.Methods.UNBAN_CHAT_MEMBER, payload) - async def restrict_chat_member(self, chat_id: typing.Union[base.Integer, base.String], - user_id: base.Integer, - permissions: typing.Optional[types.ChatPermissions] = None, - # permissions argument need to be required after removing other `can_*` arguments - until_date: typing.Union[ - base.Integer, datetime.datetime, datetime.timedelta, None] = None, - can_send_messages: typing.Optional[base.Boolean] = None, - can_send_media_messages: typing.Optional[base.Boolean] = None, - can_send_other_messages: typing.Optional[base.Boolean] = None, - can_add_web_page_previews: typing.Optional[base.Boolean] = None) -> base.Boolean: + async def restrict_chat_member( + self, + chat_id: typing.Union[base.Integer, base.String], + user_id: base.Integer, + permissions: typing.Optional[types.ChatPermissions], + use_independent_chat_permissions: typing.Optional[base.Boolean] = None, + # permissions argument need to be required after removing other `can_*` arguments + until_date: typing.Union[ + base.Integer, datetime.datetime, datetime.timedelta, None] = None, + can_send_messages: typing.Optional[base.Boolean] = None, + can_send_media_messages: typing.Optional[base.Boolean] = None, + can_send_other_messages: typing.Optional[base.Boolean] = None, + can_add_web_page_previews: typing.Optional[base.Boolean] = None, + ) -> base.Boolean: """ Use this method to restrict a user in a supergroup. The bot must be an administrator in the supergroup for this to work and must have the appropriate admin rights. @@ -1908,6 +1927,15 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :type user_id: :obj:`base.Integer` :param permissions: New user permissions :type permissions: :obj:`ChatPermissions` + :param use_independent_chat_permissions: Pass True if chat + permissions are set independently. Otherwise, + the can_send_other_messages and can_add_web_page_previews + permissions will imply the can_send_messages, + can_send_audios, can_send_documents, can_send_photos, + can_send_videos, can_send_video_notes, and + can_send_voice_notes permissions; the can_send_polls + permission will imply the can_send_messages permission. + :type use_independent_chat_permissions: :obj:`typing.Optional[base.Boolean]` :param until_date: Date when restrictions will be lifted for the user, unix time :type until_date: :obj:`typing.Optional[base.Integer]` :param can_send_messages: Pass True, if the user can send text messages, contacts, locations and venues @@ -2091,8 +2119,12 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): return await self.request(api.Methods.UNBAN_CHAT_SENDER_CHAT, payload) - async def set_chat_permissions(self, chat_id: typing.Union[base.Integer, base.String], - permissions: types.ChatPermissions) -> base.Boolean: + async def set_chat_permissions( + self, + chat_id: typing.Union[base.Integer, base.String], + permissions: types.ChatPermissions, + use_independent_chat_permissions: base.Boolean = None, + ) -> base.Boolean: """ Use this method to set default chat permissions for all members. The bot must be an administrator in the group or a supergroup for this to work and must have the @@ -2102,6 +2134,15 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :param chat_id: Unique identifier for the target chat or username of the target supergroup :param permissions: New default chat permissions + :param use_independent_chat_permissions: Pass True if chat + permissions are set independently. Otherwise, + the can_send_other_messages and can_add_web_page_previews + permissions will imply the can_send_messages, + can_send_audios, can_send_documents, can_send_photos, + can_send_videos, can_send_video_notes, and + can_send_voice_notes permissions; the can_send_polls + permission will imply the can_send_messages permission. + :type use_independent_chat_permissions: :obj:`typing.Optional[base.Boolean]` :return: True on success. """ permissions = prepare_arg(permissions) @@ -2458,6 +2499,97 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): return await self.request(api.Methods.UNPIN_ALL_CHAT_MESSAGES, payload) + async def close_general_forum_topic( + self, + chat_id: typing.Union[base.Integer, base.String], + ) -> base.Boolean: + """ + Use this method to close an open 'General' topic in a forum supergroup chat. + The bot must be an administrator in the chat for this to work and must have the *can_manage_topics* administrator rights. + + Returns :code:`True` on success. + + :param chat_id: Unique identifier for the target chat or username of the target supergroup (in the format :code:`@supergroupusername`) + :return: Returns :code:`True` on success. + """ + + payload = generate_payload(**locals()) + + return await self.request(api.Methods.CLOSE_GENERAL_FORUM_TOPIC, payload) + + async def edit_general_forum_topic( + self, + chat_id: typing.Union[base.Integer, base.String], + name: base.String, + ) -> base.Boolean: + """ + Use this method to edit the name of the 'General' topic in a forum supergroup chat. + The bot must be an administrator in the chat for this to work and must have *can_manage_topics* administrator rights. + + Returns :code:`True` on success. + + :param chat_id: Unique identifier for the target chat or username of the target supergroup (in the format :code:`@supergroupusername`) + :param name: New topic name, 1-128 characters + :return: Returns :code:`True` on success. + """ + + payload = generate_payload(**locals()) + + return await self.request(api.Methods.EDIT_GENERAL_FORUM_TOPIC, payload) + + async def hide_general_forum_topic( + self, + chat_id: typing.Union[base.Integer, base.String], + ) -> base.Boolean: + """ + Use this method to hide the 'General' topic in a forum supergroup chat. + The bot must be an administrator in the chat for this to work and must have the *can_manage_topics* administrator rights. + + The topic will be automatically closed if it was open. Returns :code:`True` on success. + + :param chat_id: Unique identifier for the target chat or username of the target supergroup (in the format :code:`@supergroupusername`) + :return: Returns :code:`True` on success. + """ + + payload = generate_payload(**locals()) + + return await self.request(api.Methods.HIDE_GENERAL_FORUM_TOPIC, payload) + + async def reopen_general_forum_topic( + self, + chat_id: typing.Union[base.Integer, base.String], + ) -> base.Boolean: + """ + Use this method to reopen a closed 'General' topic in a forum supergroup chat. + The bot must be an administrator in the chat for this to work and must have the *can_manage_topics* administrator rights. + The topic will be automatically unhidden if it was hidden. Returns :code:`True` on success. + + :param chat_id: Unique identifier for the target chat or username of the target supergroup (in the format :code:`@supergroupusername`) + :return: Returns :code:`True` on success. + """ + + payload = generate_payload(**locals()) + + return await self.request(api.Methods.REOPEN_GENERAL_FORUM_TOPIC, payload) + + async def unhide_general_forum_topic( + self, + chat_id: typing.Union[base.Integer, base.String], + ) -> base.Boolean: + """ + Use this method to unhide the 'General' topic in a forum supergroup chat. + The bot must be an administrator in the chat for this to work and must have the *can_manage_topics* administrator rights. + + Returns :code:`True` on success. + + :param chat_id: Unique identifier for the target chat or username of the target supergroup (in the format :code:`@supergroupusername`) + :return: Returns :code:`True` on success. + """ + + payload = generate_payload(**locals()) + + return await self.request(api.Methods.UNPIN_ALL_CHAT_MESSAGES, payload) + async def leave_chat(self, chat_id: typing.Union[base.Integer, base.String]) -> base.Boolean: """ Use this method for your bot to leave a group, supergroup or channel. @@ -2631,7 +2763,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): return types.ForumTopic(**result) async def edit_forum_topic(self, chat_id: typing.Union[int, str], - name: base.String, + name: typing.Optional[base.String] = None, message_thread_id: typing.Optional[base.Integer] = None, icon_custom_emoji_id: typing.Optional[base.String] = None) -> base.Boolean: """ diff --git a/aiogram/contrib/fsm_storage/redis.py b/aiogram/contrib/fsm_storage/redis.py index 87d76374..da0b20d8 100644 --- a/aiogram/contrib/fsm_storage/redis.py +++ b/aiogram/contrib/fsm_storage/redis.py @@ -1,18 +1,18 @@ """ -This module has redis storage for finite-state machine based on `aioredis `_ driver +This module has redis storage for finite-state machine based on `redis `_ driver. """ import asyncio import logging import typing -from abc import ABC, abstractmethod - -import aioredis from ...dispatcher.storage import BaseStorage from ...utils import json from ...utils.deprecated import deprecated +if typing.TYPE_CHECKING: + import aioredis + STATE_KEY = 'state' STATE_DATA_KEY = 'data' STATE_BUCKET_KEY = 'bucket' @@ -67,6 +67,8 @@ class RedisStorage(BaseStorage): Get Redis connection """ # Use thread-safe asyncio Lock because this method without that is not safe + import aioredis + async with self._connection_lock: if self._redis is None or self._redis.closed: self._redis = await aioredis.create_connection((self._host, self._port), @@ -207,138 +209,6 @@ class RedisStorage(BaseStorage): await self.set_record(chat=chat, user=user, state=record['state'], data=record_bucket, bucket=bucket) -class AioRedisAdapterBase(ABC): - """Base aioredis adapter class.""" - - def __init__( - self, - host: str = "localhost", - port: int = 6379, - db: typing.Optional[int] = None, - password: typing.Optional[str] = None, - ssl: typing.Optional[bool] = None, - pool_size: int = 10, - loop: typing.Optional[asyncio.AbstractEventLoop] = None, - prefix: str = "fsm", - state_ttl: typing.Optional[int] = None, - data_ttl: typing.Optional[int] = None, - bucket_ttl: typing.Optional[int] = None, - **kwargs, - ): - self._host = host - self._port = port - self._db = db - self._password = password - self._ssl = ssl - self._pool_size = pool_size - self._kwargs = kwargs - self._prefix = (prefix,) - - self._state_ttl = state_ttl - self._data_ttl = data_ttl - self._bucket_ttl = bucket_ttl - - self._redis: typing.Optional["aioredis.Redis"] = None - self._connection_lock = asyncio.Lock() - - @abstractmethod - async def get_redis(self) -> aioredis.Redis: - """Get Redis connection.""" - pass - - async def close(self): - """Grace shutdown.""" - pass - - async def wait_closed(self): - """Wait for grace shutdown finishes.""" - pass - - async def set(self, name, value, ex=None, **kwargs): - """Set the value at key ``name`` to ``value``.""" - if ex == 0: - ex = None - return await self._redis.set(name, value, ex=ex, **kwargs) - - async def get(self, name, **kwargs): - """Return the value at key ``name`` or None.""" - return await self._redis.get(name, **kwargs) - - async def delete(self, *names): - """Delete one or more keys specified by ``names``""" - return await self._redis.delete(*names) - - async def keys(self, pattern, **kwargs): - """Returns a list of keys matching ``pattern``.""" - return await self._redis.keys(pattern, **kwargs) - - async def flushdb(self): - """Delete all keys in the current database.""" - return await self._redis.flushdb() - - -class AioRedisAdapterV1(AioRedisAdapterBase): - """Redis adapter for aioredis v1.""" - - async def get_redis(self) -> aioredis.Redis: - """Get Redis connection.""" - async with self._connection_lock: # to prevent race - if self._redis is None or self._redis.closed: - self._redis = await aioredis.create_redis_pool( - (self._host, self._port), - db=self._db, - password=self._password, - ssl=self._ssl, - minsize=1, - maxsize=self._pool_size, - **self._kwargs, - ) - return self._redis - - async def close(self): - async with self._connection_lock: - if self._redis and not self._redis.closed: - self._redis.close() - - async def wait_closed(self): - async with self._connection_lock: - if self._redis: - return await self._redis.wait_closed() - return True - - async def get(self, name, **kwargs): - return await self._redis.get(name, encoding="utf8", **kwargs) - - async def set(self, name, value, ex=None, **kwargs): - if ex == 0: - ex = None - return await self._redis.set(name, value, expire=ex, **kwargs) - - async def keys(self, pattern, **kwargs): - """Returns a list of keys matching ``pattern``.""" - return await self._redis.keys(pattern, encoding="utf8", **kwargs) - - -class AioRedisAdapterV2(AioRedisAdapterBase): - """Redis adapter for aioredis v2.""" - - async def get_redis(self) -> aioredis.Redis: - """Get Redis connection.""" - async with self._connection_lock: # to prevent race - if self._redis is None: - self._redis = aioredis.Redis( - host=self._host, - port=self._port, - db=self._db, - password=self._password, - ssl=self._ssl, - max_connections=self._pool_size, - decode_responses=True, - **self._kwargs, - ) - return self._redis - - class RedisStorage2(BaseStorage): """ Busted Redis-base storage for FSM. @@ -356,7 +226,6 @@ class RedisStorage2(BaseStorage): .. code-block:: python3 await dp.storage.close() - await dp.storage.wait_closed() """ @@ -375,75 +244,49 @@ class RedisStorage2(BaseStorage): bucket_ttl: typing.Optional[int] = None, **kwargs, ): - self._host = host - self._port = port - self._db = db - self._password = password - self._ssl = ssl - self._pool_size = pool_size - self._kwargs = kwargs - self._prefix = (prefix,) + from redis.asyncio import Redis + self._redis: typing.Optional[Redis] = Redis( + host=host, + port=port, + db=db, + password=password, + ssl=ssl, + max_connections=pool_size, + decode_responses=True, + **kwargs, + ) + + self._prefix = (prefix,) self._state_ttl = state_ttl self._data_ttl = data_ttl self._bucket_ttl = bucket_ttl - self._redis: typing.Optional[AioRedisAdapterBase] = None - self._connection_lock = asyncio.Lock() - @deprecated("This method will be removed in aiogram v3.0. " "You should use your own instance of Redis.", stacklevel=3) - async def redis(self) -> aioredis.Redis: - adapter = await self._get_adapter() - return await adapter.get_redis() - - async def _get_adapter(self) -> AioRedisAdapterBase: - """Get adapter based on aioredis version.""" - if self._redis is None: - redis_version = int(aioredis.__version__.split(".")[0]) - connection_data = dict( - host=self._host, - port=self._port, - db=self._db, - password=self._password, - ssl=self._ssl, - pool_size=self._pool_size, - **self._kwargs, - ) - if redis_version == 1: - self._redis = AioRedisAdapterV1(**connection_data) - elif redis_version == 2: - self._redis = AioRedisAdapterV2(**connection_data) - else: - raise RuntimeError(f"Unsupported aioredis version: {redis_version}") - await self._redis.get_redis() + async def redis(self) -> "aioredis.Redis": return self._redis def generate_key(self, *parts): return ':'.join(self._prefix + tuple(map(str, parts))) async def close(self): - if self._redis: - return await self._redis.close() + await self._redis.close() async def wait_closed(self): - if self._redis: - await self._redis.wait_closed() - self._redis = None + pass async def get_state(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, default: typing.Optional[str] = None) -> typing.Optional[str]: chat, user = self.check_address(chat=chat, user=user) key = self.generate_key(chat, user, STATE_KEY) - redis = await self._get_adapter() - return await redis.get(key) or self.resolve_state(default) + return await self._redis.get(key) or self.resolve_state(default) async def get_data(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, default: typing.Optional[dict] = None) -> typing.Dict: chat, user = self.check_address(chat=chat, user=user) key = self.generate_key(chat, user, STATE_DATA_KEY) - redis = await self._get_adapter() - raw_result = await redis.get(key) + raw_result = await self._redis.get(key) if raw_result: return json.loads(raw_result) return default or {} @@ -452,21 +295,19 @@ class RedisStorage2(BaseStorage): state: typing.Optional[typing.AnyStr] = None): chat, user = self.check_address(chat=chat, user=user) key = self.generate_key(chat, user, STATE_KEY) - redis = await self._get_adapter() if state is None: - await redis.delete(key) + await self._redis.delete(key) else: - await redis.set(key, self.resolve_state(state), ex=self._state_ttl) + await self._redis.set(key, self.resolve_state(state), ex=self._state_ttl) async def set_data(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, data: typing.Dict = None): chat, user = self.check_address(chat=chat, user=user) key = self.generate_key(chat, user, STATE_DATA_KEY) - redis = await self._get_adapter() if data: - await redis.set(key, json.dumps(data), ex=self._data_ttl) + await self._redis.set(key, json.dumps(data), ex=self._data_ttl) else: - await redis.delete(key) + await self._redis.delete(key) async def update_data(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, data: typing.Dict = None, **kwargs): @@ -483,8 +324,7 @@ class RedisStorage2(BaseStorage): default: typing.Optional[dict] = None) -> typing.Dict: chat, user = self.check_address(chat=chat, user=user) key = self.generate_key(chat, user, STATE_BUCKET_KEY) - redis = await self._get_adapter() - raw_result = await redis.get(key) + raw_result = await self._redis.get(key) if raw_result: return json.loads(raw_result) return default or {} @@ -493,11 +333,10 @@ class RedisStorage2(BaseStorage): bucket: typing.Dict = None): chat, user = self.check_address(chat=chat, user=user) key = self.generate_key(chat, user, STATE_BUCKET_KEY) - redis = await self._get_adapter() if bucket: - await redis.set(key, json.dumps(bucket), ex=self._bucket_ttl) + await self._redis.set(key, json.dumps(bucket), ex=self._bucket_ttl) else: - await redis.delete(key) + await self._redis.delete(key) async def update_bucket(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, @@ -515,13 +354,11 @@ class RedisStorage2(BaseStorage): :param full: clean DB or clean only states :return: """ - redis = await self._get_adapter() - if full: - await redis.flushdb() + await self._redis.flushdb() else: - keys = await redis.keys(self.generate_key('*')) - await redis.delete(*keys) + keys = await self._redis.keys(self.generate_key('*')) + await self._redis.delete(*keys) async def get_states_list(self) -> typing.List[typing.Tuple[str, str]]: """ @@ -529,10 +366,9 @@ class RedisStorage2(BaseStorage): :return: list of tuples where first element is chat id and second is user id """ - redis = await self._get_adapter() result = [] - keys = await redis.keys(self.generate_key('*', '*', STATE_KEY)) + keys = await self._redis.keys(self.generate_key('*', '*', STATE_KEY)) for item in keys: *_, chat, user, _ = item.split(':') result.append((chat, user)) diff --git a/aiogram/types/__init__.py b/aiogram/types/__init__.py index cb87afa0..9d99880f 100644 --- a/aiogram/types/__init__.py +++ b/aiogram/types/__init__.py @@ -21,6 +21,7 @@ from .chat_member import ChatMember, ChatMemberAdministrator, ChatMemberBanned, from .chat_member_updated import ChatMemberUpdated from .chat_permissions import ChatPermissions from .chat_photo import ChatPhoto +from .chat_shared import ChatShared from .chosen_inline_result import ChosenInlineResult from .contact import Contact from .dice import Dice, DiceEmoji @@ -32,9 +33,12 @@ from .force_reply import ForceReply from .forum_topic import ForumTopic from .forum_topic_closed import ForumTopicClosed from .forum_topic_created import ForumTopicCreated +from .forum_topic_edited import ForumTopicEdited from .forum_topic_reopened import ForumTopicReopened from .game import Game from .game_high_score import GameHighScore +from .general_forum_topic_hidden import GeneralForumTopicHidden +from .general_forum_topic_unhidden import GeneralForumTopicUnhidden from .inline_keyboard import InlineKeyboardButton, InlineKeyboardMarkup from .inline_query import InlineQuery from .inline_query_result import InlineQueryResult, InlineQueryResultArticle, InlineQueryResultAudio, \ @@ -68,7 +72,8 @@ from .photo_size import PhotoSize from .poll import PollOption, Poll, PollAnswer, PollType from .pre_checkout_query import PreCheckoutQuery from .proximity_alert_triggered import ProximityAlertTriggered -from .reply_keyboard import KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove, KeyboardButtonPollType +from .reply_keyboard import KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove, KeyboardButtonPollType, \ + KeyboardButtonRequestChat, KeyboardButtonRequestUser from .response_parameters import ResponseParameters from .sent_web_app_message import SentWebAppMessage from .shipping_address import ShippingAddress @@ -80,6 +85,7 @@ from .successful_payment import SuccessfulPayment from .update import AllowedUpdates, Update from .user import User from .user_profile_photos import UserProfilePhotos +from .user_shared import UserShared from .venue import Venue from .video import Video from .video_chat_ended import VideoChatEnded @@ -95,6 +101,7 @@ from .voice_chat_started import VoiceChatStarted from .web_app_data import WebAppData from .web_app_info import WebAppInfo from .webhook_info import WebhookInfo +from .write_access_allowed import WriteAccessAllowed __all__ = ( 'AllowedUpdates', @@ -185,6 +192,8 @@ __all__ = ( 'Invoice', 'KeyboardButton', 'KeyboardButtonPollType', + 'KeyboardButtonRequestChat', + 'KeyboardButtonRequestUser', 'LabeledPrice', 'Location', 'LoginUrl', @@ -248,6 +257,12 @@ __all__ = ( 'ForumTopicCreated', 'ForumTopicClosed', 'ForumTopicReopened', + 'ForumTopicEdited', + 'GeneralForumTopicHidden', + 'GeneralForumTopicUnhidden', + 'WriteAccessAllowed', + "ChatShared", + "UserShared", 'base', 'fields', ) diff --git a/aiogram/types/chat.py b/aiogram/types/chat.py index 02afc9c8..b6010ca5 100644 --- a/aiogram/types/chat.py +++ b/aiogram/types/chat.py @@ -48,6 +48,8 @@ class Chat(base.TelegramObject): can_set_sticker_set: base.Boolean = fields.Field() linked_chat_id: base.Integer = fields.Field() location: ChatLocation = fields.Field() + has_hidden_members: base.Boolean = fields.Field() + has_aggressive_anti_spam_enabled: base.Boolean = fields.Field() def __hash__(self): return self.id @@ -312,14 +314,18 @@ class Chat(base.TelegramObject): async def promote(self, user_id: base.Integer, is_anonymous: typing.Optional[base.Boolean] = None, + can_manage_chat: typing.Optional[base.Boolean] = None, can_change_info: typing.Optional[base.Boolean] = None, can_post_messages: typing.Optional[base.Boolean] = None, can_edit_messages: typing.Optional[base.Boolean] = None, can_delete_messages: typing.Optional[base.Boolean] = None, + can_manage_voice_chats: typing.Optional[base.Boolean] = None, can_invite_users: typing.Optional[base.Boolean] = None, can_restrict_members: typing.Optional[base.Boolean] = None, can_pin_messages: typing.Optional[base.Boolean] = None, - can_promote_members: typing.Optional[base.Boolean] = None) -> base.Boolean: + can_promote_members: typing.Optional[base.Boolean] = None, + can_manage_video_chats: typing.Optional[base.Boolean] = None, + can_manage_topics: typing.Optional[base.Boolean] = None,) -> base.Boolean: """ Use this method to promote or demote a user in a supergroup or a channel. The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. @@ -362,6 +368,7 @@ class Chat(base.TelegramObject): :return: Returns True on success. :rtype: :obj:`base.Boolean` """ + return await self.bot.promote_chat_member(self.id, user_id=user_id, is_anonymous=is_anonymous, @@ -372,7 +379,12 @@ class Chat(base.TelegramObject): can_invite_users=can_invite_users, can_restrict_members=can_restrict_members, can_pin_messages=can_pin_messages, - can_promote_members=can_promote_members) + can_promote_members=can_promote_members, + can_manage_chat=can_manage_chat, + can_manage_voice_chats=can_manage_voice_chats, + can_manage_video_chats=can_manage_video_chats, + can_manage_topics=can_manage_topics + ) async def set_permissions(self, permissions: ChatPermissions) -> base.Boolean: """ @@ -552,7 +564,7 @@ class Chat(base.TelegramObject): """ return await self.bot.delete_chat_sticker_set(self.id) - async def do(self, action: base.String) -> base.Boolean: + async def do(self, action: base.String, message_thread_id: typing.Optional[base.Integer] = None) -> base.Boolean: """ Use this method when you need to tell the user that something is happening on the bot's side. The status is set for 5 seconds or less @@ -565,6 +577,8 @@ class Chat(base.TelegramObject): :param action: Type of action to broadcast. :type action: :obj:`base.String` + :param message_thread_id: Unique identifier for the target message thread; supergroups only + :type message_thread_id: :obj:`typing.Optional[base.Integer]` :return: Returns True on success. :rtype: :obj:`base.Boolean` """ diff --git a/aiogram/types/chat_join_request.py b/aiogram/types/chat_join_request.py index 3f8ce846..6a523dbe 100644 --- a/aiogram/types/chat_join_request.py +++ b/aiogram/types/chat_join_request.py @@ -16,6 +16,7 @@ class ChatJoinRequest(base.TelegramObject): chat: Chat = fields.Field(base=Chat) from_user: User = fields.Field(alias="from", base=User) + user_chat_id: base.Integer = fields.Field() date: datetime = fields.DateTimeField() bio: base.String = fields.Field() invite_link: ChatInviteLink = fields.Field(base=ChatInviteLink) diff --git a/aiogram/types/chat_member.py b/aiogram/types/chat_member.py index 6424025f..7586acc6 100644 --- a/aiogram/types/chat_member.py +++ b/aiogram/types/chat_member.py @@ -189,7 +189,16 @@ class ChatMemberRestricted(ChatMember): can_pin_messages: base.Boolean = fields.Field() can_manage_topics: base.Boolean = fields.Field() can_send_messages: base.Boolean = fields.Field() + can_send_audios: base.Boolean = fields.Field() + can_send_documents: base.Boolean = fields.Field() + can_send_photos: base.Boolean = fields.Field() + can_send_videos: base.Boolean = fields.Field() + can_send_video_notes: base.Boolean = fields.Field() + can_send_voice_notes: base.Boolean = fields.Field() + + # warning! field was replaced: https://core.telegram.org/bots/api#february-3-2023 can_send_media_messages: base.Boolean = fields.Field() + can_send_polls: base.Boolean = fields.Field() can_send_other_messages: base.Boolean = fields.Field() can_add_web_page_previews: base.Boolean = fields.Field() diff --git a/aiogram/types/chat_permissions.py b/aiogram/types/chat_permissions.py index 4a404688..fc825a2a 100644 --- a/aiogram/types/chat_permissions.py +++ b/aiogram/types/chat_permissions.py @@ -9,7 +9,13 @@ class ChatPermissions(base.TelegramObject): https://core.telegram.org/bots/api#chatpermissions """ can_send_messages: base.Boolean = fields.Field() - can_send_media_messages: base.Boolean = fields.Field() + can_send_media_messages: base.Boolean = fields.Field() # Deprecated since Bot API 6.5 + can_send_audios: base.Boolean = fields.Field() + can_send_documents: base.Boolean = fields.Field() + can_send_photos: base.Boolean = fields.Field() + can_send_videos: base.Boolean = fields.Field() + can_send_video_notes: base.Boolean = fields.Field() + can_send_voice_notes: base.Boolean = fields.Field() can_send_polls: base.Boolean = fields.Field() can_send_other_messages: base.Boolean = fields.Field() can_add_web_page_previews: base.Boolean = fields.Field() @@ -21,6 +27,12 @@ class ChatPermissions(base.TelegramObject): def __init__(self, can_send_messages: base.Boolean = None, can_send_media_messages: base.Boolean = None, + can_send_audios: base.Boolean = None, + can_send_documents: base.Boolean = None, + can_send_photos: base.Boolean = None, + can_send_videos: base.Boolean = None, + can_send_video_notes: base.Boolean = None, + can_send_voice_notes: base.Boolean = None, can_send_polls: base.Boolean = None, can_send_other_messages: base.Boolean = None, can_add_web_page_previews: base.Boolean = None, @@ -32,6 +44,12 @@ class ChatPermissions(base.TelegramObject): super(ChatPermissions, self).__init__( can_send_messages=can_send_messages, can_send_media_messages=can_send_media_messages, + can_send_audios=can_send_audios, + can_send_documents=can_send_documents, + can_send_photos=can_send_photos, + can_send_videos=can_send_videos, + can_send_video_notes=can_send_video_notes, + can_send_voice_notes=can_send_voice_notes, can_send_polls=can_send_polls, can_send_other_messages=can_send_other_messages, can_add_web_page_previews=can_add_web_page_previews, @@ -39,4 +57,5 @@ class ChatPermissions(base.TelegramObject): can_invite_users=can_invite_users, can_pin_messages=can_pin_messages, can_manage_topics=can_manage_topics, + **kwargs ) diff --git a/aiogram/types/chat_shared.py b/aiogram/types/chat_shared.py new file mode 100644 index 00000000..b6d18673 --- /dev/null +++ b/aiogram/types/chat_shared.py @@ -0,0 +1,12 @@ +from . import base, fields + + +class ChatShared(base.TelegramObject): + """ + This object contains information about the chat whose identifier was + shared with the bot using a KeyboardButtonRequestChat button. + + https://core.telegram.org/bots/api#chatshared + """ + request_id: base.Integer = fields.Field() + user_id: base.Integer = fields.Field() diff --git a/aiogram/types/forum_topic_edited.py b/aiogram/types/forum_topic_edited.py new file mode 100644 index 00000000..b0472853 --- /dev/null +++ b/aiogram/types/forum_topic_edited.py @@ -0,0 +1,14 @@ +import typing + +from . import base +from . import fields + + +class ForumTopicEdited(base.TelegramObject): + """ + This object represents a service message about an edited forum topic. + + https://core.telegram.org/bots/api#forumtopicedited + """ + name: typing.Optional[base.String] = fields.Field() + icon_custom_emoji_id: typing.Optional[base.String] = fields.Field() diff --git a/aiogram/types/general_forum_topic_hidden.py b/aiogram/types/general_forum_topic_hidden.py new file mode 100644 index 00000000..27f4f8e6 --- /dev/null +++ b/aiogram/types/general_forum_topic_hidden.py @@ -0,0 +1,9 @@ +from . import base + + +class GeneralForumTopicHidden(base.TelegramObject): + """ + This object represents a service message about General forum topic hidden in the chat. Currently holds no information. + + https://core.telegram.org/bots/api#generalforumtopichidden + """ diff --git a/aiogram/types/general_forum_topic_unhidden.py b/aiogram/types/general_forum_topic_unhidden.py new file mode 100644 index 00000000..7fce1a7c --- /dev/null +++ b/aiogram/types/general_forum_topic_unhidden.py @@ -0,0 +1,9 @@ +from . import base + + +class GeneralForumTopicUnhidden(base.TelegramObject): + """ + This object represents a service message about General forum topic unhidden in the chat. Currently holds no information. + + https://core.telegram.org/bots/api#generalforumtopicunhidden + """ diff --git a/aiogram/types/input_media.py b/aiogram/types/input_media.py index 6804b460..876a8f41 100644 --- a/aiogram/types/input_media.py +++ b/aiogram/types/input_media.py @@ -107,6 +107,7 @@ class InputMediaAnimation(InputMedia): width: base.Integer = fields.Field() height: base.Integer = fields.Field() duration: base.Integer = fields.Field() + has_spoiler: typing.Optional[base.Boolean] = fields.Field() def __init__( self, @@ -118,12 +119,13 @@ class InputMediaAnimation(InputMedia): duration: base.Integer = None, parse_mode: base.String = None, caption_entities: typing.Optional[typing.List[MessageEntity]] = None, + has_spoiler: typing.Optional[base.Boolean] = None, **kwargs, ): super().__init__( type='animation', media=media, thumb=thumb, caption=caption, width=width, height=height, duration=duration, parse_mode=parse_mode, - caption_entities=caption_entities, conf=kwargs, + caption_entities=caption_entities, has_spoiler=has_spoiler, conf=kwargs, ) @@ -188,6 +190,7 @@ class InputMediaPhoto(InputMedia): https://core.telegram.org/bots/api#inputmediaphoto """ + has_spoiler: typing.Optional[base.Boolean] = fields.Field() def __init__( self, @@ -195,11 +198,12 @@ class InputMediaPhoto(InputMedia): caption: base.String = None, parse_mode: base.String = None, caption_entities: typing.Optional[typing.List[MessageEntity]] = None, + has_spoiler: typing.Optional[base.Boolean] = None, **kwargs, ): super().__init__( type='photo', media=media, caption=caption, parse_mode=parse_mode, - caption_entities=caption_entities, conf=kwargs, + caption_entities=caption_entities, has_spoiler=has_spoiler, conf=kwargs, ) @@ -213,6 +217,7 @@ class InputMediaVideo(InputMedia): height: base.Integer = fields.Field() duration: base.Integer = fields.Field() supports_streaming: base.Boolean = fields.Field() + has_spoiler: typing.Optional[base.Boolean] = fields.Field() def __init__( self, @@ -225,13 +230,14 @@ class InputMediaVideo(InputMedia): parse_mode: base.String = None, caption_entities: typing.Optional[typing.List[MessageEntity]] = None, supports_streaming: base.Boolean = None, + has_spoiler: typing.Optional[base.Boolean] = None, **kwargs, ): super().__init__( type='video', media=media, thumb=thumb, caption=caption, width=width, height=height, duration=duration, parse_mode=parse_mode, caption_entities=caption_entities, - supports_streaming=supports_streaming, conf=kwargs + supports_streaming=supports_streaming, has_spoiler=has_spoiler, conf=kwargs ) diff --git a/aiogram/types/message.py b/aiogram/types/message.py index b191b827..4313cdf4 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -8,11 +8,18 @@ from . import base, fields from .animation import Animation from .audio import Audio from .chat import Chat, ChatType +from .chat_shared import ChatShared from .contact import Contact from .dice import Dice from .document import Document from .force_reply import ForceReply +from .forum_topic_closed import ForumTopicClosed +from .forum_topic_created import ForumTopicCreated +from .forum_topic_edited import ForumTopicEdited +from .forum_topic_reopened import ForumTopicReopened from .game import Game +from .general_forum_topic_hidden import GeneralForumTopicHidden +from .general_forum_topic_unhidden import GeneralForumTopicUnhidden from .inline_keyboard import InlineKeyboardMarkup from .input_media import InputMedia, MediaGroup from .invoice import Invoice @@ -28,6 +35,7 @@ from .reply_keyboard import ReplyKeyboardMarkup, ReplyKeyboardRemove from .sticker import Sticker from .successful_payment import SuccessfulPayment from .user import User +from .user_shared import UserShared from .venue import Venue from .video import Video from .video_chat_ended import VideoChatEnded @@ -41,9 +49,7 @@ from .voice_chat_participants_invited import VoiceChatParticipantsInvited from .voice_chat_scheduled import VoiceChatScheduled from .voice_chat_started import VoiceChatStarted from .web_app_data import WebAppData -from .forum_topic_created import ForumTopicCreated -from .forum_topic_closed import ForumTopicClosed -from .forum_topic_reopened import ForumTopicReopened +from .write_access_allowed import WriteAccessAllowed from ..utils import helper from ..utils import markdown as md from ..utils.text_decorations import html_decoration, markdown_decoration @@ -108,6 +114,8 @@ class Message(base.TelegramObject): pinned_message: Message = fields.Field(base="Message") invoice: Invoice = fields.Field(base=Invoice) successful_payment: SuccessfulPayment = fields.Field(base=SuccessfulPayment) + user_shared: UserShared = fields.Field(base=UserShared) + chat_shared: ChatShared = fields.Field(base=ChatShared) connected_website: base.String = fields.Field() passport_data: PassportData = fields.Field(base=PassportData) proximity_alert_triggered: ProximityAlertTriggered = fields.Field(base=ProximityAlertTriggered) @@ -124,6 +132,11 @@ class Message(base.TelegramObject): video_chat_started: VideoChatStarted = fields.Field(base=VideoChatStarted) video_chat_ended: VideoChatEnded = fields.Field(base=VideoChatEnded) video_chat_participants_invited: VideoChatParticipantsInvited = fields.Field(base=VideoChatParticipantsInvited) + forum_topic_edited: ForumTopicEdited = fields.Field(base=ForumTopicEdited) + general_forum_topic_hidden: GeneralForumTopicHidden = fields.Field(base=GeneralForumTopicHidden) + general_forum_topic_unhidden: GeneralForumTopicUnhidden = fields.Field(base=GeneralForumTopicUnhidden) + write_access_allowed: WriteAccessAllowed = fields.Field(base=WriteAccessAllowed) + has_media_spoiler: base.Boolean = fields.Field() @property @functools.lru_cache() @@ -212,6 +225,18 @@ class Message(base.TelegramObject): return ContentType.VIDEO_CHAT_ENDED if self.video_chat_participants_invited: return ContentType.VIDEO_CHAT_PARTICIPANTS_INVITED + if self.forum_topic_edited: + return ContentType.FORUM_TOPIC_EDITED + if self.general_forum_topic_hidden: + return ContentType.GENERAL_FORUM_TOPIC_HIDDEN + if self.general_forum_topic_unhidden: + return ContentType.GENERAL_FORUM_TOPIC_UNHIDDEN + if self.write_access_allowed: + return ContentType.WRITE_ACCESS_ALLOWED + if self.chat_shared: + return ContentType.CHAT_SHARED + if self.user_shared: + return ContentType.USER_SHARED return ContentType.UNKNOWN @@ -1578,7 +1603,7 @@ class Message(base.TelegramObject): async def answer_chat_action( self, - action: base.String, + action: base.String, message_thread_id: typing.Optional[base.Integer] = None ) -> base.Boolean: """ Use this method when you need to tell the user that something is happening on the bot's side. @@ -1592,6 +1617,8 @@ class Message(base.TelegramObject): :param action: Type of action to broadcast :type action: :obj:`base.String` + :param message_thread_id: Unique identifier for the target message thread; supergroups only + :type message_thread_id: :obj:`typing.Optional[base.Integer]` :return: Returns True on success :rtype: :obj:`base.Boolean` """ @@ -3245,9 +3272,9 @@ class Message(base.TelegramObject): reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, None] = None, ) -> MessageId: return await self.bot.copy_message( chat_id=chat_id, @@ -3343,6 +3370,12 @@ class ContentType(helper.Helper): VIDEO_CHAT_STARTED = helper.Item() # video_chat_started VIDEO_CHAT_ENDED = helper.Item() # video_chat_ended VIDEO_CHAT_PARTICIPANTS_INVITED = helper.Item() # video_chat_participants_invited + FORUM_TOPIC_EDITED = helper.Item() # forum_topic_edited + GENERAL_FORUM_TOPIC_HIDDEN = helper.Item() # general_forum_topic_hidden + GENERAL_FORUM_TOPIC_UNHIDDEN = helper.Item() # general_forum_topic_unhidden + WRITE_ACCESS_ALLOWED = helper.Item() # write_access_allowed + CHAT_SHARED = helper.Item() # chat_shared + USER_SHARED = helper.Item() # user_shared UNKNOWN = helper.Item() # unknown ANY = helper.Item() # any @@ -3409,14 +3442,20 @@ class ContentTypes(helper.Helper): DELETE_CHAT_PHOTO = helper.ListItem() # delete_chat_photo GROUP_CHAT_CREATED = helper.ListItem() # group_chat_created PASSPORT_DATA = helper.ListItem() # passport_data - WEB_APP_DATA = helper.Item() # web_app_data + WEB_APP_DATA = helper.ListItem() # web_app_data FORUM_TOPIC_CREATED = helper.ListItem() # forum_topic_created FORUM_TOPIC_CLOSED = helper.ListItem() # forum_topic_closed FORUM_TOPIC_REOPENED = helper.ListItem() # forum_topic_reopened - VIDEO_CHAT_SCHEDULED = helper.Item() # video_chat_scheduled - VIDEO_CHAT_STARTED = helper.Item() # video_chat_started - VIDEO_CHAT_ENDED = helper.Item() # video_chat_ended - VIDEO_CHAT_PARTICIPANTS_INVITED = helper.Item() # video_chat_participants_invited + VIDEO_CHAT_SCHEDULED = helper.ListItem() # video_chat_scheduled + VIDEO_CHAT_STARTED = helper.ListItem() # video_chat_started + VIDEO_CHAT_ENDED = helper.ListItem() # video_chat_ended + VIDEO_CHAT_PARTICIPANTS_INVITED = helper.ListItem() # video_chat_participants_invited + FORUM_TOPIC_EDITED = helper.ListItem() # forum_topic_edited + GENERAL_FORUM_TOPIC_HIDDEN = helper.ListItem() # general_forum_topic_hidden + GENERAL_FORUM_TOPIC_UNHIDDEN = helper.ListItem() # general_forum_topic_unhidden + WRITE_ACCESS_ALLOWED = helper.ListItem() # write_access_allowed + CHAT_SHARED = helper.ListItem() # chat_shared + USER_SHARED = helper.ListItem() # user_shared UNKNOWN = helper.ListItem() # unknown ANY = helper.ListItem() # any diff --git a/aiogram/types/reply_keyboard.py b/aiogram/types/reply_keyboard.py index 1a8609be..e48bacc9 100644 --- a/aiogram/types/reply_keyboard.py +++ b/aiogram/types/reply_keyboard.py @@ -2,6 +2,7 @@ import typing from . import base from . import fields +from .chat_administrator_rights import ChatAdministratorRights from .web_app_info import WebAppInfo @@ -30,6 +31,7 @@ class ReplyKeyboardMarkup(base.TelegramObject): one_time_keyboard: base.Boolean = fields.Field() input_field_placeholder: base.String = fields.Field() selective: base.Boolean = fields.Field() + is_persistent: base.Boolean = fields.Field() def __init__(self, keyboard: 'typing.List[typing.List[KeyboardButton]]' = None, resize_keyboard: base.Boolean = None, @@ -37,6 +39,7 @@ class ReplyKeyboardMarkup(base.TelegramObject): input_field_placeholder: base.String = None, selective: base.Boolean = None, row_width: base.Integer = 3, + is_persistent: base.Boolean = None, conf=None): if conf is None: conf = {} @@ -46,6 +49,7 @@ class ReplyKeyboardMarkup(base.TelegramObject): one_time_keyboard=one_time_keyboard, input_field_placeholder=input_field_placeholder, selective=selective, + is_persistent=is_persistent, conf={'row_width': row_width, **conf}, ) @@ -102,6 +106,76 @@ class ReplyKeyboardMarkup(base.TelegramObject): return self + +class KeyboardButtonRequestUser(base.TelegramObject): + """ + This object defines the criteria used to request a suitable user. + The identifier of the selected user will be shared with the bot when + the corresponding button is pressed. + + https://core.telegram.org/bots/api#keyboardbuttonrequestuser + """ + request_id: base.Integer = fields.Field() + user_is_bot: base.Boolean = fields.Field() + user_is_premium: base.Boolean = fields.Field() + + def __init__( + self, + request_id: base.Integer, + user_is_bot: typing.Optional[base.Boolean] = None, + user_is_premium: typing.Optional[base.Boolean] = None, + **kwargs, + ): + super().__init__( + request_id=request_id, + user_is_bot=user_is_bot, + user_is_premium=user_is_premium, + **kwargs, + ) + + +class KeyboardButtonRequestChat(base.TelegramObject): + """ + This object defines the criteria used to request a suitable chat. + The identifier of the selected chat will be shared with the bot when + the corresponding button is pressed. + + https://core.telegram.org/bots/api#keyboardbuttonrequestchat + """ + request_id: base.Integer = fields.Field() + chat_is_channel: base.Boolean = fields.Field() + chat_is_forum: base.Boolean = fields.Field() + chat_has_username: base.Boolean = fields.Field() + chat_is_created: base.Boolean = fields.Field() + user_administrator_rights: ChatAdministratorRights = fields.Field() + bot_administrator_rights: ChatAdministratorRights = fields.Field() + bot_is_member: base.Boolean = fields.Field() + + def __init__( + self, + request_id: base.Integer, + chat_is_channel: base.Boolean, + chat_is_forum: typing.Optional[base.Boolean] = None, + chat_has_username: typing.Optional[base.Boolean] = None, + chat_is_created: typing.Optional[base.Boolean] = None, + user_administrator_rights: typing.Optional[ChatAdministratorRights] = None, + bot_administrator_rights: typing.Optional[ChatAdministratorRights] = None, + bot_is_member: typing.Optional[base.Boolean] = None, + **kwargs, + ): + super().__init__( + request_id=request_id, + chat_is_channel=chat_is_channel, + chat_is_forum=chat_is_forum, + chat_has_username=chat_has_username, + chat_is_created=chat_is_created, + user_administrator_rights=user_administrator_rights, + bot_administrator_rights=bot_administrator_rights, + bot_is_member=bot_is_member, + **kwargs, + ) + + class KeyboardButton(base.TelegramObject): """ This object represents one button of the reply keyboard. @@ -115,18 +189,24 @@ class KeyboardButton(base.TelegramObject): https://core.telegram.org/bots/api#keyboardbutton """ text: base.String = fields.Field() + request_user: KeyboardButtonRequestUser = fields.Field() + request_chat: KeyboardButtonRequestChat = fields.Field() request_contact: base.Boolean = fields.Field() request_location: base.Boolean = fields.Field() request_poll: KeyboardButtonPollType = fields.Field() web_app: WebAppInfo = fields.Field(base=WebAppInfo) def __init__(self, text: base.String, + request_user: typing.Optional[KeyboardButtonRequestUser] = None, + request_chat: typing.Optional[KeyboardButtonRequestChat] = None, request_contact: base.Boolean = None, request_location: base.Boolean = None, request_poll: KeyboardButtonPollType = None, web_app: WebAppInfo = None, **kwargs): super(KeyboardButton, self).__init__(text=text, + request_user=request_user, + request_chat=request_chat, request_contact=request_contact, request_location=request_location, request_poll=request_poll, @@ -134,6 +214,7 @@ class KeyboardButton(base.TelegramObject): **kwargs) + class ReplyKeyboardRemove(base.TelegramObject): """ Upon receiving a message with this object, Telegram clients will remove the current custom keyboard diff --git a/aiogram/types/user_shared.py b/aiogram/types/user_shared.py new file mode 100644 index 00000000..dcbf74de --- /dev/null +++ b/aiogram/types/user_shared.py @@ -0,0 +1,12 @@ +from . import base, fields + + +class UserShared(base.TelegramObject): + """ + This object contains information about the user whose identifier was + shared with the bot using a KeyboardButtonRequestUser button. + + https://core.telegram.org/bots/api#usershared + """ + request_id: base.Integer = fields.Field() + user_id: base.Integer = fields.Field() diff --git a/aiogram/types/write_access_allowed.py b/aiogram/types/write_access_allowed.py new file mode 100644 index 00000000..16fd6909 --- /dev/null +++ b/aiogram/types/write_access_allowed.py @@ -0,0 +1,9 @@ +from . import base + + +class WriteAccessAllowed(base.TelegramObject): + """ + This object represents a service message about a user allowing a bot added to the attachment menu to write messages. Currently holds no information. + + https://core.telegram.org/bots/api#writeaccessallowed + """ diff --git a/docs/source/index.rst b/docs/source/index.rst index c5cb2055..3b618877 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -22,7 +22,7 @@ Welcome to aiogram's documentation! :target: https://pypi.python.org/pypi/aiogram :alt: Supported python versions - .. image:: https://img.shields.io/badge/Telegram%20Bot%20API-6.3-blue.svg?style=flat-square&logo=telegram + .. image:: https://img.shields.io/badge/Telegram%20Bot%20API-6.4-blue.svg?style=flat-square&logo=telegram :target: https://core.telegram.org/bots/api :alt: Telegram Bot API diff --git a/tests/conftest.py b/tests/conftest.py index b2581bd9..61cd2b2f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,7 @@ def pytest_addoption(parser): default=None, help="run tests which require redis connection", ) + parser.addini("asyncio_mode", "", default='auto') def pytest_configure(config): diff --git a/tests/contrib/fsm_storage/test_storage.py b/tests/contrib/fsm_storage/test_storage.py deleted file mode 100644 index ae06025c..00000000 --- a/tests/contrib/fsm_storage/test_storage.py +++ /dev/null @@ -1,89 +0,0 @@ -import aioredis -import pytest -import pytest_asyncio -from pytest_lazyfixture import lazy_fixture - -from aiogram.contrib.fsm_storage.memory import MemoryStorage -from aiogram.contrib.fsm_storage.redis import RedisStorage, RedisStorage2 - - -@pytest_asyncio.fixture() -@pytest.mark.redis -async def redis_store(redis_options): - if int(aioredis.__version__.split(".")[0]) == 2: - pytest.skip('aioredis v2 is not supported.') - return - s = RedisStorage(**redis_options) - try: - yield s - finally: - conn = await s.redis() - await conn.execute('FLUSHDB') - await s.close() - await s.wait_closed() - - -@pytest_asyncio.fixture() -@pytest.mark.redis -async def redis_store2(redis_options): - s = RedisStorage2(**redis_options) - try: - yield s - finally: - conn = await s.redis() - await conn.flushdb() - await s.close() - await s.wait_closed() - - -@pytest_asyncio.fixture() -async def memory_store(): - yield MemoryStorage() - - -@pytest.mark.parametrize( - "store", [ - lazy_fixture('redis_store'), - lazy_fixture('redis_store2'), - lazy_fixture('memory_store'), - ] -) -class TestStorage: - @pytest.mark.asyncio - async def test_set_get(self, store): - assert await store.get_data(chat='1234') == {} - await store.set_data(chat='1234', data={'foo': 'bar'}) - assert await store.get_data(chat='1234') == {'foo': 'bar'} - - @pytest.mark.asyncio - async def test_reset(self, store): - await store.set_data(chat='1234', data={'foo': 'bar'}) - await store.reset_data(chat='1234') - assert await store.get_data(chat='1234') == {} - - @pytest.mark.asyncio - async def test_reset_empty(self, store): - await store.reset_data(chat='1234') - assert await store.get_data(chat='1234') == {} - - -@pytest.mark.parametrize( - "store", [ - lazy_fixture('redis_store'), - lazy_fixture('redis_store2'), - ] -) -class TestRedisStorage2: - @pytest.mark.asyncio - async def test_close_and_open_connection(self, store): - await store.set_data(chat='1234', data={'foo': 'bar'}) - assert await store.get_data(chat='1234') == {'foo': 'bar'} - pool_id = id(store._redis) - await store.close() - await store.wait_closed() - - # new pool will be open at this point - assert await store.get_data(chat='1234') == { - 'foo': 'bar', - } - assert id(store._redis) != pool_id diff --git a/tests/test_bot.py b/tests/test_bot.py index 9e4d45de..1a61fd2d 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -3,8 +3,6 @@ import pytest from aiogram import Bot, types from . import BOT_ID, FakeTelegram -pytestmark = pytest.mark.asyncio - async def test_get_me(bot: Bot): """ getMe method test """ diff --git a/tests/test_bot/test_bot_download_file.py b/tests/test_bot/test_bot_download_file.py index c5b5077c..ff50dc9e 100644 --- a/tests/test_bot/test_bot_download_file.py +++ b/tests/test_bot/test_bot_download_file.py @@ -13,8 +13,6 @@ from aiogram.utils.json import json from tests import TOKEN from tests.types.dataset import FILE -pytestmark = pytest.mark.asyncio - @pytest_asyncio.fixture(name='bot') async def bot_fixture(): diff --git a/tests/test_contrib/test_fsm_storage/test_storage.py b/tests/test_contrib/test_fsm_storage/test_storage.py new file mode 100644 index 00000000..68ce8a81 --- /dev/null +++ b/tests/test_contrib/test_fsm_storage/test_storage.py @@ -0,0 +1,157 @@ +import aioredis +import pytest +import pytest_asyncio +from pytest_lazyfixture import lazy_fixture +from redis.asyncio.connection import Connection, ConnectionPool + +from aiogram.contrib.fsm_storage.memory import MemoryStorage +from aiogram.contrib.fsm_storage.redis import RedisStorage, RedisStorage2 + + +@pytest_asyncio.fixture() +@pytest.mark.redis +async def redis_store(redis_options): + if int(aioredis.__version__.split(".")[0]) == 2: + pytest.skip('aioredis v2 is not supported.') + return + s = RedisStorage(**redis_options) + try: + yield s + finally: + conn = await s.redis() + await conn.execute('FLUSHDB') + await s.close() + await s.wait_closed() + + +@pytest_asyncio.fixture() +@pytest.mark.redis +async def redis_store2(redis_options): + s = RedisStorage2(**redis_options) + try: + yield s + finally: + conn = await s.redis() + await conn.flushdb() + await s.close() + await s.wait_closed() + + +@pytest_asyncio.fixture() +async def memory_store(): + yield MemoryStorage() + + +@pytest.mark.parametrize( + "store", [ + lazy_fixture('redis_store'), + lazy_fixture('redis_store2'), + lazy_fixture('memory_store'), + ] +) +class TestStorage: + async def test_set_get(self, store): + assert await store.get_data(chat='1234') == {} + await store.set_data(chat='1234', data={'foo': 'bar'}) + assert await store.get_data(chat='1234') == {'foo': 'bar'} + + async def test_reset(self, store): + await store.set_data(chat='1234', data={'foo': 'bar'}) + await store.reset_data(chat='1234') + assert await store.get_data(chat='1234') == {} + + async def test_reset_empty(self, store): + await store.reset_data(chat='1234') + assert await store.get_data(chat='1234') == {} + + +@pytest.mark.parametrize( + "store", [ + lazy_fixture('redis_store'), + lazy_fixture('redis_store2'), + ] +) +class TestRedisStorage2: + async def test_close_and_open_connection(self, store: RedisStorage2): + await store.set_data(chat='1234', data={'foo': 'bar'}) + assert await store.get_data(chat='1234') == {'foo': 'bar'} + await store.close() + await store.wait_closed() + + pool: ConnectionPool = store._redis.connection_pool + + # noinspection PyUnresolvedReferences + assert not pool._in_use_connections + + # noinspection PyUnresolvedReferences + if pool._available_connections: + # noinspection PyUnresolvedReferences + connection: Connection = pool._available_connections[0] + assert connection.is_connected is False + + @pytest.mark.parametrize( + "chat_id,user_id,state", + [ + [12345, 54321, "foo"], + [12345, 54321, None], + [12345, None, "foo"], + [None, 54321, "foo"], + ], + ) + async def test_set_get_state(self, chat_id, user_id, state, store): + await store.reset_state(chat=chat_id, user=user_id, with_data=False) + + await store.set_state(chat=chat_id, user=user_id, state=state) + s = await store.get_state(chat=chat_id, user=user_id) + assert s == state + + @pytest.mark.parametrize( + "chat_id,user_id,data,new_data", + [ + [12345, 54321, {"foo": "bar"}, {"bar": "foo"}], + [12345, 54321, None, None], + [12345, 54321, {"foo": "bar"}, None], + [12345, 54321, None, {"bar": "foo"}], + [12345, None, {"foo": "bar"}, {"bar": "foo"}], + [None, 54321, {"foo": "bar"}, {"bar": "foo"}], + ], + ) + async def test_set_get_update_data(self, chat_id, user_id, data, new_data, store): + await store.reset_state(chat=chat_id, user=user_id, with_data=True) + + await store.set_data(chat=chat_id, user=user_id, data=data) + d = await store.get_data(chat=chat_id, user=user_id) + assert d == (data or {}) + + await store.update_data(chat=chat_id, user=user_id, data=new_data) + d = await store.get_data(chat=chat_id, user=user_id) + updated_data = (data or {}) + updated_data.update(new_data or {}) + assert d == updated_data + + async def test_has_bucket(self, store): + assert store.has_bucket() + + @pytest.mark.parametrize( + "chat_id,user_id,data,new_data", + [ + [12345, 54321, {"foo": "bar"}, {"bar": "foo"}], + [12345, 54321, None, None], + [12345, 54321, {"foo": "bar"}, None], + [12345, 54321, None, {"bar": "foo"}], + [12345, None, {"foo": "bar"}, {"bar": "foo"}], + [None, 54321, {"foo": "bar"}, {"bar": "foo"}], + ], + ) + async def test_set_get_update_bucket(self, chat_id, user_id, data, new_data, store): + await store.reset_state(chat=chat_id, user=user_id, with_data=True) + + await store.set_bucket(chat=chat_id, user=user_id, bucket=data) + d = await store.get_bucket(chat=chat_id, user=user_id) + assert d == (data or {}) + + await store.update_bucket(chat=chat_id, user=user_id, bucket=new_data) + d = await store.get_bucket(chat=chat_id, user=user_id) + updated_bucket = (data or {}) + updated_bucket.update(new_data or {}) + assert d == updated_bucket diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index e38d7c4a..aa8dd384 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -2,8 +2,6 @@ import pytest from aiogram import Dispatcher, Bot -pytestmark = pytest.mark.asyncio - class TestDispatcherInit: async def test_successful_init(self, bot): diff --git a/tests/test_dispatcher/test_middlewares/test_lifetimecontroller.py b/tests/test_dispatcher/test_middlewares/test_lifetimecontroller.py index b4519a9b..647e4733 100644 --- a/tests/test_dispatcher/test_middlewares/test_lifetimecontroller.py +++ b/tests/test_dispatcher/test_middlewares/test_lifetimecontroller.py @@ -1,10 +1,7 @@ from unittest.mock import AsyncMock -import pytest from aiogram.dispatcher.middlewares import LifetimeControllerMiddleware -pytestmark = pytest.mark.asyncio - async def test_no_skip(): class Middleware(LifetimeControllerMiddleware): diff --git a/tests/test_filters.py b/tests/test_filters.py index f29e1982..b135b6be 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -6,9 +6,6 @@ import pytest from aiogram.dispatcher.filters import Text, CommandStart from aiogram.types import Message, CallbackQuery, InlineQuery, Poll -# enable asyncio mode -pytestmark = pytest.mark.asyncio - def data_sample_1(): return [ diff --git a/tests/test_message.py b/tests/test_message.py index aea11302..3e29885e 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -4,8 +4,6 @@ import pytest_asyncio from aiogram import Bot, types from . import FakeTelegram -pytestmark = pytest.mark.asyncio - @pytest_asyncio.fixture(name="message") async def message_fixture(bot: Bot): diff --git a/tests/test_utils/test_deep_linking.py b/tests/test_utils/test_deep_linking.py index f4aa14f1..64effbab 100644 --- a/tests/test_utils/test_deep_linking.py +++ b/tests/test_utils/test_deep_linking.py @@ -8,8 +8,6 @@ from aiogram.utils.deep_linking import ( ) from tests.types import dataset -# enable asyncio mode -pytestmark = pytest.mark.asyncio PAYLOADS = [ 'foo', diff --git a/tests/types/test_chat.py b/tests/types/test_chat.py index c8e20146..c31f4f35 100644 --- a/tests/types/test_chat.py +++ b/tests/types/test_chat.py @@ -1,10 +1,7 @@ -import pytest - from aiogram import Bot, types from .dataset import CHAT, FULL_CHAT from .. import FakeTelegram -pytestmark = pytest.mark.asyncio chat = types.Chat(**CHAT) diff --git a/tests/types/test_mixins.py b/tests/types/test_mixins.py index 84ec991a..28dee2b8 100644 --- a/tests/types/test_mixins.py +++ b/tests/types/test_mixins.py @@ -14,8 +14,6 @@ from aiogram.utils.json import json from tests import TOKEN from tests.types.dataset import FILE -pytestmark = pytest.mark.asyncio - @pytest_asyncio.fixture(name='bot') async def bot_fixture():